feat: add React frontend. Nginix reverse proxy, Docker Compose

This commit is contained in:
ikechukwu Elizabeth
2026-04-10 04:19:31 +01:00
parent 8db53078d6
commit c7b21e719a
9 changed files with 655 additions and 0 deletions
+23
View File
@@ -0,0 +1,23 @@
# Stage 1: Build the React app
FROM node:24-alpine AS builder
WORKDIR /app
COPY package*.json ./
RUN npm install
COPY . .
RUN npm run build
# Stage 2: Serve with Nginx
FROM nginx:alpine
COPY --from=builder /app/dist /usr/share/nginx/html
COPY nginx.conf /etc/nginx/conf.d/default.conf
EXPOSE 80
CMD ["nginx", "-g", "daemon off;"]
+13
View File
@@ -0,0 +1,13 @@
<!DOCTYPE html>
<html lang="en">
<head>
<meta charset="UTF-8" />
<meta name="viewport" content="width=device-width, initial-scale=1.0" />
<link rel="icon" type="image/svg+xml" href="/favicon.svg" />
<title>Task Platform</title>
</head>
<body>
<div id="root"></div>
<script type="module" src="/src/main.jsx"></script>
</body>
</html>
+16
View File
@@ -0,0 +1,16 @@
server {
listen 80;
location / {
root /usr/share/nginx/html;
index index.html;
try_files $uri $uri/ /index.html;
}
location /api/ {
proxy_pass http://backend:3000/;
proxy_http_version 1.1;
proxy_set_header Host $host;
proxy_set_header X-Real-IP $remote_addr;
}
}
+19
View File
@@ -0,0 +1,19 @@
{
"name": "task-frontend",
"private": true,
"version": "1.0.0",
"type": "module",
"scripts": {
"dev": "vite",
"build": "vite build",
"preview": "vite preview"
},
"dependencies": {
"react": "^18.3.1",
"react-dom": "^18.3.1"
},
"devDependencies": {
"@vitejs/plugin-react": "^4.3.4",
"vite": "^6.0.5"
}
}
+155
View File
@@ -0,0 +1,155 @@
import { useEffect, useState } from 'react'
const API = '/api'
export default function App() {
const [tasks, setTasks] = useState([])
const [title, setTitle] = useState('')
const [loading, setLoading] = useState(true)
const [adding, setAdding] = useState(false)
const fetchTasks = async () => {
const res = await fetch(`${API}/tasks`)
const data = await res.json()
setTasks(data)
setLoading(false)
}
useEffect(() => { fetchTasks() }, [])
const addTask = async () => {
if (!title.trim()) return
setAdding(true)
await fetch(`${API}/tasks`, {
method: 'POST',
headers: { 'Content-Type': 'application/json' },
body: JSON.stringify({ title })
})
setTitle('')
setAdding(false)
fetchTasks()
}
const toggleTask = async (id) => {
await fetch(`${API}/tasks/${id}`, { method: 'PATCH' })
fetchTasks()
}
const deleteTask = async (id) => {
await fetch(`${API}/tasks/${id}`, { method: 'DELETE' })
fetchTasks()
}
const completed = tasks.filter(t => t.completed).length
const remaining = tasks.filter(t => !t.completed).length
const progress = tasks.length > 0 ? (completed / tasks.length) * 100 : 0
return (
<div className="app">
{/* LEFT PANEL */}
<div className="sidebar">
<div className="sidebar-top">
<div className="brand">
<div className="brand-icon">✓</div>
<div>
<h1>TaskFlow</h1>
<p className="brand-sub">Personal workspace</p>
</div>
</div>
<div className="stats">
<div className="stat-card">
<span className="stat-number">{tasks.length}</span>
<span className="stat-label">Total</span>
</div>
<div className="stat-card accent">
<span className="stat-number">{remaining}</span>
<span className="stat-label">Remaining</span>
</div>
<div className="stat-card green">
<span className="stat-number">{completed}</span>
<span className="stat-label">Done</span>
</div>
</div>
<div className="progress-section">
<div className="progress-header">
<span>Progress</span>
<span>{Math.round(progress)}%</span>
</div>
<div className="progress-bar">
<div className="progress-fill" style={{ width: `${progress}%` }} />
</div>
</div>
</div>
<div className="sidebar-footer">
<p className="built-by">Built with Node.js · Docker · Nginx</p>
<p className="built-by">Deployed via Docker Compose</p>
</div>
</div>
{/* RIGHT PANEL */}
<div className="main">
<div className="main-header">
<div>
<h2>My Tasks</h2>
<p className="main-sub">Stay focused. Ship things.</p>
</div>
</div>
<div className="input-area">
<input
value={title}
onChange={e => setTitle(e.target.value)}
onKeyDown={e => e.key === 'Enter' && addTask()}
placeholder="What needs to be done? Press Enter or click Add..."
/>
<button className="btn-add" onClick={addTask} disabled={adding}>
{adding ? '...' : '+ Add Task'}
</button>
</div>
<div className="task-section">
{loading && <p className="empty">Loading your tasks...</p>}
{!loading && tasks.length === 0 && (
<div className="empty-state">
<div className="empty-icon">📋</div>
<p className="empty-title">No tasks yet</p>
<p className="empty-desc">Add your first task above to get started</p>
</div>
)}
{tasks.filter(t => !t.completed).length > 0 && (
<div className="task-group">
<p className="group-label">● Active</p>
{tasks.filter(t => !t.completed).map(task => (
<div key={task.id} className="task-item">
<button className="check-btn" onClick={() => toggleTask(task.id)} title="Mark complete" />
<span className="task-title">{task.title}</span>
<button className="delete-btn" onClick={() => deleteTask(task.id)}>Delete</button>
</div>
))}
</div>
)}
{tasks.filter(t => t.completed).length > 0 && (
<div className="task-group">
<p className="group-label done-label">✓ Completed</p>
{tasks.filter(t => t.completed).map(task => (
<div key={task.id} className="task-item completed">
<button className="check-btn checked" onClick={() => toggleTask(task.id)} title="Mark incomplete">✓</button>
<span className="task-title">{task.title}</span>
<button className="delete-btn" onClick={() => deleteTask(task.id)}>Delete</button>
</div>
))}
</div>
)}
</div>
</div>
</div>
)
}
+383
View File
@@ -0,0 +1,383 @@
@import url('https://fonts.googleapis.com/css2?family=Syne:wght@400;500;600;700;800&family=DM+Sans:wght@300;400;500;600&display=swap');
*, *::before, *::after { box-sizing: border-box; margin: 0; padding: 0; }
:root {
--bg: #0a0b0f;
--surface: #111318;
--surface2: #16181f;
--border: #1e2028;
--accent: #6366f1;
--accent-dim: rgba(99,102,241,0.12);
--green: #22c55e;
--green-dim: rgba(34,197,94,0.12);
--red: #ef4444;
--red-dim: rgba(239,68,68,0.12);
--text: #f1f5f9;
--text-2: #94a3b8;
--text-3: #3d4257;
}
body {
font-family: 'DM Sans', sans-serif;
background: var(--bg);
color: var(--text);
min-height: 100vh;
overflow: hidden;
}
/* LAYOUT */
.app {
display: grid;
grid-template-columns: 300px 1fr;
min-height: 100vh;
}
/* SIDEBAR */
.sidebar {
background: var(--surface);
border-right: 1px solid var(--border);
display: flex;
flex-direction: column;
justify-content: space-between;
padding: 36px 28px;
}
.sidebar-top { display: flex; flex-direction: column; gap: 36px; }
.brand { display: flex; align-items: center; gap: 14px; }
.brand-icon {
width: 44px;
height: 44px;
background: var(--accent);
border-radius: 12px;
display: flex;
align-items: center;
justify-content: center;
font-size: 1.1rem;
font-weight: 800;
color: white;
flex-shrink: 0;
box-shadow: 0 0 24px rgba(99,102,241,0.35);
font-family: 'Syne', sans-serif;
}
h1 {
font-family: 'Syne', sans-serif;
font-size: 1.2rem;
font-weight: 800;
color: var(--text);
letter-spacing: -0.5px;
}
.brand-sub {
font-size: 0.72rem;
color: var(--text-3);
font-weight: 500;
margin-top: 2px;
text-transform: uppercase;
letter-spacing: 0.8px;
}
/* STATS */
.stats {
display: flex;
flex-direction: column;
gap: 10px;
}
.stat-card {
background: var(--surface2);
border: 1px solid var(--border);
border-radius: 10px;
padding: 14px 18px;
display: flex;
justify-content: space-between;
align-items: center;
transition: border-color 0.2s;
}
.stat-card:hover { border-color: var(--text-3); }
.stat-card.accent { border-color: rgba(99,102,241,0.3); background: var(--accent-dim); }
.stat-card.green { border-color: rgba(34,197,94,0.3); background: var(--green-dim); }
.stat-number {
font-family: 'Syne', sans-serif;
font-size: 1.6rem;
font-weight: 800;
color: var(--text);
line-height: 1;
}
.stat-card.accent .stat-number { color: #818cf8; }
.stat-card.green .stat-number { color: var(--green); }
.stat-label {
font-size: 0.75rem;
color: var(--text-2);
font-weight: 500;
text-transform: uppercase;
letter-spacing: 0.5px;
}
/* PROGRESS */
.progress-section { display: flex; flex-direction: column; gap: 10px; }
.progress-header {
display: flex;
justify-content: space-between;
font-size: 0.78rem;
color: var(--text-2);
font-weight: 600;
text-transform: uppercase;
letter-spacing: 0.5px;
}
.progress-bar {
height: 6px;
background: var(--border);
border-radius: 3px;
overflow: hidden;
}
.progress-fill {
height: 100%;
background: linear-gradient(90deg, var(--accent), var(--green));
border-radius: 3px;
transition: width 0.5s ease;
}
.sidebar-footer { padding-top: 20px; border-top: 1px solid var(--border); }
.built-by {
font-size: 0.7rem;
color: var(--text-3);
font-weight: 500;
line-height: 1.8;
letter-spacing: 0.3px;
}
/* MAIN */
.main {
display: flex;
flex-direction: column;
padding: 40px 48px;
overflow-y: auto;
max-height: 100vh;
}
.main-header {
margin-bottom: 32px;
padding-bottom: 24px;
border-bottom: 1px solid var(--border);
}
.main-header h2 {
font-family: 'Syne', sans-serif;
font-size: 1.8rem;
font-weight: 800;
color: var(--text);
letter-spacing: -0.8px;
}
.main-sub {
font-size: 0.85rem;
color: var(--text-3);
margin-top: 4px;
font-weight: 500;
}
/* INPUT */
.input-area {
display: flex;
gap: 12px;
margin-bottom: 36px;
}
input {
flex: 1;
background: var(--surface);
border: 1px solid var(--border);
border-radius: 10px;
padding: 14px 20px;
font-size: 0.95rem;
font-family: 'DM Sans', sans-serif;
color: var(--text);
outline: none;
transition: border-color 0.15s, box-shadow 0.15s;
}
input::placeholder { color: var(--text-3); }
input:focus {
border-color: var(--accent);
box-shadow: 0 0 0 3px rgba(99,102,241,0.12);
}
.btn-add {
background: var(--accent);
color: white;
border: none;
border-radius: 10px;
padding: 14px 28px;
font-size: 0.9rem;
font-weight: 700;
font-family: 'DM Sans', sans-serif;
cursor: pointer;
transition: all 0.15s;
white-space: nowrap;
box-shadow: 0 4px 20px rgba(99,102,241,0.3);
letter-spacing: 0.2px;
}
.btn-add:hover { background: #4f46e5; transform: translateY(-1px); box-shadow: 0 8px 24px rgba(99,102,241,0.4); }
.btn-add:active { transform: translateY(0); }
.btn-add:disabled { opacity: 0.6; cursor: not-allowed; }
/* TASK GROUPS */
.task-section { display: flex; flex-direction: column; gap: 28px; }
.task-group { display: flex; flex-direction: column; gap: 8px; }
.group-label {
font-size: 0.72rem;
font-weight: 700;
color: var(--accent);
text-transform: uppercase;
letter-spacing: 1px;
margin-bottom: 4px;
padding-left: 4px;
}
.done-label { color: var(--green); }
/* TASK ITEMS */
.task-item {
display: flex;
align-items: center;
gap: 14px;
background: var(--surface);
border: 1px solid var(--border);
border-radius: 10px;
padding: 16px 20px;
transition: all 0.15s;
animation: slideIn 0.2s ease;
}
@keyframes slideIn {
from { opacity: 0; transform: translateY(6px); }
to { opacity: 1; transform: translateY(0); }
}
.task-item:hover {
border-color: var(--accent);
background: var(--surface2);
transform: translateX(3px);
}
.task-item.completed {
background: var(--green-dim);
border-color: rgba(34,197,94,0.25);
}
.task-item.completed:hover {
border-color: var(--green);
transform: translateX(3px);
}
/* CHECK BUTTON */
.check-btn {
width: 24px;
height: 24px;
border-radius: 6px;
border: 2px solid var(--text-3);
background: transparent;
cursor: pointer;
flex-shrink: 0;
transition: all 0.15s;
display: flex;
align-items: center;
justify-content: center;
color: transparent;
font-size: 0;
}
.check-btn:hover {
border-color: var(--green);
background: var(--green-dim);
}
.check-btn.checked {
background: var(--green);
border-color: var(--green);
color: white;
font-size: 0.75rem;
font-weight: 800;
}
/* TASK TITLE */
.task-title {
flex: 1;
font-size: 0.95rem;
color: var(--text);
font-weight: 500;
line-height: 1.4;
}
.completed .task-title {
text-decoration: line-through;
text-decoration-color: var(--green);
text-decoration-thickness: 2px;
color: var(--text-2);
}
/* DELETE BUTTON */
.delete-btn {
background: var(--red-dim);
border: 1px solid rgba(239,68,68,0.3);
color: var(--red);
font-size: 0.78rem;
font-weight: 600;
cursor: pointer;
padding: 6px 14px;
border-radius: 7px;
transition: all 0.15s;
font-family: 'DM Sans', sans-serif;
flex-shrink: 0;
letter-spacing: 0.2px;
}
.delete-btn:hover {
background: var(--red);
border-color: var(--red);
color: white;
transform: scale(1.05);
}
/* EMPTY STATE */
.empty-state {
display: flex;
flex-direction: column;
align-items: center;
justify-content: center;
padding: 80px 0;
gap: 12px;
}
.empty-icon { font-size: 3rem; }
.empty-title {
font-family: 'Syne', sans-serif;
font-size: 1.1rem;
font-weight: 700;
color: var(--text-2);
}
.empty-desc {
font-size: 0.85rem;
color: var(--text-3);
font-weight: 500;
}
.empty { color: var(--text-3); font-size: 0.9rem; padding: 40px 0; text-align: center; }
+10
View File
@@ -0,0 +1,10 @@
import { StrictMode } from 'react'
import { createRoot } from 'react-dom/client'
import './index.css'
import App from './App.jsx'
createRoot(document.getElementById('root')).render(
<StrictMode>
<App />
</StrictMode>,
)
+6
View File
@@ -0,0 +1,6 @@
import { defineConfig } from 'vite'
import react from '@vitejs/plugin-react'
export default defineConfig({
plugins: [react()],
})