Initial commit: MOPC platform with Docker deployment setup
Full Next.js 15 platform with tRPC, Prisma, PostgreSQL, NextAuth. Includes production Dockerfile (multi-stage, port 7600), docker-compose with registry-based image pull, Gitea Actions CI workflow, nginx config for portal.monaco-opc.com, deployment scripts, and DEPLOYMENT.md guide. Co-Authored-By: Claude Opus 4.5 <noreply@anthropic.com>
This commit is contained in:
374
docs/architecture/README.md
Normal file
374
docs/architecture/README.md
Normal file
@@ -0,0 +1,374 @@
|
||||
# MOPC Platform - Architecture Overview
|
||||
|
||||
## System Overview
|
||||
|
||||
The MOPC Platform is a secure jury voting system built as a modern full-stack TypeScript application for the **Monaco Ocean Protection Challenge**. It follows a layered architecture with clear separation of concerns.
|
||||
|
||||
**Phase 1 Focus**: Jury selection rounds (130→60→6 projects)
|
||||
**Domain**: `monaco-opc.com`
|
||||
|
||||
## Finalized Decisions
|
||||
|
||||
| Decision | Choice |
|
||||
|----------|--------|
|
||||
| **Domain** | `monaco-opc.com` |
|
||||
| **Evaluation Criteria** | Fully configurable per round (admin defines) |
|
||||
| **CSV Import** | Flexible column mapping (admin maps columns) |
|
||||
| **Max File Size** | 500MB (for videos) |
|
||||
| **Observer Role** | Included in Phase 1 |
|
||||
| **First Admin** | Database seed script |
|
||||
| **Past Evaluations** | Visible read-only after submit |
|
||||
| **Grace Period** | Admin-configurable per juror/project |
|
||||
| **Smart Assignment** | AI-powered (GPT) + Smart Algorithm fallback |
|
||||
| **AI Provider** | Admin-configurable (OpenAI GPT) |
|
||||
| **AI Data Privacy** | All data anonymized/encoded before sending to GPT |
|
||||
|
||||
## Tech Stack
|
||||
|
||||
| Layer | Technology | Version |
|
||||
|-------|-----------|---------|
|
||||
| **Framework** | Next.js (App Router) | 15.x |
|
||||
| **Language** | TypeScript | 5.x |
|
||||
| **UI Components** | shadcn/ui | latest |
|
||||
| **Styling** | Tailwind CSS | 3.x |
|
||||
| **API Layer** | tRPC | 11.x |
|
||||
| **Database** | PostgreSQL | 16.x |
|
||||
| **ORM** | Prisma | 6.x |
|
||||
| **Authentication** | NextAuth.js (Auth.js) | 5.x |
|
||||
| **AI** | OpenAI GPT | 4.x SDK |
|
||||
| **File Storage** | MinIO (S3-compatible) | External |
|
||||
| **Email** | Nodemailer + Poste.io | External |
|
||||
| **Animation** | Motion (Framer Motion) | 11.x |
|
||||
| **Notifications** | Sonner | 1.x |
|
||||
| **Command Palette** | cmdk | 1.x |
|
||||
| **Containerization** | Docker Compose | 2.x |
|
||||
| **Reverse Proxy** | Nginx | External |
|
||||
|
||||
## Brand Identity
|
||||
|
||||
### Colors
|
||||
| Name | Hex | Usage |
|
||||
|------|-----|-------|
|
||||
| Primary Red | `#de0f1e` | Accents, CTAs, alerts |
|
||||
| Dark Blue | `#053d57` | Headers, sidebar, primary text |
|
||||
| White | `#fefefe` | Backgrounds |
|
||||
| Teal | `#557f8c` | Secondary elements, links |
|
||||
|
||||
### Typography
|
||||
- **Headings**: Montserrat (600/700 weight)
|
||||
- **Body**: Montserrat Light (300/400 weight)
|
||||
|
||||
## High-Level Architecture Diagram
|
||||
|
||||
```
|
||||
┌─────────────────────────────────────────────────────────────────────────────┐
|
||||
│ PRESENTATION LAYER │
|
||||
├─────────────────────────────────────────────────────────────────────────────┤
|
||||
│ │
|
||||
│ ┌──────────────────┐ ┌──────────────────┐ ┌──────────────────┐ │
|
||||
│ │ Admin Views │ │ Jury Views │ │ Auth Views │ │
|
||||
│ │ │ │ │ │ │ │
|
||||
│ │ - Dashboard │ │ - Project List │ │ - Login │ │
|
||||
│ │ - Rounds │ │ - Project View │ │ - Magic Link │ │
|
||||
│ │ - Projects │ │ - Evaluation │ │ - Verify │ │
|
||||
│ │ - Jury Mgmt │ │ - My Progress │ │ │ │
|
||||
│ │ - Assignments │ │ │ │ │ │
|
||||
│ │ - Reports │ │ │ │ │ │
|
||||
│ └──────────────────┘ └──────────────────┘ └──────────────────┘ │
|
||||
│ │
|
||||
│ Built with: Next.js App Router + React Server Components + shadcn/ui │
|
||||
└─────────────────────────────────────────────────────────────────────────────┘
|
||||
│
|
||||
▼
|
||||
┌─────────────────────────────────────────────────────────────────────────────┐
|
||||
│ API LAYER │
|
||||
├─────────────────────────────────────────────────────────────────────────────┤
|
||||
│ │
|
||||
│ ┌──────────────────────────────────────────────────────────────────────┐ │
|
||||
│ │ tRPC Router │ │
|
||||
│ │ │ │
|
||||
│ │ ┌────────┐ ┌────────┐ ┌────────┐ ┌────────┐ ┌────────┐ ┌────────┐ │ │
|
||||
│ │ │program │ │ round │ │project │ │ user │ │assign- │ │evalua- │ │ │
|
||||
│ │ │Router │ │Router │ │Router │ │Router │ │ment │ │tion │ │ │
|
||||
│ │ │ │ │ │ │ │ │ │ │Router │ │Router │ │ │
|
||||
│ │ └────────┘ └────────┘ └────────┘ └────────┘ └────────┘ └────────┘ │ │
|
||||
│ └──────────────────────────────────────────────────────────────────────┘ │
|
||||
│ │ │
|
||||
│ ┌──────────────────┐ ┌──────────────────┐ ┌──────────────────┐ │
|
||||
│ │ Auth Middleware │ │ RBAC Middleware │ │ Audit Logger │ │
|
||||
│ │ (NextAuth.js) │ │ (role checks) │ │ (all actions) │ │
|
||||
│ └──────────────────┘ └──────────────────┘ └──────────────────┘ │
|
||||
│ │
|
||||
└─────────────────────────────────────────────────────────────────────────────┘
|
||||
│
|
||||
▼
|
||||
┌─────────────────────────────────────────────────────────────────────────────┐
|
||||
│ SERVICE LAYER │
|
||||
├─────────────────────────────────────────────────────────────────────────────┤
|
||||
│ │
|
||||
│ ┌────────────┐ ┌────────────┐ ┌────────────┐ ┌────────────┐ │
|
||||
│ │ Program │ │ Round │ │ Assignment │ │ Evaluation │ │
|
||||
│ │ Service │ │ Service │ │ Service │ │ Service │ │
|
||||
│ └────────────┘ └────────────┘ └────────────┘ └────────────┘ │
|
||||
│ │
|
||||
│ ┌────────────┐ ┌────────────┐ ┌────────────┐ ┌────────────┐ │
|
||||
│ │ File │ │ Email │ │ Export │ │ Audit │ │
|
||||
│ │ Service │ │ Service │ │ Service │ │ Service │ │
|
||||
│ └────────────┘ └────────────┘ └────────────┘ └────────────┘ │
|
||||
│ │
|
||||
└─────────────────────────────────────────────────────────────────────────────┘
|
||||
│
|
||||
▼
|
||||
┌─────────────────────────────────────────────────────────────────────────────┐
|
||||
│ DATA LAYER │
|
||||
├─────────────────────────────────────────────────────────────────────────────┤
|
||||
│ │
|
||||
│ ┌──────────────────────────────────────────────────────────────────────┐ │
|
||||
│ │ Prisma ORM │ │
|
||||
│ │ │ │
|
||||
│ │ Type-safe database access, migrations, query building │ │
|
||||
│ └──────────────────────────────────────────────────────────────────────┘ │
|
||||
│ │ │ │
|
||||
│ ▼ ▼ │
|
||||
│ ┌──────────────────┐ ┌──────────────────┐ │
|
||||
│ │ PostgreSQL │ │ MinIO │ │
|
||||
│ │ (Database) │ │ (File Store) │ │
|
||||
│ │ │ │ │ │
|
||||
│ │ - Users │ │ - PDFs │ │
|
||||
│ │ - Programs │ │ - Videos │ │
|
||||
│ │ - Rounds │ │ - Exports │ │
|
||||
│ │ - Projects │ │ │ │
|
||||
│ │ - Evaluations │ │ │ │
|
||||
│ │ - Audit Logs │ │ │ │
|
||||
│ └──────────────────┘ └──────────────────┘ │
|
||||
│ │
|
||||
└─────────────────────────────────────────────────────────────────────────────┘
|
||||
```
|
||||
|
||||
## Component Responsibilities
|
||||
|
||||
### Presentation Layer
|
||||
|
||||
| Component | Responsibility |
|
||||
|-----------|----------------|
|
||||
| **Admin Views** | Program/round management, project import, jury management, assignments, dashboards |
|
||||
| **Jury Views** | View assigned projects, evaluate projects, track progress |
|
||||
| **Auth Views** | Login, magic link verification, session management |
|
||||
| **Layouts** | Responsive navigation, sidebar, mobile adaptations |
|
||||
| **UI Components** | shadcn/ui based, reusable, accessible |
|
||||
|
||||
### API Layer
|
||||
|
||||
| Component | Responsibility |
|
||||
|-----------|----------------|
|
||||
| **tRPC Routers** | Type-safe API endpoints grouped by domain |
|
||||
| **Auth Middleware** | Session validation via NextAuth.js |
|
||||
| **RBAC Middleware** | Role-based access control enforcement |
|
||||
| **Audit Logger** | Record all significant actions |
|
||||
| **Validators** | Zod schemas for input validation |
|
||||
|
||||
### Service Layer
|
||||
|
||||
| Service | Responsibility |
|
||||
|---------|----------------|
|
||||
| **ProgramService** | CRUD for programs (e.g., "MOPC 2026") |
|
||||
| **RoundService** | Round lifecycle, voting windows, form versions |
|
||||
| **AssignmentService** | Jury-project assignments, load balancing |
|
||||
| **EvaluationService** | Form submission, autosave, scoring |
|
||||
| **FileService** | MinIO uploads, pre-signed URLs |
|
||||
| **EmailService** | Magic links, notifications via Nodemailer |
|
||||
| **ExportService** | CSV/Excel generation |
|
||||
| **AuditService** | Immutable event logging |
|
||||
|
||||
### Data Layer
|
||||
|
||||
| Component | Responsibility |
|
||||
|-----------|----------------|
|
||||
| **Prisma Client** | Type-safe database queries |
|
||||
| **PostgreSQL** | Primary data store (relational) |
|
||||
| **MinIO** | S3-compatible file storage |
|
||||
| **Migrations** | Schema versioning and evolution |
|
||||
|
||||
## Data Flow Examples
|
||||
|
||||
### 1. Jury Login Flow
|
||||
|
||||
```
|
||||
User Next.js NextAuth PostgreSQL
|
||||
│ │ │ │
|
||||
│ 1. Enter email │ │ │
|
||||
│───────────────────────>│ │ │
|
||||
│ │ 2. Request magic link │ │
|
||||
│ │───────────────────────>│ │
|
||||
│ │ │ 3. Store token │
|
||||
│ │ │───────────────────────>│
|
||||
│ │ │<───────────────────────│
|
||||
│ │ 4. Send email │ │
|
||||
│ │<───────────────────────│ │
|
||||
│ 5. Email with link │ │ │
|
||||
│<───────────────────────│ │ │
|
||||
│ │ │ │
|
||||
│ 6. Click link │ │ │
|
||||
│───────────────────────>│ │ │
|
||||
│ │ 7. Verify token │ │
|
||||
│ │───────────────────────>│ │
|
||||
│ │ │ 8. Validate │
|
||||
│ │ │───────────────────────>│
|
||||
│ │ │<───────────────────────│
|
||||
│ │ 9. Create session │ │
|
||||
│ │<───────────────────────│ │
|
||||
│ 10. Redirect to dash │ │ │
|
||||
│<───────────────────────│ │ │
|
||||
```
|
||||
|
||||
### 2. Jury Evaluation Flow
|
||||
|
||||
```
|
||||
Jury Member Next.js/tRPC Service Layer PostgreSQL/MinIO
|
||||
│ │ │ │
|
||||
│ 1. View project list │ │ │
|
||||
│───────────────────────>│ 2. project.listAssigned() │
|
||||
│ │───────────────────────>│ 3. Query assignments │
|
||||
│ │ │───────────────────────>│
|
||||
│ │ │<───────────────────────│
|
||||
│ 4. Show assigned projects │ │
|
||||
│<───────────────────────│ │ │
|
||||
│ │ │ │
|
||||
│ 5. Open project │ │ │
|
||||
│───────────────────────>│ 6. project.getDetails() │
|
||||
│ │───────────────────────>│ 7. Get project + files│
|
||||
│ │ │───────────────────────>│
|
||||
│ │ │ 8. Generate pre-signed URLs
|
||||
│ │ │<───────────────────────│
|
||||
│ 9. Show project with file links │ │
|
||||
│<───────────────────────│ │ │
|
||||
│ │ │ │
|
||||
│ 10. Fill evaluation │ │ │
|
||||
│ (typing...) │ 11. evaluation.autosave() (debounced) │
|
||||
│───────────────────────>│───────────────────────>│ 12. Save draft │
|
||||
│ │ │───────────────────────>│
|
||||
│ │ │<───────────────────────│
|
||||
│ 13. Autosaved indicator │ │
|
||||
│<───────────────────────│ │ │
|
||||
│ │ │ │
|
||||
│ 14. Submit final │ │ │
|
||||
│───────────────────────>│ 15. evaluation.submit() │
|
||||
│ │───────────────────────>│ 16. Validate window │
|
||||
│ │ │ 17. Lock evaluation │
|
||||
│ │ │ 18. Log audit event │
|
||||
│ │ │───────────────────────>│
|
||||
│ │ │<───────────────────────│
|
||||
│ 19. Success, mark complete │ │
|
||||
│<───────────────────────│ │ │
|
||||
```
|
||||
|
||||
### 3. Admin Export Flow
|
||||
|
||||
```
|
||||
Admin Next.js/tRPC Service Layer PostgreSQL/MinIO
|
||||
│ │ │ │
|
||||
│ 1. Request CSV export │ │ │
|
||||
│───────────────────────>│ 2. export.generateCSV() │
|
||||
│ │───────────────────────>│ 3. Query all evals │
|
||||
│ │ │───────────────────────>│
|
||||
│ │ │<───────────────────────│
|
||||
│ │ │ 4. Build CSV │
|
||||
│ │ │ 5. Upload to MinIO │
|
||||
│ │ │───────────────────────>│
|
||||
│ │ │<───────────────────────│
|
||||
│ │ │ 6. Log audit event │
|
||||
│ │ │───────────────────────>│
|
||||
│ │ 7. Return download URL│ │
|
||||
│ │<───────────────────────│ │
|
||||
│ 8. Download file │ │ │
|
||||
│<───────────────────────│ │ │
|
||||
```
|
||||
|
||||
## Security Architecture
|
||||
|
||||
### Authentication
|
||||
- **Method**: Email magic links (passwordless)
|
||||
- **Sessions**: JWT stored in HTTP-only cookies
|
||||
- **Provider**: NextAuth.js with custom email provider
|
||||
|
||||
### Authorization (RBAC)
|
||||
- **Enforcement**: tRPC middleware checks role before procedure
|
||||
- **Granularity**: Role + resource-level (jury sees only assigned projects)
|
||||
- **Storage**: User.role field in database
|
||||
|
||||
### Data Security
|
||||
- **File Access**: Pre-signed URLs with short TTL (15 min)
|
||||
- **SQL Injection**: Prevented by Prisma parameterized queries
|
||||
- **XSS**: React's built-in escaping + CSP headers
|
||||
- **CSRF**: NextAuth.js built-in protection
|
||||
|
||||
### Audit Trail
|
||||
- **Coverage**: All admin actions, all state changes
|
||||
- **Immutability**: Append-only audit_logs table
|
||||
- **Fields**: user, action, entity, details, timestamp, IP
|
||||
|
||||
## Scalability Considerations
|
||||
|
||||
### Current Design (Phase 1)
|
||||
- Single PostgreSQL instance (sufficient for ~200 concurrent users)
|
||||
- Single Next.js instance behind Nginx
|
||||
- MinIO for file storage (horizontally scalable)
|
||||
|
||||
### Future Scale Path
|
||||
1. **Database**: Read replicas for dashboards, connection pooling (PgBouncer)
|
||||
2. **Application**: Multiple Next.js instances behind load balancer
|
||||
3. **Caching**: Redis for session storage and query caching
|
||||
4. **CDN**: Static assets via CDN
|
||||
5. **Background Jobs**: BullMQ for email queues, exports
|
||||
|
||||
## Smart Assignment System
|
||||
|
||||
The platform includes two assignment modes:
|
||||
|
||||
### 1. AI-Powered Assignment (GPT)
|
||||
- Analyzes juror expertise and project tags
|
||||
- Optimizes for balanced workload
|
||||
- Respects organizational conflicts
|
||||
- **Privacy**: All data is anonymized before sending to GPT
|
||||
- Names → `JUROR_A`, `JUROR_B`
|
||||
- Projects → `PROJECT_1`, `PROJECT_2`
|
||||
- Organizations → `ORG_X`, `ORG_Y`
|
||||
- Emails and personal details are never sent
|
||||
|
||||
### 2. Smart Algorithm (Rule-Based Fallback)
|
||||
- Fully featured scoring algorithm
|
||||
- No external API required
|
||||
- Handles 200 projects × 50 jurors in < 1 second
|
||||
- Deterministic results
|
||||
|
||||
**Scoring Formula**:
|
||||
```
|
||||
Score = (expertise_match × 40) + (load_balance × 25) +
|
||||
(specialty_match × 20) + (diversity × 10) - (conflict × 100)
|
||||
```
|
||||
|
||||
## Admin Settings Panel
|
||||
|
||||
Centralized configuration for:
|
||||
- **AI Configuration**: Provider, API key, model, budget limits
|
||||
- **Platform Branding**: Logo, colors, name
|
||||
- **Email/SMTP**: Server, credentials, templates
|
||||
- **File Storage**: MinIO endpoint, bucket, limits
|
||||
- **Security**: Session duration, rate limits
|
||||
- **Defaults**: Timezone, pagination, autosave interval
|
||||
|
||||
## User Roles (RBAC)
|
||||
|
||||
| Role | Permissions |
|
||||
|------|------------|
|
||||
| **SUPER_ADMIN** | Full system access, all programs, user management |
|
||||
| **PROGRAM_ADMIN** | Manage specific programs, rounds, projects, jury |
|
||||
| **JURY_MEMBER** | View assigned projects only, submit evaluations |
|
||||
| **OBSERVER** | Read-only access to dashboards |
|
||||
|
||||
## Related Documentation
|
||||
|
||||
- [Database Design](./database.md) - Schema, relationships, indexes
|
||||
- [API Design](./api.md) - tRPC routers, endpoints, auth flow
|
||||
- [Infrastructure](./infrastructure.md) - Docker, Nginx, deployment
|
||||
- [UI/UX Patterns](./ui.md) - Components, responsive design
|
||||
1138
docs/architecture/api.md
Normal file
1138
docs/architecture/api.md
Normal file
File diff suppressed because it is too large
Load Diff
701
docs/architecture/database.md
Normal file
701
docs/architecture/database.md
Normal file
@@ -0,0 +1,701 @@
|
||||
# MOPC Platform - Database Design
|
||||
|
||||
## Overview
|
||||
|
||||
The MOPC platform uses PostgreSQL as its primary database, accessed via Prisma ORM. The schema is designed for:
|
||||
|
||||
1. **Type Safety**: Prisma generates TypeScript types from the schema
|
||||
2. **Extensibility**: JSON fields allow future attributes without migrations
|
||||
3. **Auditability**: All significant changes are logged
|
||||
4. **Performance**: Strategic indexes for common query patterns
|
||||
|
||||
## Entity Relationship Diagram
|
||||
|
||||
```
|
||||
┌─────────────┐
|
||||
│ Program │
|
||||
│─────────────│
|
||||
│ id │
|
||||
│ name │
|
||||
│ year │
|
||||
│ status │
|
||||
└──────┬──────┘
|
||||
│ 1:N
|
||||
▼
|
||||
┌─────────────┐ ┌─────────────────┐
|
||||
│ Round │ │ EvaluationForm │
|
||||
│─────────────│ │─────────────────│
|
||||
│ id │◄──────►│ id │
|
||||
│ programId │ 1:N │ roundId │
|
||||
│ name │ │ version │
|
||||
│ status │ │ criteriaJson │
|
||||
│ votingStart │ │ scalesJson │
|
||||
│ votingEnd │ └─────────────────┘
|
||||
│ settings │
|
||||
└──────┬──────┘
|
||||
│ 1:N
|
||||
▼
|
||||
┌─────────────┐ ┌─────────────────┐
|
||||
│ Project │ │ ProjectFile │
|
||||
│─────────────│ │─────────────────│
|
||||
│ id │◄──────►│ id │
|
||||
│ roundId │ 1:N │ projectId │
|
||||
│ title │ │ fileType │
|
||||
│ teamName │ │ bucket │
|
||||
│ description │ │ objectKey │
|
||||
│ status │ │ mimeType │
|
||||
│ tags[] │ │ size │
|
||||
│ metadata │ └─────────────────┘
|
||||
└──────┬──────┘
|
||||
│
|
||||
│ N:M (via Assignment)
|
||||
│
|
||||
┌──────┴──────┐
|
||||
│ Assignment │
|
||||
│─────────────│
|
||||
│ id │
|
||||
│ userId │◄─────────┐
|
||||
│ projectId │ │
|
||||
│ roundId │ │
|
||||
│ method │ │
|
||||
│ completed │ │
|
||||
└──────┬──────┘ │
|
||||
│ 1:1 │
|
||||
▼ │
|
||||
┌─────────────┐ ┌─────┴───────┐
|
||||
│ Evaluation │ │ User │
|
||||
│─────────────│ │─────────────│
|
||||
│ id │ │ id │
|
||||
│ assignmentId│ │ email │
|
||||
│ status │ │ name │
|
||||
│ scores │ │ role │
|
||||
│ globalScore │ │ status │
|
||||
│ decision │ │ expertise[] │
|
||||
│ feedback │ │ metadata │
|
||||
└─────────────┘ └─────────────┘
|
||||
|
||||
┌─────────────┐
|
||||
│ AuditLog │
|
||||
│─────────────│
|
||||
│ id │
|
||||
│ userId │
|
||||
│ action │
|
||||
│ entityType │
|
||||
│ entityId │
|
||||
│ details │
|
||||
│ ipAddress │
|
||||
│ timestamp │
|
||||
└─────────────┘
|
||||
|
||||
┌─────────────────────┐
|
||||
│ SystemSettings │
|
||||
│─────────────────────│
|
||||
│ id │
|
||||
│ key │
|
||||
│ value │
|
||||
│ type │
|
||||
│ category │
|
||||
│ description │
|
||||
│ updatedAt │
|
||||
│ updatedBy │
|
||||
└─────────────────────┘
|
||||
|
||||
┌─────────────────────┐
|
||||
│ GracePeriod │
|
||||
│─────────────────────│
|
||||
│ id │
|
||||
│ roundId │
|
||||
│ userId │
|
||||
│ projectId (opt) │
|
||||
│ extendedUntil │
|
||||
│ reason │
|
||||
│ grantedBy │
|
||||
│ createdAt │
|
||||
└─────────────────────┘
|
||||
```
|
||||
|
||||
## Prisma Schema
|
||||
|
||||
```prisma
|
||||
// prisma/schema.prisma
|
||||
|
||||
generator client {
|
||||
provider = "prisma-client-js"
|
||||
}
|
||||
|
||||
datasource db {
|
||||
provider = "postgresql"
|
||||
url = env("DATABASE_URL")
|
||||
}
|
||||
|
||||
// =============================================================================
|
||||
// ENUMS
|
||||
// =============================================================================
|
||||
|
||||
enum UserRole {
|
||||
SUPER_ADMIN
|
||||
PROGRAM_ADMIN
|
||||
JURY_MEMBER
|
||||
OBSERVER
|
||||
}
|
||||
|
||||
enum UserStatus {
|
||||
INVITED
|
||||
ACTIVE
|
||||
SUSPENDED
|
||||
}
|
||||
|
||||
enum ProgramStatus {
|
||||
DRAFT
|
||||
ACTIVE
|
||||
ARCHIVED
|
||||
}
|
||||
|
||||
enum RoundStatus {
|
||||
DRAFT
|
||||
ACTIVE
|
||||
CLOSED
|
||||
ARCHIVED
|
||||
}
|
||||
|
||||
enum ProjectStatus {
|
||||
SUBMITTED
|
||||
ELIGIBLE
|
||||
ASSIGNED
|
||||
SEMIFINALIST
|
||||
FINALIST
|
||||
REJECTED
|
||||
}
|
||||
|
||||
enum EvaluationStatus {
|
||||
NOT_STARTED
|
||||
DRAFT
|
||||
SUBMITTED
|
||||
LOCKED
|
||||
}
|
||||
|
||||
enum AssignmentMethod {
|
||||
MANUAL
|
||||
AUTO
|
||||
BULK
|
||||
}
|
||||
|
||||
enum FileType {
|
||||
EXEC_SUMMARY
|
||||
PRESENTATION
|
||||
VIDEO
|
||||
OTHER
|
||||
}
|
||||
|
||||
// =============================================================================
|
||||
// USERS & AUTHENTICATION
|
||||
// =============================================================================
|
||||
|
||||
model User {
|
||||
id String @id @default(cuid())
|
||||
email String @unique
|
||||
name String?
|
||||
role UserRole @default(JURY_MEMBER)
|
||||
status UserStatus @default(INVITED)
|
||||
expertiseTags String[] @default([])
|
||||
metadataJson Json? @db.JsonB
|
||||
|
||||
createdAt DateTime @default(now())
|
||||
updatedAt DateTime @updatedAt
|
||||
lastLoginAt DateTime?
|
||||
|
||||
// Relations
|
||||
assignments Assignment[]
|
||||
auditLogs AuditLog[]
|
||||
|
||||
// Indexes
|
||||
@@index([email])
|
||||
@@index([role])
|
||||
@@index([status])
|
||||
}
|
||||
|
||||
// NextAuth.js required models
|
||||
model Account {
|
||||
id String @id @default(cuid())
|
||||
userId String
|
||||
type String
|
||||
provider String
|
||||
providerAccountId String
|
||||
refresh_token String? @db.Text
|
||||
access_token String? @db.Text
|
||||
expires_at Int?
|
||||
token_type String?
|
||||
scope String?
|
||||
id_token String? @db.Text
|
||||
session_state String?
|
||||
|
||||
@@unique([provider, providerAccountId])
|
||||
}
|
||||
|
||||
model Session {
|
||||
id String @id @default(cuid())
|
||||
sessionToken String @unique
|
||||
userId String
|
||||
expires DateTime
|
||||
}
|
||||
|
||||
model VerificationToken {
|
||||
identifier String
|
||||
token String @unique
|
||||
expires DateTime
|
||||
|
||||
@@unique([identifier, token])
|
||||
}
|
||||
|
||||
// =============================================================================
|
||||
// PROGRAMS & ROUNDS
|
||||
// =============================================================================
|
||||
|
||||
model Program {
|
||||
id String @id @default(cuid())
|
||||
name String // e.g., "Monaco Ocean Protection Challenge"
|
||||
year Int // e.g., 2026
|
||||
status ProgramStatus @default(DRAFT)
|
||||
description String?
|
||||
settingsJson Json? @db.JsonB
|
||||
|
||||
createdAt DateTime @default(now())
|
||||
updatedAt DateTime @updatedAt
|
||||
|
||||
// Relations
|
||||
rounds Round[]
|
||||
|
||||
// Indexes
|
||||
@@unique([name, year])
|
||||
@@index([status])
|
||||
}
|
||||
|
||||
model Round {
|
||||
id String @id @default(cuid())
|
||||
programId String
|
||||
name String // e.g., "Round 1 - Semi-Finalists"
|
||||
status RoundStatus @default(DRAFT)
|
||||
|
||||
// Voting window
|
||||
votingStartAt DateTime?
|
||||
votingEndAt DateTime?
|
||||
|
||||
// Configuration
|
||||
requiredReviews Int @default(3) // Min evaluations per project
|
||||
settingsJson Json? @db.JsonB // Grace periods, visibility rules, etc.
|
||||
|
||||
createdAt DateTime @default(now())
|
||||
updatedAt DateTime @updatedAt
|
||||
|
||||
// Relations
|
||||
program Program @relation(fields: [programId], references: [id], onDelete: Cascade)
|
||||
projects Project[]
|
||||
assignments Assignment[]
|
||||
evaluationForms EvaluationForm[]
|
||||
|
||||
// Indexes
|
||||
@@index([programId])
|
||||
@@index([status])
|
||||
@@index([votingStartAt, votingEndAt])
|
||||
}
|
||||
|
||||
model EvaluationForm {
|
||||
id String @id @default(cuid())
|
||||
roundId String
|
||||
version Int @default(1)
|
||||
|
||||
// Form configuration
|
||||
criteriaJson Json @db.JsonB // Array of criteria with labels, scales
|
||||
scalesJson Json? @db.JsonB // Scale definitions (1-5, 1-10, etc.)
|
||||
isActive Boolean @default(false)
|
||||
|
||||
createdAt DateTime @default(now())
|
||||
updatedAt DateTime @updatedAt
|
||||
|
||||
// Relations
|
||||
round Round @relation(fields: [roundId], references: [id], onDelete: Cascade)
|
||||
evaluations Evaluation[]
|
||||
|
||||
// Indexes
|
||||
@@unique([roundId, version])
|
||||
@@index([roundId, isActive])
|
||||
}
|
||||
|
||||
// =============================================================================
|
||||
// PROJECTS
|
||||
// =============================================================================
|
||||
|
||||
model Project {
|
||||
id String @id @default(cuid())
|
||||
roundId String
|
||||
|
||||
// Core fields
|
||||
title String
|
||||
teamName String?
|
||||
description String? @db.Text
|
||||
status ProjectStatus @default(SUBMITTED)
|
||||
|
||||
// Flexible fields
|
||||
tags String[] @default([]) // "Ocean Conservation", "Tech", etc.
|
||||
metadataJson Json? @db.JsonB // Custom fields from Typeform, etc.
|
||||
externalIdsJson Json? @db.JsonB // Typeform ID, Notion ID, etc.
|
||||
|
||||
createdAt DateTime @default(now())
|
||||
updatedAt DateTime @updatedAt
|
||||
|
||||
// Relations
|
||||
round Round @relation(fields: [roundId], references: [id], onDelete: Cascade)
|
||||
files ProjectFile[]
|
||||
assignments Assignment[]
|
||||
|
||||
// Indexes
|
||||
@@index([roundId])
|
||||
@@index([status])
|
||||
@@index([tags])
|
||||
}
|
||||
|
||||
model ProjectFile {
|
||||
id String @id @default(cuid())
|
||||
projectId String
|
||||
|
||||
// File info
|
||||
fileType FileType
|
||||
fileName String
|
||||
mimeType String
|
||||
size Int // bytes
|
||||
|
||||
// MinIO location
|
||||
bucket String
|
||||
objectKey String
|
||||
|
||||
createdAt DateTime @default(now())
|
||||
|
||||
// Relations
|
||||
project Project @relation(fields: [projectId], references: [id], onDelete: Cascade)
|
||||
|
||||
// Indexes
|
||||
@@index([projectId])
|
||||
@@index([fileType])
|
||||
@@unique([bucket, objectKey])
|
||||
}
|
||||
|
||||
// =============================================================================
|
||||
// ASSIGNMENTS & EVALUATIONS
|
||||
// =============================================================================
|
||||
|
||||
model Assignment {
|
||||
id String @id @default(cuid())
|
||||
userId String
|
||||
projectId String
|
||||
roundId String
|
||||
|
||||
// Assignment info
|
||||
method AssignmentMethod @default(MANUAL)
|
||||
isRequired Boolean @default(true)
|
||||
isCompleted Boolean @default(false)
|
||||
|
||||
createdAt DateTime @default(now())
|
||||
createdBy String? // Admin who created the assignment
|
||||
|
||||
// Relations
|
||||
user User @relation(fields: [userId], references: [id], onDelete: Cascade)
|
||||
project Project @relation(fields: [projectId], references: [id], onDelete: Cascade)
|
||||
round Round @relation(fields: [roundId], references: [id], onDelete: Cascade)
|
||||
evaluation Evaluation?
|
||||
|
||||
// Constraints
|
||||
@@unique([userId, projectId, roundId])
|
||||
|
||||
// Indexes
|
||||
@@index([userId])
|
||||
@@index([projectId])
|
||||
@@index([roundId])
|
||||
@@index([isCompleted])
|
||||
}
|
||||
|
||||
model Evaluation {
|
||||
id String @id @default(cuid())
|
||||
assignmentId String @unique
|
||||
formId String
|
||||
|
||||
// Status
|
||||
status EvaluationStatus @default(NOT_STARTED)
|
||||
|
||||
// Scores
|
||||
criterionScoresJson Json? @db.JsonB // { "criterion1": 4, "criterion2": 5 }
|
||||
globalScore Int? // 1-10
|
||||
binaryDecision Boolean? // Yes/No for semi-finalist
|
||||
feedbackText String? @db.Text
|
||||
|
||||
// Versioning
|
||||
version Int @default(1)
|
||||
|
||||
// Timestamps
|
||||
createdAt DateTime @default(now())
|
||||
updatedAt DateTime @updatedAt
|
||||
submittedAt DateTime?
|
||||
|
||||
// Relations
|
||||
assignment Assignment @relation(fields: [assignmentId], references: [id], onDelete: Cascade)
|
||||
form EvaluationForm @relation(fields: [formId], references: [id])
|
||||
|
||||
// Indexes
|
||||
@@index([status])
|
||||
@@index([submittedAt])
|
||||
}
|
||||
|
||||
// =============================================================================
|
||||
// AUDIT LOGGING
|
||||
// =============================================================================
|
||||
|
||||
model AuditLog {
|
||||
id String @id @default(cuid())
|
||||
userId String?
|
||||
|
||||
// Event info
|
||||
action String // "CREATE", "UPDATE", "DELETE", "LOGIN", "EXPORT", etc.
|
||||
entityType String // "Round", "Project", "Evaluation", etc.
|
||||
entityId String?
|
||||
|
||||
// Details
|
||||
detailsJson Json? @db.JsonB // Before/after values, additional context
|
||||
|
||||
// Request info
|
||||
ipAddress String?
|
||||
userAgent String?
|
||||
|
||||
timestamp DateTime @default(now())
|
||||
|
||||
// Relations
|
||||
user User? @relation(fields: [userId], references: [id], onDelete: SetNull)
|
||||
|
||||
// Indexes
|
||||
@@index([userId])
|
||||
@@index([action])
|
||||
@@index([entityType, entityId])
|
||||
@@index([timestamp])
|
||||
}
|
||||
```
|
||||
|
||||
## Indexing Strategy
|
||||
|
||||
### Primary Indexes (Automatic)
|
||||
- All `@id` fields have primary key indexes
|
||||
- All `@unique` constraints have unique indexes
|
||||
|
||||
### Query-Optimized Indexes
|
||||
|
||||
| Table | Index | Purpose |
|
||||
|-------|-------|---------|
|
||||
| User | `email` | Login lookup |
|
||||
| User | `role` | Filter by role |
|
||||
| User | `status` | Filter active users |
|
||||
| Program | `status` | List active programs |
|
||||
| Round | `programId` | Get rounds for program |
|
||||
| Round | `status` | Filter active rounds |
|
||||
| Round | `votingStartAt, votingEndAt` | Check voting window |
|
||||
| Project | `roundId` | Get projects in round |
|
||||
| Project | `status` | Filter by status |
|
||||
| Project | `tags` | Filter by tag (GIN index) |
|
||||
| Assignment | `userId` | Get user's assignments |
|
||||
| Assignment | `projectId` | Get project's reviewers |
|
||||
| Assignment | `roundId` | Get all assignments for round |
|
||||
| Assignment | `isCompleted` | Track progress |
|
||||
| Evaluation | `status` | Filter by completion |
|
||||
| Evaluation | `submittedAt` | Sort by submission time |
|
||||
| AuditLog | `timestamp` | Time-based queries |
|
||||
| AuditLog | `entityType, entityId` | Entity history |
|
||||
|
||||
### JSON Field Indexes
|
||||
|
||||
For PostgreSQL JSONB fields, we can add GIN indexes for complex queries:
|
||||
|
||||
```sql
|
||||
-- Add via migration if needed
|
||||
CREATE INDEX idx_project_metadata ON "Project" USING GIN ("metadataJson");
|
||||
CREATE INDEX idx_evaluation_scores ON "Evaluation" USING GIN ("criterionScoresJson");
|
||||
```
|
||||
|
||||
## Migration Strategy
|
||||
|
||||
### Development
|
||||
```bash
|
||||
# Push schema changes directly (no migration files)
|
||||
npx prisma db push
|
||||
|
||||
# Generate client after schema changes
|
||||
npx prisma generate
|
||||
```
|
||||
|
||||
### Production
|
||||
```bash
|
||||
# Create migration from schema changes
|
||||
npx prisma migrate dev --name description_of_change
|
||||
|
||||
# Apply migrations in production
|
||||
npx prisma migrate deploy
|
||||
```
|
||||
|
||||
### Migration Best Practices
|
||||
1. **Never** use `db push` in production
|
||||
2. **Always** backup before migrations
|
||||
3. **Test** migrations on staging first
|
||||
4. **Review** generated SQL before applying
|
||||
5. **Keep** migrations small and focused
|
||||
|
||||
## Data Seeding
|
||||
|
||||
```typescript
|
||||
// prisma/seed.ts
|
||||
|
||||
import { PrismaClient, UserRole, ProgramStatus, RoundStatus } from '@prisma/client'
|
||||
|
||||
const prisma = new PrismaClient()
|
||||
|
||||
async function main() {
|
||||
// Create super admin
|
||||
const admin = await prisma.user.upsert({
|
||||
where: { email: 'admin@mopc.org' },
|
||||
update: {},
|
||||
create: {
|
||||
email: 'admin@mopc.org',
|
||||
name: 'System Admin',
|
||||
role: UserRole.SUPER_ADMIN,
|
||||
status: 'ACTIVE',
|
||||
},
|
||||
})
|
||||
|
||||
// Create sample program
|
||||
const program = await prisma.program.upsert({
|
||||
where: { name_year: { name: 'Monaco Ocean Protection Challenge', year: 2026 } },
|
||||
update: {},
|
||||
create: {
|
||||
name: 'Monaco Ocean Protection Challenge',
|
||||
year: 2026,
|
||||
status: ProgramStatus.ACTIVE,
|
||||
description: 'Annual ocean conservation startup competition',
|
||||
},
|
||||
})
|
||||
|
||||
// Create Round 1
|
||||
const round1 = await prisma.round.create({
|
||||
data: {
|
||||
programId: program.id,
|
||||
name: 'Round 1 - Semi-Finalists Selection',
|
||||
status: RoundStatus.DRAFT,
|
||||
requiredReviews: 3,
|
||||
votingStartAt: new Date('2026-02-18'),
|
||||
votingEndAt: new Date('2026-02-23'),
|
||||
},
|
||||
})
|
||||
|
||||
// Create evaluation form for Round 1
|
||||
await prisma.evaluationForm.create({
|
||||
data: {
|
||||
roundId: round1.id,
|
||||
version: 1,
|
||||
isActive: true,
|
||||
criteriaJson: [
|
||||
{ id: 'need_clarity', label: 'Need clarity', scale: '1-5', weight: 1 },
|
||||
{ id: 'solution_relevance', label: 'Solution relevance', scale: '1-5', weight: 1 },
|
||||
{ id: 'gap_analysis', label: 'Gap analysis', scale: '1-5', weight: 1 },
|
||||
{ id: 'target_customers', label: 'Target customers clarity', scale: '1-5', weight: 1 },
|
||||
{ id: 'ocean_impact', label: 'Ocean impact', scale: '1-5', weight: 1 },
|
||||
],
|
||||
scalesJson: {
|
||||
'1-5': { min: 1, max: 5, labels: { 1: 'Poor', 3: 'Average', 5: 'Excellent' } },
|
||||
'1-10': { min: 1, max: 10, labels: { 1: 'Poor', 5: 'Average', 10: 'Excellent' } },
|
||||
},
|
||||
},
|
||||
})
|
||||
|
||||
console.log('Seed completed')
|
||||
}
|
||||
|
||||
main()
|
||||
.catch((e) => {
|
||||
console.error(e)
|
||||
process.exit(1)
|
||||
})
|
||||
.finally(async () => {
|
||||
await prisma.$disconnect()
|
||||
})
|
||||
```
|
||||
|
||||
Run seed:
|
||||
```bash
|
||||
npx prisma db seed
|
||||
```
|
||||
|
||||
## Query Patterns
|
||||
|
||||
### Get Jury's Assigned Projects
|
||||
```typescript
|
||||
const assignments = await prisma.assignment.findMany({
|
||||
where: {
|
||||
userId: currentUser.id,
|
||||
round: {
|
||||
status: 'ACTIVE',
|
||||
},
|
||||
},
|
||||
include: {
|
||||
project: {
|
||||
include: {
|
||||
files: true,
|
||||
},
|
||||
},
|
||||
evaluation: true,
|
||||
},
|
||||
})
|
||||
```
|
||||
|
||||
### Get Project Evaluation Stats
|
||||
```typescript
|
||||
const stats = await prisma.evaluation.groupBy({
|
||||
by: ['status'],
|
||||
where: {
|
||||
assignment: {
|
||||
projectId: projectId,
|
||||
},
|
||||
},
|
||||
_count: true,
|
||||
})
|
||||
```
|
||||
|
||||
### Check Voting Window
|
||||
```typescript
|
||||
const isVotingOpen = await prisma.round.findFirst({
|
||||
where: {
|
||||
id: roundId,
|
||||
status: 'ACTIVE',
|
||||
votingStartAt: { lte: new Date() },
|
||||
votingEndAt: { gte: new Date() },
|
||||
},
|
||||
})
|
||||
```
|
||||
|
||||
## Backup Strategy
|
||||
|
||||
### Automated Backups
|
||||
```bash
|
||||
# Daily backup cron job
|
||||
0 2 * * * pg_dump -h localhost -U mopc -d mopc -F c -f /backups/mopc_$(date +\%Y\%m\%d).dump
|
||||
|
||||
# Keep last 30 days
|
||||
find /backups -name "mopc_*.dump" -mtime +30 -delete
|
||||
```
|
||||
|
||||
### Manual Backup
|
||||
```bash
|
||||
docker exec -t mopc-postgres pg_dump -U mopc mopc > backup.sql
|
||||
```
|
||||
|
||||
### Restore
|
||||
```bash
|
||||
docker exec -i mopc-postgres psql -U mopc mopc < backup.sql
|
||||
```
|
||||
|
||||
## Related Documentation
|
||||
|
||||
- [API Design](./api.md) - How the database is accessed via tRPC
|
||||
- [Infrastructure](./infrastructure.md) - PostgreSQL deployment
|
||||
651
docs/architecture/infrastructure.md
Normal file
651
docs/architecture/infrastructure.md
Normal file
@@ -0,0 +1,651 @@
|
||||
# MOPC Platform - Infrastructure
|
||||
|
||||
## Overview
|
||||
|
||||
The MOPC platform is self-hosted on a Linux VPS at **monaco-opc.com** with the following architecture:
|
||||
|
||||
- **Nginx** (host-level) - Reverse proxy with SSL termination
|
||||
- **Docker Compose** (MOPC stack) - Next.js + PostgreSQL
|
||||
- **MinIO** (separate stack) - S3-compatible file storage
|
||||
- **Poste.io** (separate stack) - Self-hosted email server
|
||||
|
||||
**Key Configurations:**
|
||||
- Max file size: 500MB (for video uploads)
|
||||
- SSL via Certbot (Let's Encrypt)
|
||||
|
||||
## Architecture Diagram
|
||||
|
||||
```
|
||||
┌──────────────────────────────────────────────────────────────────────────────┐
|
||||
│ INTERNET │
|
||||
└─────────────────────────────────┬────────────────────────────────────────────┘
|
||||
│
|
||||
▼
|
||||
┌──────────────────────────────────────────────────────────────────────────────┐
|
||||
│ LINUX VPS │
|
||||
├──────────────────────────────────────────────────────────────────────────────┤
|
||||
│ │
|
||||
│ ┌────────────────────────────────────────────────────────────────────────┐ │
|
||||
│ │ NGINX (Host Level) │ │
|
||||
│ │ │ │
|
||||
│ │ - SSL termination via Certbot │ │
|
||||
│ │ - Reverse proxy to Docker services │ │
|
||||
│ │ - Rate limiting │ │
|
||||
│ │ - Security headers │ │
|
||||
│ │ │ │
|
||||
│ │ Ports: 80 (HTTP → HTTPS redirect), 443 (HTTPS) │ │
|
||||
│ └─────────────────────────────────┬──────────────────────────────────────┘ │
|
||||
│ │ │
|
||||
│ ┌───────────────────────┼───────────────────────┐ │
|
||||
│ │ │ │ │
|
||||
│ ▼ ▼ ▼ │
|
||||
│ ┌──────────────────┐ ┌──────────────────┐ ┌──────────────────┐ │
|
||||
│ │ MOPC Stack │ │ MinIO Stack │ │ Poste.io Stack │ │
|
||||
│ │ (Docker Compose)│ │ (Docker Compose)│ │ (Docker Compose)│ │
|
||||
│ │ │ │ │ │ │ │
|
||||
│ │ ┌────────────┐ │ │ ┌────────────┐ │ │ ┌────────────┐ │ │
|
||||
│ │ │ Next.js │ │ │ │ MinIO │ │ │ │ Poste.io │ │ │
|
||||
│ │ │ :3000 │ │ │ │ :9000 │ │ │ │ :25,587 │ │ │
|
||||
│ │ └────────────┘ │ │ │ :9001 │ │ │ └────────────┘ │ │
|
||||
│ │ │ │ └────────────┘ │ │ │ │
|
||||
│ │ ┌────────────┐ │ │ │ │ │ │
|
||||
│ │ │ PostgreSQL │ │ │ │ │ │ │
|
||||
│ │ │ :5432 │ │ │ │ │ │ │
|
||||
│ │ └────────────┘ │ │ │ │ │ │
|
||||
│ └──────────────────┘ └──────────────────┘ └──────────────────┘ │
|
||||
│ │
|
||||
│ Data Volumes: │
|
||||
│ /data/mopc/postgres /data/minio /data/poste │
|
||||
│ │
|
||||
└──────────────────────────────────────────────────────────────────────────────┘
|
||||
```
|
||||
|
||||
## Docker Compose Configuration
|
||||
|
||||
### MOPC Stack
|
||||
|
||||
```yaml
|
||||
# docker/docker-compose.yml
|
||||
|
||||
version: '3.8'
|
||||
|
||||
services:
|
||||
app:
|
||||
build:
|
||||
context: ..
|
||||
dockerfile: docker/Dockerfile
|
||||
container_name: mopc-app
|
||||
restart: unless-stopped
|
||||
ports:
|
||||
- "127.0.0.1:3000:3000"
|
||||
environment:
|
||||
- NODE_ENV=production
|
||||
- DATABASE_URL=postgresql://mopc:${DB_PASSWORD}@postgres:5432/mopc
|
||||
- NEXTAUTH_URL=${NEXTAUTH_URL}
|
||||
- NEXTAUTH_SECRET=${NEXTAUTH_SECRET}
|
||||
- MINIO_ENDPOINT=${MINIO_ENDPOINT}
|
||||
- MINIO_ACCESS_KEY=${MINIO_ACCESS_KEY}
|
||||
- MINIO_SECRET_KEY=${MINIO_SECRET_KEY}
|
||||
- MINIO_BUCKET=${MINIO_BUCKET}
|
||||
- SMTP_HOST=${SMTP_HOST}
|
||||
- SMTP_PORT=${SMTP_PORT}
|
||||
- SMTP_USER=${SMTP_USER}
|
||||
- SMTP_PASS=${SMTP_PASS}
|
||||
- EMAIL_FROM=${EMAIL_FROM}
|
||||
depends_on:
|
||||
postgres:
|
||||
condition: service_healthy
|
||||
networks:
|
||||
- mopc-network
|
||||
|
||||
postgres:
|
||||
image: postgres:16-alpine
|
||||
container_name: mopc-postgres
|
||||
restart: unless-stopped
|
||||
environment:
|
||||
- POSTGRES_USER=mopc
|
||||
- POSTGRES_PASSWORD=${DB_PASSWORD}
|
||||
- POSTGRES_DB=mopc
|
||||
volumes:
|
||||
- postgres_data:/var/lib/postgresql/data
|
||||
healthcheck:
|
||||
test: ["CMD-SHELL", "pg_isready -U mopc"]
|
||||
interval: 10s
|
||||
timeout: 5s
|
||||
retries: 5
|
||||
networks:
|
||||
- mopc-network
|
||||
|
||||
volumes:
|
||||
postgres_data:
|
||||
driver: local
|
||||
driver_opts:
|
||||
type: none
|
||||
o: bind
|
||||
device: /data/mopc/postgres
|
||||
|
||||
networks:
|
||||
mopc-network:
|
||||
driver: bridge
|
||||
```
|
||||
|
||||
### Development Stack
|
||||
|
||||
The development stack includes PostgreSQL, MinIO, and the Next.js app running in Docker containers.
|
||||
|
||||
```yaml
|
||||
# docker/docker-compose.dev.yml
|
||||
|
||||
services:
|
||||
postgres:
|
||||
image: postgres:16-alpine
|
||||
container_name: mopc-postgres-dev
|
||||
ports:
|
||||
- "5432:5432"
|
||||
environment:
|
||||
- POSTGRES_USER=mopc
|
||||
- POSTGRES_PASSWORD=devpassword
|
||||
- POSTGRES_DB=mopc
|
||||
volumes:
|
||||
- postgres_dev_data:/var/lib/postgresql/data
|
||||
healthcheck:
|
||||
test: ["CMD-SHELL", "pg_isready -U mopc"]
|
||||
interval: 5s
|
||||
timeout: 5s
|
||||
retries: 5
|
||||
|
||||
minio:
|
||||
image: minio/minio
|
||||
container_name: mopc-minio-dev
|
||||
ports:
|
||||
- "9000:9000"
|
||||
- "9001:9001"
|
||||
environment:
|
||||
- MINIO_ROOT_USER=minioadmin
|
||||
- MINIO_ROOT_PASSWORD=minioadmin
|
||||
volumes:
|
||||
- minio_dev_data:/data
|
||||
command: server /data --console-address ":9001"
|
||||
healthcheck:
|
||||
test: ["CMD", "curl", "-f", "http://localhost:9000/minio/health/live"]
|
||||
interval: 30s
|
||||
timeout: 20s
|
||||
retries: 3
|
||||
|
||||
# MinIO client to create default bucket on startup
|
||||
createbuckets:
|
||||
image: minio/mc
|
||||
depends_on:
|
||||
minio:
|
||||
condition: service_healthy
|
||||
entrypoint: >
|
||||
/bin/sh -c "
|
||||
mc alias set myminio http://minio:9000 minioadmin minioadmin;
|
||||
mc mb --ignore-existing myminio/mopc-files;
|
||||
mc anonymous set download myminio/mopc-files;
|
||||
echo 'Bucket created successfully';
|
||||
"
|
||||
|
||||
# Next.js application
|
||||
app:
|
||||
build:
|
||||
context: ..
|
||||
dockerfile: docker/Dockerfile.dev
|
||||
container_name: mopc-app-dev
|
||||
ports:
|
||||
- "3000:3000"
|
||||
environment:
|
||||
- DATABASE_URL=postgresql://mopc:devpassword@postgres:5432/mopc
|
||||
- NEXTAUTH_URL=http://localhost:3000
|
||||
- NEXTAUTH_SECRET=dev-secret-key-for-local-development-only
|
||||
- MINIO_ENDPOINT=http://minio:9000
|
||||
- MINIO_ACCESS_KEY=minioadmin
|
||||
- MINIO_SECRET_KEY=minioadmin
|
||||
- MINIO_BUCKET=mopc-files
|
||||
- NODE_ENV=development
|
||||
volumes:
|
||||
- ../src:/app/src
|
||||
- ../public:/app/public
|
||||
- ../prisma:/app/prisma
|
||||
depends_on:
|
||||
postgres:
|
||||
condition: service_healthy
|
||||
minio:
|
||||
condition: service_healthy
|
||||
|
||||
volumes:
|
||||
postgres_dev_data:
|
||||
minio_dev_data:
|
||||
```
|
||||
|
||||
### Quick Start (Development)
|
||||
|
||||
```bash
|
||||
# 1. Start all services (PostgreSQL, MinIO, Next.js)
|
||||
docker compose -f docker/docker-compose.dev.yml up --build -d
|
||||
|
||||
# 2. Push database schema
|
||||
docker exec mopc-app-dev npx prisma db push
|
||||
|
||||
# 3. Seed test data
|
||||
docker exec mopc-app-dev npx tsx prisma/seed.ts
|
||||
|
||||
# 4. Open http://localhost:3000
|
||||
# Login with: admin@monaco-opc.com (magic link)
|
||||
```
|
||||
|
||||
### Development URLs
|
||||
|
||||
| Service | URL | Credentials |
|
||||
|---------|-----|-------------|
|
||||
| Next.js App | http://localhost:3000 | See seed data |
|
||||
| MinIO Console | http://localhost:9001 | minioadmin / minioadmin |
|
||||
| PostgreSQL | localhost:5432 | mopc / devpassword |
|
||||
|
||||
### Test Accounts (after seeding)
|
||||
|
||||
| Role | Email |
|
||||
|------|-------|
|
||||
| Super Admin | admin@monaco-opc.com |
|
||||
| Jury Member | jury1@example.com |
|
||||
| Jury Member | jury2@example.com |
|
||||
| Jury Member | jury3@example.com |
|
||||
|
||||
## Dockerfile
|
||||
|
||||
```dockerfile
|
||||
# docker/Dockerfile
|
||||
|
||||
FROM node:20-alpine AS base
|
||||
|
||||
# Install dependencies only when needed
|
||||
FROM base AS deps
|
||||
RUN apk add --no-cache libc6-compat
|
||||
WORKDIR /app
|
||||
|
||||
COPY package.json package-lock.json* ./
|
||||
RUN npm ci
|
||||
|
||||
# Rebuild the source code only when needed
|
||||
FROM base AS builder
|
||||
WORKDIR /app
|
||||
COPY --from=deps /app/node_modules ./node_modules
|
||||
COPY . .
|
||||
|
||||
# Generate Prisma client
|
||||
RUN npx prisma generate
|
||||
|
||||
# Build Next.js
|
||||
ENV NEXT_TELEMETRY_DISABLED 1
|
||||
RUN npm run build
|
||||
|
||||
# Production image
|
||||
FROM base AS runner
|
||||
WORKDIR /app
|
||||
|
||||
ENV NODE_ENV production
|
||||
ENV NEXT_TELEMETRY_DISABLED 1
|
||||
|
||||
RUN addgroup --system --gid 1001 nodejs
|
||||
RUN adduser --system --uid 1001 nextjs
|
||||
|
||||
COPY --from=builder /app/public ./public
|
||||
COPY --from=builder /app/.next/standalone ./
|
||||
COPY --from=builder /app/.next/static ./.next/static
|
||||
COPY --from=builder /app/prisma ./prisma
|
||||
|
||||
USER nextjs
|
||||
|
||||
EXPOSE 3000
|
||||
ENV PORT 3000
|
||||
ENV HOSTNAME "0.0.0.0"
|
||||
|
||||
CMD ["node", "server.js"]
|
||||
```
|
||||
|
||||
## Nginx Configuration
|
||||
|
||||
```nginx
|
||||
# /etc/nginx/sites-available/mopc-platform
|
||||
|
||||
# Rate limiting zone
|
||||
limit_req_zone $binary_remote_addr zone=mopc_limit:10m rate=10r/s;
|
||||
|
||||
# MOPC Platform
|
||||
server {
|
||||
listen 443 ssl http2;
|
||||
listen [::]:443 ssl http2;
|
||||
server_name monaco-opc.com;
|
||||
|
||||
# SSL certificates (managed by Certbot)
|
||||
ssl_certificate /etc/letsencrypt/live/monaco-opc.com/fullchain.pem;
|
||||
ssl_certificate_key /etc/letsencrypt/live/monaco-opc.com/privkey.pem;
|
||||
include /etc/letsencrypt/options-ssl-nginx.conf;
|
||||
ssl_dhparam /etc/letsencrypt/ssl-dhparams.pem;
|
||||
|
||||
# Security headers
|
||||
add_header X-Frame-Options "SAMEORIGIN" always;
|
||||
add_header X-Content-Type-Options "nosniff" always;
|
||||
add_header X-XSS-Protection "1; mode=block" always;
|
||||
add_header Referrer-Policy "strict-origin-when-cross-origin" always;
|
||||
add_header Content-Security-Policy "default-src 'self'; script-src 'self' 'unsafe-inline' 'unsafe-eval'; style-src 'self' 'unsafe-inline'; img-src 'self' data: blob:; font-src 'self' data:; connect-src 'self';" always;
|
||||
|
||||
# File upload size (500MB for videos)
|
||||
client_max_body_size 500M;
|
||||
|
||||
# Rate limiting
|
||||
limit_req zone=mopc_limit burst=20 nodelay;
|
||||
|
||||
# Logging
|
||||
access_log /var/log/nginx/mopc-access.log;
|
||||
error_log /var/log/nginx/mopc-error.log;
|
||||
|
||||
# Next.js application
|
||||
location / {
|
||||
proxy_pass http://127.0.0.1:3000;
|
||||
proxy_http_version 1.1;
|
||||
proxy_set_header Upgrade $http_upgrade;
|
||||
proxy_set_header Connection 'upgrade';
|
||||
proxy_set_header Host $host;
|
||||
proxy_set_header X-Real-IP $remote_addr;
|
||||
proxy_set_header X-Forwarded-For $proxy_add_x_forwarded_for;
|
||||
proxy_set_header X-Forwarded-Proto $scheme;
|
||||
proxy_cache_bypass $http_upgrade;
|
||||
|
||||
# Timeouts for large file uploads
|
||||
proxy_connect_timeout 300s;
|
||||
proxy_send_timeout 300s;
|
||||
proxy_read_timeout 300s;
|
||||
}
|
||||
|
||||
# Static files caching
|
||||
location /_next/static {
|
||||
proxy_pass http://127.0.0.1:3000;
|
||||
proxy_cache_valid 200 365d;
|
||||
add_header Cache-Control "public, max-age=31536000, immutable";
|
||||
}
|
||||
|
||||
# Health check endpoint
|
||||
location /api/health {
|
||||
proxy_pass http://127.0.0.1:3000;
|
||||
access_log off;
|
||||
}
|
||||
}
|
||||
|
||||
# HTTP to HTTPS redirect
|
||||
server {
|
||||
listen 80;
|
||||
listen [::]:80;
|
||||
server_name monaco-opc.com;
|
||||
return 301 https://$host$request_uri;
|
||||
}
|
||||
```
|
||||
|
||||
## SSL Setup with Certbot
|
||||
|
||||
```bash
|
||||
# Install Certbot
|
||||
sudo apt update
|
||||
sudo apt install certbot python3-certbot-nginx
|
||||
|
||||
# Obtain certificate
|
||||
sudo certbot --nginx -d monaco-opc.com
|
||||
|
||||
# Auto-renewal is configured automatically
|
||||
# Test renewal
|
||||
sudo certbot renew --dry-run
|
||||
```
|
||||
|
||||
## Environment Variables
|
||||
|
||||
### Production (.env)
|
||||
|
||||
```env
|
||||
# Application
|
||||
NODE_ENV=production
|
||||
NEXTAUTH_URL=https://monaco-opc.com
|
||||
NEXTAUTH_SECRET=generate-a-secure-random-string-here
|
||||
|
||||
# Database
|
||||
DB_PASSWORD=your-secure-database-password
|
||||
DATABASE_URL=postgresql://mopc:${DB_PASSWORD}@postgres:5432/mopc
|
||||
|
||||
# MinIO (external stack)
|
||||
MINIO_ENDPOINT=http://localhost:9000
|
||||
MINIO_ACCESS_KEY=your-minio-access-key
|
||||
MINIO_SECRET_KEY=your-minio-secret-key
|
||||
MINIO_BUCKET=mopc-files
|
||||
|
||||
# Email (Poste.io)
|
||||
SMTP_HOST=localhost
|
||||
SMTP_PORT=587
|
||||
SMTP_USER=noreply@monaco-opc.com
|
||||
SMTP_PASS=your-smtp-password
|
||||
EMAIL_FROM=MOPC Platform <noreply@monaco-opc.com>
|
||||
```
|
||||
|
||||
### Generate Secrets
|
||||
|
||||
```bash
|
||||
# Generate NEXTAUTH_SECRET
|
||||
openssl rand -base64 32
|
||||
|
||||
# Generate DB_PASSWORD
|
||||
openssl rand -base64 24
|
||||
```
|
||||
|
||||
## Deployment Commands
|
||||
|
||||
### Initial Deployment
|
||||
|
||||
```bash
|
||||
# 1. Clone repository
|
||||
git clone https://github.com/your-org/mopc-platform.git /opt/mopc
|
||||
cd /opt/mopc
|
||||
|
||||
# 2. Create environment file
|
||||
cp .env.example .env
|
||||
nano .env # Edit with production values
|
||||
|
||||
# 3. Create data directories
|
||||
sudo mkdir -p /data/mopc/postgres
|
||||
sudo chown -R 1000:1000 /data/mopc
|
||||
|
||||
# 4. Start the stack
|
||||
cd docker
|
||||
docker compose up -d
|
||||
|
||||
# 5. Run database migrations
|
||||
docker compose exec app npx prisma migrate deploy
|
||||
|
||||
# 6. Seed initial data (optional)
|
||||
docker compose exec app npx prisma db seed
|
||||
|
||||
# 7. Enable Nginx site
|
||||
sudo ln -s /etc/nginx/sites-available/mopc-platform /etc/nginx/sites-enabled/
|
||||
sudo nginx -t
|
||||
sudo systemctl reload nginx
|
||||
|
||||
# 8. Set up SSL
|
||||
sudo certbot --nginx -d monaco-opc.com
|
||||
```
|
||||
|
||||
### Updates
|
||||
|
||||
```bash
|
||||
cd /opt/mopc
|
||||
|
||||
# 1. Pull latest code
|
||||
git pull origin main
|
||||
|
||||
# 2. Rebuild and restart
|
||||
cd docker
|
||||
docker compose build app
|
||||
docker compose up -d app
|
||||
|
||||
# 3. Run any new migrations
|
||||
docker compose exec app npx prisma migrate deploy
|
||||
```
|
||||
|
||||
### Rollback
|
||||
|
||||
```bash
|
||||
# Revert to previous image
|
||||
docker compose down
|
||||
git checkout HEAD~1
|
||||
docker compose build app
|
||||
docker compose up -d
|
||||
|
||||
# Or restore from specific tag
|
||||
git checkout v1.0.0
|
||||
docker compose build app
|
||||
docker compose up -d
|
||||
```
|
||||
|
||||
## Backup Strategy
|
||||
|
||||
### Database Backups
|
||||
|
||||
```bash
|
||||
# Create backup script
|
||||
cat > /opt/mopc/scripts/backup-db.sh << 'EOF'
|
||||
#!/bin/bash
|
||||
BACKUP_DIR=/data/backups/mopc
|
||||
DATE=$(date +%Y%m%d_%H%M%S)
|
||||
BACKUP_FILE=$BACKUP_DIR/mopc_$DATE.sql.gz
|
||||
|
||||
mkdir -p $BACKUP_DIR
|
||||
|
||||
docker exec mopc-postgres pg_dump -U mopc mopc | gzip > $BACKUP_FILE
|
||||
|
||||
# Keep last 30 days
|
||||
find $BACKUP_DIR -name "mopc_*.sql.gz" -mtime +30 -delete
|
||||
|
||||
echo "Backup completed: $BACKUP_FILE"
|
||||
EOF
|
||||
|
||||
chmod +x /opt/mopc/scripts/backup-db.sh
|
||||
|
||||
# Add to crontab (daily at 2 AM)
|
||||
echo "0 2 * * * /opt/mopc/scripts/backup-db.sh >> /var/log/mopc-backup.log 2>&1" | sudo tee -a /etc/cron.d/mopc-backup
|
||||
```
|
||||
|
||||
### Restore Database
|
||||
|
||||
```bash
|
||||
# Restore from backup
|
||||
gunzip < /data/backups/mopc/mopc_20260115_020000.sql.gz | docker exec -i mopc-postgres psql -U mopc mopc
|
||||
```
|
||||
|
||||
## Monitoring
|
||||
|
||||
### Health Check Endpoint
|
||||
|
||||
```typescript
|
||||
// src/app/api/health/route.ts
|
||||
|
||||
import { prisma } from '@/lib/prisma'
|
||||
|
||||
export async function GET() {
|
||||
try {
|
||||
// Check database connection
|
||||
await prisma.$queryRaw`SELECT 1`
|
||||
|
||||
return Response.json({
|
||||
status: 'healthy',
|
||||
timestamp: new Date().toISOString(),
|
||||
})
|
||||
} catch (error) {
|
||||
return Response.json(
|
||||
{
|
||||
status: 'unhealthy',
|
||||
error: 'Database connection failed',
|
||||
},
|
||||
{ status: 503 }
|
||||
)
|
||||
}
|
||||
}
|
||||
```
|
||||
|
||||
### Log Viewing
|
||||
|
||||
```bash
|
||||
# Application logs
|
||||
docker compose logs -f app
|
||||
|
||||
# Nginx access logs
|
||||
tail -f /var/log/nginx/mopc-access.log
|
||||
|
||||
# Nginx error logs
|
||||
tail -f /var/log/nginx/mopc-error.log
|
||||
|
||||
# PostgreSQL logs
|
||||
docker compose logs -f postgres
|
||||
```
|
||||
|
||||
### Resource Monitoring
|
||||
|
||||
```bash
|
||||
# Docker stats
|
||||
docker stats mopc-app mopc-postgres
|
||||
|
||||
# System resources
|
||||
htop
|
||||
```
|
||||
|
||||
## Security Checklist
|
||||
|
||||
- [ ] SSL certificate active and auto-renewing
|
||||
- [ ] Database password is strong and unique
|
||||
- [ ] NEXTAUTH_SECRET is randomly generated
|
||||
- [ ] MinIO credentials are secure
|
||||
- [ ] SMTP credentials are secure
|
||||
- [ ] Firewall allows only ports 80, 443, 22
|
||||
- [ ] Docker daemon not exposed to network
|
||||
- [ ] Regular backups configured
|
||||
- [ ] Log rotation configured
|
||||
- [ ] Security headers enabled in Nginx
|
||||
|
||||
## Troubleshooting
|
||||
|
||||
### Application Won't Start
|
||||
|
||||
```bash
|
||||
# Check logs
|
||||
docker compose logs app
|
||||
|
||||
# Check if database is ready
|
||||
docker compose exec postgres pg_isready -U mopc
|
||||
|
||||
# Restart stack
|
||||
docker compose restart
|
||||
```
|
||||
|
||||
### Database Connection Issues
|
||||
|
||||
```bash
|
||||
# Test connection from app container
|
||||
docker compose exec app sh
|
||||
nc -zv postgres 5432
|
||||
|
||||
# Check PostgreSQL logs
|
||||
docker compose logs postgres
|
||||
```
|
||||
|
||||
### SSL Certificate Issues
|
||||
|
||||
```bash
|
||||
# Test certificate
|
||||
sudo certbot certificates
|
||||
|
||||
# Force renewal
|
||||
sudo certbot renew --force-renewal
|
||||
|
||||
# Check Nginx configuration
|
||||
sudo nginx -t
|
||||
```
|
||||
|
||||
## Related Documentation
|
||||
|
||||
- [Database Design](./database.md) - Schema and migrations
|
||||
- [API Design](./api.md) - tRPC endpoints
|
||||
749
docs/architecture/ui.md
Normal file
749
docs/architecture/ui.md
Normal file
@@ -0,0 +1,749 @@
|
||||
# MOPC Platform - UI/UX Architecture
|
||||
|
||||
## Overview
|
||||
|
||||
The MOPC platform uses a mobile-first responsive design built with:
|
||||
|
||||
- **Next.js App Router** - Server Components by default
|
||||
- **shadcn/ui** - Accessible, customizable component library
|
||||
- **Tailwind CSS** - Utility-first styling
|
||||
- **Radix UI** - Headless accessible primitives
|
||||
- **Motion** (Framer Motion) - Buttery smooth animations
|
||||
- **Vaul** - Native-feeling mobile drawers
|
||||
- **Sonner** - Beautiful toast notifications
|
||||
- **cmdk** - Command palette (⌘K)
|
||||
|
||||
## Design Philosophy
|
||||
|
||||
**CRITICAL: Avoid "AI-built" aesthetic. Platform must look professionally designed.**
|
||||
|
||||
### What to AVOID (typical AI-generated look)
|
||||
| Don't | Why | Instead |
|
||||
|-------|-----|---------|
|
||||
| Cards everywhere | Generic, lazy layout | Use varied layouts: tables, lists, grids, hero sections |
|
||||
| Same border-radius on everything | Monotonous | Vary: sharp corners for data, rounded for actions |
|
||||
| Identical padding/spacing | Robotic feel | Use intentional rhythm: tight for data, generous for CTAs |
|
||||
| Blue/purple gradients | Screams "AI template" | Use brand colors with restraint |
|
||||
| Stock icons everywhere | Impersonal | Custom icons or carefully curated set |
|
||||
| Centered everything | No visual hierarchy | Left-align content, strategic centering |
|
||||
| Gray backgrounds | Dull, corporate | Subtle off-white textures, strategic white space |
|
||||
| "Dashboard" with 6 equal cards | The #1 AI cliché | Prioritize: hero metric, then supporting data |
|
||||
|
||||
### What TO DO (professional design)
|
||||
| Do | Why | Example |
|
||||
|----|-----|---------|
|
||||
| Visual hierarchy | Guides the eye | Large numbers for KPIs, smaller for details |
|
||||
| Intentional white space | Breathability | 32-48px between sections, not uniform 16px |
|
||||
| Typography scale | Professional rhythm | 12/14/16/20/24/32/48px - skip sizes intentionally |
|
||||
| Micro-interactions | Delight users | Button hover states, loading skeletons |
|
||||
| Consistent but varied | Not monotonous | Same colors, different layouts per page |
|
||||
| Data density where needed | Efficient | Tables for lists, not cards |
|
||||
| Strategic color accents | Draw attention | Red only for primary CTAs, not decoration |
|
||||
| Real content sizes | Accommodate reality | Long project names, international characters |
|
||||
|
||||
## Brand Colors
|
||||
|
||||
```css
|
||||
:root {
|
||||
/* Brand Colors */
|
||||
--color-primary: #de0f1e; /* Primary Red - CTAs, alerts */
|
||||
--color-primary-hover: #c00d1a;
|
||||
--color-secondary: #053d57; /* Dark Blue - headers, sidebar */
|
||||
--color-accent: #557f8c; /* Teal - links, secondary elements */
|
||||
--color-background: #fefefe; /* White - backgrounds */
|
||||
|
||||
/* Semantic */
|
||||
--color-success: #10b981;
|
||||
--color-warning: #f59e0b;
|
||||
--color-error: #ef4444;
|
||||
|
||||
/* Neutrals (warm, not cold gray) */
|
||||
--color-gray-50: #fafaf9;
|
||||
--color-gray-100: #f5f5f4;
|
||||
--color-gray-200: #e7e5e4;
|
||||
--color-gray-500: #78716c;
|
||||
--color-gray-900: #1c1917;
|
||||
}
|
||||
```
|
||||
|
||||
## Typography
|
||||
|
||||
- **Font Family**: Montserrat
|
||||
- **Headings**: 600/700 weight
|
||||
- **Body**: 300/400 weight (Montserrat Light)
|
||||
|
||||
```css
|
||||
:root {
|
||||
--font-family: 'Montserrat', system-ui, sans-serif;
|
||||
--font-size-xs: 0.75rem; /* 12px */
|
||||
--font-size-sm: 0.875rem; /* 14px */
|
||||
--font-size-base: 1rem; /* 16px */
|
||||
--font-size-lg: 1.25rem; /* 20px */
|
||||
--font-size-xl: 1.5rem; /* 24px */
|
||||
--font-size-2xl: 2rem; /* 32px */
|
||||
--font-size-3xl: 3rem; /* 48px */
|
||||
}
|
||||
```
|
||||
|
||||
## Design Principles
|
||||
|
||||
1. **Mobile First**: Base styles for mobile, enhanced for larger screens
|
||||
2. **Accessibility**: WCAG 2.1 AA compliance, keyboard navigation, screen reader support
|
||||
3. **Performance**: Server Components, minimal client JavaScript
|
||||
4. **Consistency**: Design tokens, component library, consistent patterns
|
||||
5. **Feedback**: Loading states, error messages, success confirmations
|
||||
|
||||
## Responsive Breakpoints
|
||||
|
||||
```css
|
||||
/* Tailwind CSS default breakpoints */
|
||||
sm: 640px /* Small tablets */
|
||||
md: 768px /* Tablets */
|
||||
lg: 1024px /* Laptops */
|
||||
xl: 1280px /* Desktops */
|
||||
2xl: 1536px /* Large monitors */
|
||||
```
|
||||
|
||||
## Layout Architecture
|
||||
|
||||
### Application Shell
|
||||
|
||||
```
|
||||
┌─────────────────────────────────────────────────────────────────────────────┐
|
||||
│ DESKTOP LAYOUT │
|
||||
├─────────────────────────────────────────────────────────────────────────────┤
|
||||
│ │
|
||||
│ ┌─────────────┐ ┌──────────────────────────────────────────────────────┐ │
|
||||
│ │ │ │ HEADER │ │
|
||||
│ │ │ │ Logo Search (optional) User Menu │ │
|
||||
│ │ │ └──────────────────────────────────────────────────────┘ │
|
||||
│ │ │ ┌──────────────────────────────────────────────────────┐ │
|
||||
│ │ SIDEBAR │ │ │ │
|
||||
│ │ │ │ │ │
|
||||
│ │ - Dashboard│ │ MAIN CONTENT │ │
|
||||
│ │ - Rounds │ │ │ │
|
||||
│ │ - Projects │ │ │ │
|
||||
│ │ - Jury │ │ │ │
|
||||
│ │ - Reports │ │ │ │
|
||||
│ │ │ │ │ │
|
||||
│ │ │ │ │ │
|
||||
│ └─────────────┘ └──────────────────────────────────────────────────────┘ │
|
||||
│ │
|
||||
└─────────────────────────────────────────────────────────────────────────────┘
|
||||
|
||||
┌─────────────────────────────────────────────────────────────────────────────┐
|
||||
│ MOBILE LAYOUT │
|
||||
├─────────────────────────────────────────────────────────────────────────────┤
|
||||
│ │
|
||||
│ ┌──────────────────────────────────────────────────────────────────────┐ │
|
||||
│ │ ☰ Logo User Avatar │ │
|
||||
│ └──────────────────────────────────────────────────────────────────────┘ │
|
||||
│ ┌──────────────────────────────────────────────────────────────────────┐ │
|
||||
│ │ │ │
|
||||
│ │ │ │
|
||||
│ │ │ │
|
||||
│ │ MAIN CONTENT │ │
|
||||
│ │ (full width) │ │
|
||||
│ │ │ │
|
||||
│ │ │ │
|
||||
│ │ │ │
|
||||
│ │ │ │
|
||||
│ └──────────────────────────────────────────────────────────────────────┘ │
|
||||
│ ┌──────────────────────────────────────────────────────────────────────┐ │
|
||||
│ │ 🏠 📋 📊 👤 │ │
|
||||
│ │ Home Projects Reports Profile │ │
|
||||
│ └──────────────────────────────────────────────────────────────────────┘ │
|
||||
│ │
|
||||
└─────────────────────────────────────────────────────────────────────────────┘
|
||||
```
|
||||
|
||||
### Layout Components
|
||||
|
||||
```typescript
|
||||
// src/components/layouts/app-layout.tsx
|
||||
|
||||
import { Sidebar } from './sidebar'
|
||||
import { Header } from './header'
|
||||
import { MobileNav } from './mobile-nav'
|
||||
|
||||
export function AppLayout({ children }: { children: React.ReactNode }) {
|
||||
return (
|
||||
<div className="min-h-screen bg-background">
|
||||
{/* Desktop Sidebar */}
|
||||
<Sidebar className="hidden lg:fixed lg:inset-y-0 lg:flex lg:w-64" />
|
||||
|
||||
{/* Main Content Area */}
|
||||
<div className="lg:pl-64">
|
||||
<Header />
|
||||
<main className="p-4 lg:p-8">{children}</main>
|
||||
</div>
|
||||
|
||||
{/* Mobile Bottom Navigation */}
|
||||
<MobileNav className="fixed bottom-0 left-0 right-0 lg:hidden" />
|
||||
</div>
|
||||
)
|
||||
}
|
||||
```
|
||||
|
||||
## Component Hierarchy
|
||||
|
||||
```
|
||||
src/components/
|
||||
├── ui/ # shadcn/ui base components
|
||||
│ ├── button.tsx
|
||||
│ ├── input.tsx
|
||||
│ ├── card.tsx
|
||||
│ ├── table.tsx
|
||||
│ ├── dialog.tsx
|
||||
│ ├── dropdown-menu.tsx
|
||||
│ ├── form.tsx
|
||||
│ ├── select.tsx
|
||||
│ ├── textarea.tsx
|
||||
│ ├── badge.tsx
|
||||
│ ├── progress.tsx
|
||||
│ ├── skeleton.tsx
|
||||
│ ├── toast.tsx
|
||||
│ └── ...
|
||||
├── layouts/ # Layout components
|
||||
│ ├── app-layout.tsx
|
||||
│ ├── auth-layout.tsx
|
||||
│ ├── sidebar.tsx
|
||||
│ ├── header.tsx
|
||||
│ └── mobile-nav.tsx
|
||||
├── forms/ # Form components
|
||||
│ ├── evaluation-form.tsx
|
||||
│ ├── project-import-form.tsx
|
||||
│ ├── round-settings-form.tsx
|
||||
│ └── user-invite-form.tsx
|
||||
├── data-display/ # Data display components
|
||||
│ ├── project-card.tsx
|
||||
│ ├── project-list.tsx
|
||||
│ ├── project-table.tsx
|
||||
│ ├── evaluation-summary.tsx
|
||||
│ ├── progress-tracker.tsx
|
||||
│ └── stats-card.tsx
|
||||
└── shared/ # Shared utility components
|
||||
├── file-viewer.tsx
|
||||
├── loading-state.tsx
|
||||
├── error-state.tsx
|
||||
├── empty-state.tsx
|
||||
└── confirm-dialog.tsx
|
||||
```
|
||||
|
||||
## Page Layouts by View
|
||||
|
||||
### Admin Dashboard
|
||||
|
||||
```
|
||||
┌─────────────────────────────────────────────────────────────────┐
|
||||
│ ADMIN DASHBOARD │
|
||||
├─────────────────────────────────────────────────────────────────┤
|
||||
│ │
|
||||
│ ┌─────────────┐ ┌─────────────┐ ┌─────────────┐ ┌───────────┐ │
|
||||
│ │ Projects │ │ Evaluations │ │ Jury Active │ │ Time Left │ │
|
||||
│ │ 130 │ │ 234/390 │ │ 12/15 │ │ 5 days │ │
|
||||
│ └─────────────┘ └─────────────┘ └─────────────┘ └───────────┘ │
|
||||
│ │
|
||||
│ ┌────────────────────────────────────────────────────────────┐ │
|
||||
│ │ PROGRESS BY PROJECT │ │
|
||||
│ │ ████████████████████████████░░░░░░░░░░░░ 60% Complete │ │
|
||||
│ └────────────────────────────────────────────────────────────┘ │
|
||||
│ │
|
||||
│ ┌─────────────────────────────┐ ┌────────────────────────────┐ │
|
||||
│ │ JURY PROGRESS │ │ RECENT ACTIVITY │ │
|
||||
│ │ │ │ │ │
|
||||
│ │ Alice ████████░░ 80% │ │ • John submitted eval... │ │
|
||||
│ │ Bob ██████░░░░ 60% │ │ • Sarah started eval... │ │
|
||||
│ │ Carol ████░░░░░░ 40% │ │ • Admin extended window │ │
|
||||
│ │ ... │ │ • ... │ │
|
||||
│ └─────────────────────────────┘ └────────────────────────────┘ │
|
||||
│ │
|
||||
└─────────────────────────────────────────────────────────────────┘
|
||||
|
||||
Mobile: Stats stack vertically, Progress & Activity in tabs
|
||||
```
|
||||
|
||||
### Jury Project List
|
||||
|
||||
```
|
||||
DESKTOP:
|
||||
┌─────────────────────────────────────────────────────────────────┐
|
||||
│ MY ASSIGNED PROJECTS Filter ▼ Search │
|
||||
├─────────────────────────────────────────────────────────────────┤
|
||||
│ │
|
||||
│ ┌────────────────────────────────────────────────────────────┐ │
|
||||
│ │ Title │ Team │ Status │ Actions │ │
|
||||
│ ├────────────────────────────────────────────────────────────┤ │
|
||||
│ │ Ocean Cleanup AI │ BlueWave │ ✅ Done │ View │ │
|
||||
│ │ Coral Restoration │ ReefGuard │ 📝 Draft │ Continue │ │
|
||||
│ │ Plastic Tracker │ CleanSeas │ ⏳ Pending│ Start │ │
|
||||
│ │ ... │ │ │ │ │
|
||||
│ └────────────────────────────────────────────────────────────┘ │
|
||||
│ │
|
||||
│ Showing 1-10 of 15 < 1 2 > │
|
||||
└─────────────────────────────────────────────────────────────────┘
|
||||
|
||||
MOBILE (Card View):
|
||||
┌─────────────────────────────────────┐
|
||||
│ MY PROJECTS (15) 🔍 Filter │
|
||||
├─────────────────────────────────────┤
|
||||
│ │
|
||||
│ ┌─────────────────────────────────┐│
|
||||
│ │ Ocean Cleanup AI ││
|
||||
│ │ Team: BlueWave ││
|
||||
│ │ ✅ Completed ││
|
||||
│ │ [View →] ││
|
||||
│ └─────────────────────────────────┘│
|
||||
│ │
|
||||
│ ┌─────────────────────────────────┐│
|
||||
│ │ Coral Restoration ││
|
||||
│ │ Team: ReefGuard ││
|
||||
│ │ 📝 Draft saved ││
|
||||
│ │ [Continue →] ││
|
||||
│ └─────────────────────────────────┘│
|
||||
│ │
|
||||
│ ┌─────────────────────────────────┐│
|
||||
│ │ Plastic Tracker ││
|
||||
│ │ Team: CleanSeas ││
|
||||
│ │ ⏳ Not started ││
|
||||
│ │ [Start →] ││
|
||||
│ └─────────────────────────────────┘│
|
||||
│ │
|
||||
└─────────────────────────────────────┘
|
||||
```
|
||||
|
||||
### Evaluation Form
|
||||
|
||||
```
|
||||
DESKTOP (Side Panel):
|
||||
┌─────────────────────────────────────────────────────────────────┐
|
||||
│ PROJECT DETAILS │ EVALUATION FORM │
|
||||
├─────────────────────────────────────────────────────────────────┤
|
||||
│ │ │
|
||||
│ Ocean Cleanup AI │ Need Clarity │
|
||||
│ Team: BlueWave Tech │ ○ 1 ○ 2 ○ 3 ● 4 ○ 5 │
|
||||
│ │ │
|
||||
│ [📄 Exec Summary] [📊 Deck] │ Solution Relevance │
|
||||
│ [🎬 Video] │ ○ 1 ○ 2 ● 3 ○ 4 ○ 5 │
|
||||
│ │ │
|
||||
│ Description: │ Gap Analysis │
|
||||
│ Our AI-powered system uses │ ○ 1 ○ 2 ○ 3 ○ 4 ● 5 │
|
||||
│ machine learning to identify │ │
|
||||
│ ocean plastic concentrations... │ Target Customers │
|
||||
│ │ ○ 1 ○ 2 ○ 3 ● 4 ○ 5 │
|
||||
│ Tags: AI, Plastic, Monitoring │ │
|
||||
│ │ Ocean Impact │
|
||||
│ │ ○ 1 ○ 2 ○ 3 ○ 4 ● 5 │
|
||||
│ │ │
|
||||
│ │ Global Score (1-10) │
|
||||
│ │ [ 8 ] │
|
||||
│ │ │
|
||||
│ │ Semi-finalist? │
|
||||
│ │ (●) Yes ( ) No │
|
||||
│ │ │
|
||||
│ │ Feedback │
|
||||
│ │ ┌────────────────────┐ │
|
||||
│ │ │ Strong technical │ │
|
||||
│ │ │ approach with... │ │
|
||||
│ │ └────────────────────┘ │
|
||||
│ │ │
|
||||
│ │ Autosaved 2s ago │
|
||||
│ │ [Submit Evaluation] │
|
||||
│ │ │
|
||||
└─────────────────────────────────────────────────────────────────┘
|
||||
|
||||
MOBILE (Full Screen Wizard):
|
||||
┌─────────────────────────────────────┐
|
||||
│ ← Ocean Cleanup AI Step 3/7 │
|
||||
├─────────────────────────────────────┤
|
||||
│ │
|
||||
│ Gap Analysis │
|
||||
│ │
|
||||
│ How well does the project │
|
||||
│ analyze market gaps? │
|
||||
│ │
|
||||
│ ┌─────────────────────────────────┐│
|
||||
│ │ ││
|
||||
│ │ 1 2 3 4 5 ││
|
||||
│ │ (○) (○) (○) (○) (●) ││
|
||||
│ │ Poor Excellent ││
|
||||
│ │ ││
|
||||
│ └─────────────────────────────────┘│
|
||||
│ │
|
||||
│ │
|
||||
│ │
|
||||
│ │
|
||||
│ │
|
||||
│ ┌─────────────────────────────────┐│
|
||||
│ │ ○ ○ ● ○ ○ ○ ○ ││
|
||||
│ └─────────────────────────────────┘│
|
||||
│ │
|
||||
│ [← Previous] [Next →] │
|
||||
│ │
|
||||
└─────────────────────────────────────┘
|
||||
```
|
||||
|
||||
## Design System
|
||||
|
||||
### Color Palette (MOPC Brand)
|
||||
|
||||
```css
|
||||
/* CSS Variables in tailwind.config.ts - MOPC Brand Colors */
|
||||
:root {
|
||||
/* Brand Colors */
|
||||
--color-primary: 354 90% 47%; /* #de0f1e - Primary Red */
|
||||
--color-secondary: 198 85% 18%; /* #053d57 - Dark Blue */
|
||||
--color-accent: 194 25% 44%; /* #557f8c - Teal */
|
||||
|
||||
/* shadcn/ui mapped to MOPC brand */
|
||||
--background: 0 0% 100%; /* #fefefe */
|
||||
--foreground: 198 85% 18%; /* Dark Blue for text */
|
||||
--card: 0 0% 100%;
|
||||
--card-foreground: 198 85% 18%;
|
||||
--popover: 0 0% 100%;
|
||||
--popover-foreground: 198 85% 18%;
|
||||
--primary: 354 90% 47%; /* Primary Red - main actions */
|
||||
--primary-foreground: 0 0% 100%;
|
||||
--secondary: 30 6% 96%; /* Warm gray */
|
||||
--secondary-foreground: 198 85% 18%;
|
||||
--muted: 30 6% 96%;
|
||||
--muted-foreground: 30 8% 45%;
|
||||
--accent: 194 25% 44%; /* Teal */
|
||||
--accent-foreground: 0 0% 100%;
|
||||
--destructive: 0 84.2% 60.2%;
|
||||
--destructive-foreground: 0 0% 100%;
|
||||
--border: 30 6% 91%;
|
||||
--input: 30 6% 91%;
|
||||
--ring: 354 90% 47%; /* Primary Red for focus */
|
||||
--radius: 0.5rem;
|
||||
|
||||
/* Semantic colors */
|
||||
--success: 142.1 76.2% 36.3%;
|
||||
--warning: 38 92% 50%;
|
||||
--info: 194 25% 44%; /* Teal */
|
||||
}
|
||||
```
|
||||
|
||||
### Typography (Montserrat)
|
||||
|
||||
```typescript
|
||||
// tailwind.config.ts
|
||||
const config = {
|
||||
theme: {
|
||||
extend: {
|
||||
fontFamily: {
|
||||
sans: ['Montserrat', 'system-ui', 'sans-serif'],
|
||||
mono: ['JetBrains Mono', 'monospace'],
|
||||
},
|
||||
fontWeight: {
|
||||
light: '300',
|
||||
normal: '400',
|
||||
semibold: '600',
|
||||
bold: '700',
|
||||
},
|
||||
fontSize: {
|
||||
'display-lg': ['3rem', { lineHeight: '1.1', fontWeight: '700' }],
|
||||
'display': ['2.25rem', { lineHeight: '1.2', fontWeight: '700' }],
|
||||
'heading': ['1.5rem', { lineHeight: '1.3', fontWeight: '600' }],
|
||||
'subheading': ['1.125rem', { lineHeight: '1.4', fontWeight: '600' }],
|
||||
'body': ['1rem', { lineHeight: '1.5', fontWeight: '400' }],
|
||||
'small': ['0.875rem', { lineHeight: '1.5', fontWeight: '400' }],
|
||||
'tiny': ['0.75rem', { lineHeight: '1.5', fontWeight: '400' }],
|
||||
},
|
||||
},
|
||||
},
|
||||
}
|
||||
```
|
||||
|
||||
### Spacing System
|
||||
|
||||
```
|
||||
Base unit: 4px
|
||||
|
||||
0 = 0px
|
||||
1 = 4px
|
||||
2 = 8px
|
||||
3 = 12px
|
||||
4 = 16px
|
||||
5 = 20px
|
||||
6 = 24px
|
||||
8 = 32px
|
||||
10 = 40px
|
||||
12 = 48px
|
||||
16 = 64px
|
||||
20 = 80px
|
||||
24 = 96px
|
||||
```
|
||||
|
||||
## Component Patterns
|
||||
|
||||
### Loading States
|
||||
|
||||
```typescript
|
||||
// Always show skeleton while loading
|
||||
function ProjectList() {
|
||||
const { data, isLoading } = trpc.project.list.useQuery({ roundId })
|
||||
|
||||
if (isLoading) {
|
||||
return (
|
||||
<div className="space-y-4">
|
||||
{Array.from({ length: 5 }).map((_, i) => (
|
||||
<Skeleton key={i} className="h-20 w-full" />
|
||||
))}
|
||||
</div>
|
||||
)
|
||||
}
|
||||
|
||||
return <ProjectTable projects={data.projects} />
|
||||
}
|
||||
```
|
||||
|
||||
### Error States
|
||||
|
||||
```typescript
|
||||
// Consistent error display
|
||||
function ErrorState({
|
||||
title = 'Something went wrong',
|
||||
message,
|
||||
onRetry,
|
||||
}: {
|
||||
title?: string
|
||||
message: string
|
||||
onRetry?: () => void
|
||||
}) {
|
||||
return (
|
||||
<div className="flex flex-col items-center justify-center p-8 text-center">
|
||||
<AlertCircle className="h-12 w-12 text-destructive" />
|
||||
<h3 className="mt-4 text-lg font-semibold">{title}</h3>
|
||||
<p className="mt-2 text-muted-foreground">{message}</p>
|
||||
{onRetry && (
|
||||
<Button onClick={onRetry} className="mt-4">
|
||||
Try Again
|
||||
</Button>
|
||||
)}
|
||||
</div>
|
||||
)
|
||||
}
|
||||
```
|
||||
|
||||
### Empty States
|
||||
|
||||
```typescript
|
||||
function EmptyState({
|
||||
icon: Icon,
|
||||
title,
|
||||
description,
|
||||
action,
|
||||
}: {
|
||||
icon: React.ComponentType<{ className?: string }>
|
||||
title: string
|
||||
description: string
|
||||
action?: React.ReactNode
|
||||
}) {
|
||||
return (
|
||||
<div className="flex flex-col items-center justify-center p-8 text-center">
|
||||
<Icon className="h-12 w-12 text-muted-foreground" />
|
||||
<h3 className="mt-4 text-lg font-semibold">{title}</h3>
|
||||
<p className="mt-2 text-muted-foreground">{description}</p>
|
||||
{action && <div className="mt-4">{action}</div>}
|
||||
</div>
|
||||
)
|
||||
}
|
||||
```
|
||||
|
||||
### Responsive Patterns
|
||||
|
||||
```typescript
|
||||
// Table on desktop, cards on mobile
|
||||
function ProjectDisplay({ projects }: { projects: Project[] }) {
|
||||
return (
|
||||
<>
|
||||
{/* Desktop: Table */}
|
||||
<div className="hidden md:block">
|
||||
<ProjectTable projects={projects} />
|
||||
</div>
|
||||
|
||||
{/* Mobile: Cards */}
|
||||
<div className="md:hidden space-y-4">
|
||||
{projects.map((project) => (
|
||||
<ProjectCard key={project.id} project={project} />
|
||||
))}
|
||||
</div>
|
||||
</>
|
||||
)
|
||||
}
|
||||
```
|
||||
|
||||
## Touch Targets
|
||||
|
||||
All interactive elements must have a minimum touch target of 44x44px on mobile:
|
||||
|
||||
```typescript
|
||||
// Good: Large touch target
|
||||
<Button className="min-h-[44px] min-w-[44px] px-4">
|
||||
Click me
|
||||
</Button>
|
||||
|
||||
// Good: Icon button with padding
|
||||
<Button variant="ghost" size="icon" className="h-11 w-11">
|
||||
<Menu className="h-5 w-5" />
|
||||
</Button>
|
||||
```
|
||||
|
||||
## Form Patterns
|
||||
|
||||
### Autosave with Debounce
|
||||
|
||||
```typescript
|
||||
function EvaluationForm({ evaluation }: { evaluation: Evaluation }) {
|
||||
const utils = trpc.useUtils()
|
||||
|
||||
const autosave = trpc.evaluation.autosave.useMutation({
|
||||
onSuccess: () => {
|
||||
utils.evaluation.get.invalidate({ assignmentId: evaluation.assignmentId })
|
||||
},
|
||||
})
|
||||
|
||||
const debouncedSave = useMemo(
|
||||
() => debounce((data: FormData) => autosave.mutate(data), 1000),
|
||||
[autosave]
|
||||
)
|
||||
|
||||
return (
|
||||
<Form
|
||||
onChange={(data) => {
|
||||
debouncedSave(data)
|
||||
}}
|
||||
>
|
||||
{/* Form fields */}
|
||||
<div className="text-sm text-muted-foreground">
|
||||
{autosave.isPending ? 'Saving...' : 'Autosaved'}
|
||||
</div>
|
||||
</Form>
|
||||
)
|
||||
}
|
||||
```
|
||||
|
||||
### Form Validation
|
||||
|
||||
```typescript
|
||||
const evaluationSchema = z.object({
|
||||
criterionScores: z.record(z.number().min(1).max(5)),
|
||||
globalScore: z.number().min(1).max(10),
|
||||
binaryDecision: z.boolean(),
|
||||
feedbackText: z.string().min(10, 'Please provide at least 10 characters'),
|
||||
})
|
||||
|
||||
function EvaluationForm() {
|
||||
const form = useForm<z.infer<typeof evaluationSchema>>({
|
||||
resolver: zodResolver(evaluationSchema),
|
||||
})
|
||||
|
||||
return (
|
||||
<Form {...form}>
|
||||
<FormField
|
||||
control={form.control}
|
||||
name="feedbackText"
|
||||
render={({ field }) => (
|
||||
<FormItem>
|
||||
<FormLabel>Feedback</FormLabel>
|
||||
<FormControl>
|
||||
<Textarea {...field} />
|
||||
</FormControl>
|
||||
<FormMessage /> {/* Shows validation error */}
|
||||
</FormItem>
|
||||
)}
|
||||
/>
|
||||
</Form>
|
||||
)
|
||||
}
|
||||
```
|
||||
|
||||
## Accessibility Checklist
|
||||
|
||||
- [ ] All images have alt text
|
||||
- [ ] Form inputs have labels
|
||||
- [ ] Color is not the only indicator (icons + text)
|
||||
- [ ] Focus states are visible
|
||||
- [ ] Skip links for main content
|
||||
- [ ] Keyboard navigation works
|
||||
- [ ] Screen reader tested
|
||||
- [ ] Reduced motion respected
|
||||
- [ ] Sufficient color contrast (4.5:1 for text)
|
||||
|
||||
## Animation Patterns
|
||||
|
||||
### Page Transitions (Motion)
|
||||
```typescript
|
||||
const pageVariants = {
|
||||
initial: { opacity: 0, y: 20 },
|
||||
enter: { opacity: 1, y: 0, transition: { duration: 0.3, ease: [0.25, 0.1, 0.25, 1] } },
|
||||
exit: { opacity: 0, y: -10, transition: { duration: 0.2 } }
|
||||
}
|
||||
```
|
||||
|
||||
### List Stagger (Items enter one by one)
|
||||
```typescript
|
||||
const listVariants = {
|
||||
visible: { transition: { staggerChildren: 0.05 } }
|
||||
}
|
||||
```
|
||||
|
||||
### Spring Physics (Natural movement)
|
||||
```typescript
|
||||
const springConfig = { type: "spring", stiffness: 400, damping: 30 }
|
||||
```
|
||||
|
||||
## Mobile-Specific UX Patterns
|
||||
|
||||
| Pattern | Implementation |
|
||||
|---------|----------------|
|
||||
| Bottom sheets instead of modals | Vaul drawer, thumb-reachable |
|
||||
| Swipe gestures | Motion drag handlers |
|
||||
| Pull-to-refresh | Custom spring animation |
|
||||
| Haptic feedback hints | Visual bounce on limits |
|
||||
| Large touch targets | Min 44x44px, generous spacing |
|
||||
| Thumb-zone navigation | Bottom nav, not hamburger menu |
|
||||
| Native-feeling scrolls | CSS scroll-snap, momentum |
|
||||
|
||||
## Performance Targets
|
||||
|
||||
| Metric | Target | How |
|
||||
|--------|--------|-----|
|
||||
| First Contentful Paint | < 1.5s | SSR, optimized fonts |
|
||||
| Largest Contentful Paint | < 2.5s | Image optimization, lazy loading |
|
||||
| Time to Interactive | < 3.5s | Code splitting, minimal JS |
|
||||
| Cumulative Layout Shift | < 0.1 | Reserved space, skeleton loaders |
|
||||
| Touch response | < 100ms | Optimistic UI, spring animations |
|
||||
| Scroll performance | 60fps | CSS transforms, will-change |
|
||||
|
||||
## Component Design Rules
|
||||
|
||||
### Buttons
|
||||
- Primary: Solid brand red (#de0f1e), 12px radius, subtle shadow
|
||||
- Secondary: Ghost/outline, same radius
|
||||
- Hover: Scale 1.02, slight lift shadow
|
||||
- Active: Scale 0.98, pressed feel
|
||||
- Loading: Spinner replaces text, same width
|
||||
|
||||
### Tables (for data density)
|
||||
- Zebra striping: Subtle, not harsh
|
||||
- Row hover: Slight highlight, not full color change
|
||||
- Sortable headers: Subtle indicator, not loud
|
||||
- Mobile: Horizontal scroll with sticky first column
|
||||
|
||||
### Forms
|
||||
- Labels above inputs (not placeholder-as-label)
|
||||
- Clear focus states (brand color ring)
|
||||
- Inline validation (not modal alerts)
|
||||
- Autosave indicator: Subtle, top-right
|
||||
|
||||
### Empty States
|
||||
- Illustration + helpful text
|
||||
- Clear CTA to fix the empty state
|
||||
- Not just "No data found"
|
||||
|
||||
## Related Documentation
|
||||
|
||||
- [Architecture Overview](./README.md) - System design
|
||||
- [API Design](./api.md) - tRPC endpoints
|
||||
Reference in New Issue
Block a user