# 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

```typescript
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

```json
{ "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/login` if token expired

---

## Frontend Changes (Public Site)

1. **Content hook** — `useContent()` fetches `/api/content` on mount, shows loading skeleton, caches in React context
2. **Refactor `App.jsx`** — Replace hardcoded strings/arrays with `content.hero`, `content.services`, etc.
3. **Refactor `FeaturedGallery.jsx`** — Accept `content.gallery` as prop instead of inline data
4. **Image URLs** — All image paths become absolute URLs from API (`/uploads/...` or external)
5. **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

```env
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)

```nginx
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

```bash
pm2 start server/index.js --name citpl-api
pm2 save
```

### Deploy Script

```bash
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

1. Admin can log in at `/admin` with username/password
2. Admin can edit any section and save — changes appear on public site within seconds (page refresh)
3. Admin can upload/replace images and hero video
4. Public site loads all content from API with no hardcoded copy remaining
5. API + site run stably on a 1GB RAM VPS via PM2
