# 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 `) | 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= ADMIN_USERNAME=admin ADMIN_PASSWORD= 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