first commit
This commit is contained in:
@@ -0,0 +1,398 @@
|
||||
# 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
|
||||
Reference in New Issue
Block a user