11 KiB
11 KiB
Dynamic CMS with Admin Panel — Design Spec
Date: 2026-07-09
Status: Approved (architecture)
Goal: Convert the static CITPL marketing site into a dynamic site with instant content updates via a password-protected admin panel hosted on a VPS.
Requirements
| Requirement | Decision |
|---|---|
| Content updates | Instant — no rebuild required |
| Data model | Latest data only (no versioning/drafts) |
| Hosting | Self-hosted VPS |
| Admin users | Single admin login |
| Editable content | Text, links, and image/video uploads |
| Public site | Existing React SPA, same visual design |
Architecture
┌──────────────────────────────────────────────────────────┐
│ VPS (Ubuntu + Nginx) │
│ │
│ citpl.com/ citpl.com/admin │
│ │ │ │
│ ▼ ▼ │
│ React SPA (dist/) Admin SPA (dist/admin/) │
│ │ │ │
│ └────────────┬────────────┘ │
│ ▼ │
│ Express API :3001 │
│ GET /api/content (public) │
│ POST /api/auth/login (public) │
│ PUT /api/content/:section (auth required) │
│ POST /api/upload (auth required) │
│ │ │
│ ┌──────────┴──────────┐ │
│ ▼ ▼ │
│ SQLite (content.db) /var/www/citpl/uploads/ │
│ - admin user images, videos, logos │
│ - content sections │
└──────────────────────────────────────────────────────────┘
Tech Stack
| Layer | Technology |
|---|---|
| Public frontend | React 19 + Vite 8 (existing) |
| Admin frontend | React 19 + Vite 8 (new entry point) |
| API | Express.js 4 |
| Database | better-sqlite3 (SQLite) |
| Auth | bcrypt + jsonwebtoken (JWT, 24h expiry) |
| File uploads | multer (max 50MB, images + video) |
| Process manager | PM2 |
| Reverse proxy | Nginx |
Content Schema
All site content is stored as JSON documents in a single content table, keyed by section ID. On first boot, the API seeds the database from current hardcoded values.
Sections
type SiteContent = {
site: {
title: string
logo: string // URL path e.g. /uploads/logo.svg
copyright: string
}
navigation: Array<{
label: string
href: string
type: 'hash' | 'modal'
}>
hero: {
video: string
badge: string
headline: string
subheadline: string
}
metrics: Array<{
value: string
label: string
}>
about: {
heading: string
badgeImage: string
badgeAlt: string
backgroundImage: string
}
partners: {
heading: string
subheading: string
body: string
logos: Array<{ name: string; image: string }>
}
certifications: {
heading: string
items: Array<{ image: string; alt: string }>
}
services: {
eyebrow: string
title: string
intro: string
items: Array<{
id: string
number: string
title: string
boldStatement: string
description: string
capabilities: string[]
cta: string
glowColor: string
borderColor: string
}>
}
whyUs: {
eyebrow: string
title: string
cards: Array<{ title: string; description: string }>
images: Array<{ src: string; alt: string }>
}
gallery: {
eyebrow: string
title: string
subtitle: string
items: Array<{
id: number
title: string
category: string
excerpt: string
image: string
readTime: string
}>
}
products: {
eyebrow: string
title: string
subtitle: string
items: Array<{
name: string
description: string
image: string
stats: Array<{ value: string; label: string }>
}>
}
insights: {
title: string
subtitle: string
items: Array<{
category: string
title: string
date: string
image: string
excerpt: string
}>
}
footer: {
cta: {
headline: string
body: string
image: string
primaryButton: string
secondaryButton: string
}
quickLinks: Array<{ label: string; href: string }>
productLinks: Array<{ label: string; href: string }>
social: Array<{ platform: string; url: string }>
legal: Array<{ label: string; href: string }>
}
}
API Design
Public Endpoints
| Method | Path | Description |
|---|---|---|
| GET | /api/content |
Returns full SiteContent object |
| GET | /api/health |
Health check for PM2/Nginx |
Auth Endpoints
| Method | Path | Body | Response |
|---|---|---|---|
| POST | /api/auth/login |
{ username, password } |
{ token, expiresAt } |
| GET | /api/auth/me |
Bearer token | { username } |
Protected Endpoints (require Authorization: Bearer <token>)
| Method | Path | Body | Description |
|---|---|---|---|
| PUT | /api/content/:section |
Section JSON | Update one section (e.g. hero, services) |
| PUT | /api/content |
Full SiteContent |
Bulk update all sections |
| POST | /api/upload |
multipart/form-data field file |
Upload image/video, returns { url } |
| DELETE | /api/upload/:filename |
— | Remove uploaded file |
Error Responses
{ "error": "Human-readable message", "code": "UNAUTHORIZED" }
HTTP codes: 400 (validation), 401 (auth), 404 (not found), 413 (file too large), 500 (server).
Admin Panel
URL: /admin
Login: Single username/password set via environment variable on first deploy.
Pages
| Page | Edits |
|---|---|
| Dashboard | Quick links to all sections, last-updated timestamp |
| Hero & Metrics | Headline, video upload, badge text, stat values |
| About & Partners | Headings, partner logos (upload + reorder) |
| Certifications | Upload/replace badge images |
| Services | CRUD for all 6 service cards |
| Why Us | 4 value cards + 3 image uploads |
| Gallery | CRUD gallery items with image upload |
| Products | 3 product cards with stats and screenshots |
| Insights | CRUD blog cards with image upload |
| Footer & Links | CTA text, social URLs, quick links |
| Settings | Change admin password |
Admin UX
- Sidebar navigation matching section list above
- Inline form fields with save button per section
- Image fields show current preview + upload/replace button
- Toast notifications on save success/failure
- Redirect to
/admin/loginif token expired
Frontend Changes (Public Site)
- Content hook —
useContent()fetches/api/contenton mount, shows loading skeleton, caches in React context - Refactor
App.jsx— Replace hardcoded strings/arrays withcontent.hero,content.services, etc. - Refactor
FeaturedGallery.jsx— Acceptcontent.galleryas prop instead of inline data - Image URLs — All image paths become absolute URLs from API (
/uploads/...or external) - Fallback — If API unreachable, show cached content or graceful error banner (not blank page)
New Files
server/
index.js # Express app entry
db.js # SQLite setup + seed
auth.js # JWT middleware
routes/
content.js
auth.js
upload.js
seed/
default-content.json # Current site content extracted from App.jsx
src/
context/ContentContext.jsx
hooks/useContent.js
admin/
main.jsx
App.jsx
pages/
Login.jsx
Dashboard.jsx
HeroEditor.jsx
ServicesEditor.jsx
... (one per section)
components/
ImageUpload.jsx
SectionForm.jsx
AdminLayout.jsx
Security
| Concern | Mitigation |
|---|---|
| Brute-force login | Rate limit /api/auth/login (5 attempts/min per IP) |
| JWT theft | HttpOnly not possible cross-origin; use short 24h expiry + secure flag in production |
| File uploads | Whitelist MIME types: image/*, video/mp4; max 50MB; sanitize filenames |
| SQL injection | Parameterized queries only (better-sqlite3 prepared statements) |
| Admin password | bcrypt hash (cost 12); initial password via ADMIN_PASSWORD env var |
| CORS | Restrict to production domain only |
Deployment (VPS)
Environment Variables
PORT=3001
JWT_SECRET=<random-64-char-string>
ADMIN_USERNAME=admin
ADMIN_PASSWORD=<set-on-first-deploy>
UPLOAD_DIR=/var/www/citpl/uploads
DB_PATH=/var/www/citpl/data/content.db
NODE_ENV=production
CORS_ORIGIN=https://citpl.com
Nginx Config (summary)
server {
listen 80;
server_name citpl.com;
# Public site
location / {
root /var/www/citpl/dist;
try_files $uri $uri/ /index.html;
}
# Admin panel
location /admin {
alias /var/www/citpl/dist-admin;
try_files $uri $uri/ /admin/index.html;
}
# API
location /api {
proxy_pass http://127.0.0.1:3001;
}
# Uploaded media
location /uploads {
alias /var/www/citpl/uploads;
expires 30d;
}
}
PM2
pm2 start server/index.js --name citpl-api
pm2 save
Deploy Script
npm run build # public site → dist/
npm run build:admin # admin panel → dist-admin/
rsync dist/ dist-admin/ server/ → VPS
pm2 restart citpl-api
Out of Scope (v1)
- Multiple admin users / roles
- Content versioning or drafts
- Careers / Strategy Call form backend (keep EmailJS as-is)
- Blog detail pages (insights remain cards only)
- SSL setup (assumed handled separately via Certbot)
- CI/CD pipeline (manual deploy script provided)
Success Criteria
- Admin can log in at
/adminwith username/password - Admin can edit any section and save — changes appear on public site within seconds (page refresh)
- Admin can upload/replace images and hero video
- Public site loads all content from API with no hardcoded copy remaining
- API + site run stably on a 1GB RAM VPS via PM2