Merge pull request #9 from elizabeth-ikechukwu/feat/week3-frontend-compose
feat: add React frontend. Nginix reverse proxy, Docker Compose
This commit is contained in:
@@ -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;"]
|
||||||
@@ -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>
|
||||||
@@ -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;
|
||||||
|
}
|
||||||
|
}
|
||||||
@@ -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"
|
||||||
|
}
|
||||||
|
}
|
||||||
@@ -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>
|
||||||
|
)
|
||||||
|
}
|
||||||
@@ -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; }
|
||||||
@@ -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>,
|
||||||
|
)
|
||||||
@@ -0,0 +1,6 @@
|
|||||||
|
import { defineConfig } from 'vite'
|
||||||
|
import react from '@vitejs/plugin-react'
|
||||||
|
|
||||||
|
export default defineConfig({
|
||||||
|
plugins: [react()],
|
||||||
|
})
|
||||||
@@ -0,0 +1,30 @@
|
|||||||
|
version: '3.8'
|
||||||
|
|
||||||
|
services:
|
||||||
|
backend:
|
||||||
|
build:
|
||||||
|
context: ./app/backend
|
||||||
|
dockerfile: Dockerfile
|
||||||
|
container_name: task-backend
|
||||||
|
restart: unless-stopped
|
||||||
|
environment:
|
||||||
|
- PORT=3000
|
||||||
|
networks:
|
||||||
|
- task-network
|
||||||
|
|
||||||
|
frontend:
|
||||||
|
build:
|
||||||
|
context: ./app/frontend
|
||||||
|
dockerfile: Dockerfile
|
||||||
|
container_name: task-frontend
|
||||||
|
restart: unless-stopped
|
||||||
|
ports:
|
||||||
|
- "80:80"
|
||||||
|
depends_on:
|
||||||
|
- backend
|
||||||
|
networks:
|
||||||
|
- task-network
|
||||||
|
|
||||||
|
networks:
|
||||||
|
task-network:
|
||||||
|
driver: bridge
|
||||||
Reference in New Issue
Block a user