Add Task Manager Docker web application (z1)

This commit is contained in:
Gopikanta Shill 2026-04-01 09:34:28 +02:00
commit 341aa36b05
16 changed files with 1815 additions and 0 deletions

351
z1/README.md Normal file
View File

@ -0,0 +1,351 @@
# Task Manager — Dockerized Web Application
A modern task management web application deployed using Docker containers. The application consists of **5 services** running in isolated containers, communicating over virtual networks, with persistent storage for data durability.
## Table of Contents
- [Description](#description)
- [Prerequisites](#prerequisites)
- [Architecture](#architecture)
- [Services](#services)
- [Networks](#networks)
- [Volumes](#volumes)
- [Container Configuration](#container-configuration)
- [Quick Start](#quick-start)
- [Usage Instructions](#usage-instructions)
- [Viewing the Application](#viewing-the-application)
- [Example Workflow](#example-workflow)
- [Sources](#sources)
- [AI Usage Declaration](#ai-usage-declaration)
---
## Description
**Task Manager** is a full-stack web application for creating, managing, and tracking tasks. Users can:
- **Create tasks** with a title and optional description
- **Mark tasks** as completed or reopen them
- **Delete tasks** they no longer need
- **Filter tasks** by status (All / Active / Completed)
- **View statistics** including total, active, and completed task counts
- **Manage the database** via Adminer web interface
The application uses a modern dark-themed UI served by Nginx, a Node.js/Express REST API backend, PostgreSQL for persistent data storage, and Redis for response caching.
---
## Prerequisites
To deploy and run this application, you need:
| Software | Minimum Version | Purpose |
|----------|----------------|---------|
| **Linux** | Any modern distribution | Host operating system |
| **Docker** | 20.10+ | Container runtime |
| **Docker Compose** | v2.0+ (plugin) | Multi-container orchestration |
| **bash** | 4.0+ | Running management scripts |
### Verify installation:
```bash
docker --version # Docker version 20.10+
docker compose version # Docker Compose version v2.0+
bash --version # GNU bash, version 4.0+
```
---
## Architecture
```
┌─────────────────────────────────────────────────────────┐
│ Docker Host │
│ │
│ ┌──────────────── frontend-net ──────────────────┐ │
│ │ │ │
│ │ ┌─────────────┐ ┌─────────────┐ │ │
│ │ │ Nginx │ │ Adminer │ │ │
│ │ │ (frontend) │ │ (DB UI) │ │ │
│ │ │ :5000→:80 │ │ :5001→:5000│ │ │
│ │ └──────┬──────┘ └──────┬──────┘ │ │
│ │ │ │ │ │
│ └─────────┼────────────────────────┼──────────────┘ │
│ │ │ │
│ ┌─────────┼────────────────────────┼──────────────┐ │
│ │ │ backend-net │ │ │
│ │ ┌──────▼──────┐ │ │ │
│ │ │ Node.js │ │ │ │
│ │ │ (api) │ │ │ │
│ │ │ :3000 │ │ │ │
│ │ └──┬──────┬───┘ │ │ │
│ │ │ │ │ │ │
│ │ ┌──▼────┐ ┌▼─────────┐ ┌──────▼──────┐ │ │
│ │ │ Redis │ │PostgreSQL │ │ (Adminer │ │ │
│ │ │ :6379 │ │ :5432 │ │ connects) │ │ │
│ │ └───────┘ └───────────┘ └─────────────┘ │ │
│ │ 📦 📦 │ │
│ │ redisdata pgdata │ │
│ └─────────────────────────────────────────────────┘ │
└──────────────────────────────────────────────────────────┘
```
---
## Services
### 1. Frontend (Nginx)
- **Container name:** `taskapp-frontend`
- **Image:** Custom (built from `frontend/Dockerfile` using `nginx:alpine`)
- **Port:** `5000` (public) → `80` (container)
- **Role:** Serves the static HTML/CSS/JS frontend and acts as a reverse proxy, forwarding `/api/` requests to the Node.js backend
- **Networks:** `frontend-net`, `backend-net`
### 2. API (Node.js / Express)
- **Container name:** `taskapp-api`
- **Image:** Custom (built from `api/Dockerfile` using `node:20-alpine`)
- **Port:** `3000` (virtual, not exposed to host)
- **Role:** REST API server providing CRUD operations for tasks. Connects to PostgreSQL for data persistence and Redis for response caching
- **Networks:** `backend-net`
### 3. PostgreSQL Database
- **Container name:** `taskapp-postgres`
- **Image:** `postgres:16-alpine`
- **Port:** `5432` (virtual, not exposed to host)
- **Role:** Primary relational database storing all task data
- **Volume:** `taskapp-pgdata` mounted at `/var/lib/postgresql/data`
- **Networks:** `backend-net`
- **Initialization:** `db/init.sql` is automatically executed on first run to create the `tasks` table
### 4. Redis Cache
- **Container name:** `taskapp-redis`
- **Image:** `redis:7-alpine`
- **Port:** `6379` (virtual, not exposed to host)
- **Role:** In-memory cache for API responses. Reduces database load by caching task list queries for 30 seconds. Configured with append-only persistence
- **Volume:** `taskapp-redisdata` mounted at `/data`
- **Networks:** `backend-net`
### 5. Adminer (Database Web Interface)
- **Container name:** `taskapp-adminer`
- **Image:** `adminer:latest`
- **Port:** `5001` (public) → `5000` (container)
- **Role:** Web-based database management tool. Allows direct SQL queries, table browsing, and data export
- **Networks:** `frontend-net`, `backend-net`
---
## Networks
| Network Name | Driver | Connected Services | Purpose |
|-------------|--------|-------------------|---------|
| `taskapp-frontend-net` | bridge | frontend, adminer | Isolates user-facing services |
| `taskapp-backend-net` | bridge | frontend, api, postgres, redis, adminer | Connects backend services for internal communication |
**Why two networks?**
- Services on `frontend-net` are publicly accessible
- Services on `backend-net` handle internal communication
- The `frontend` and `adminer` services bridge both networks since they need public access while communicating with backend services
---
## Volumes
| Volume Name | Mount Point | Service | Purpose |
|------------|-------------|---------|---------|
| `taskapp-pgdata` | `/var/lib/postgresql/data` | postgres | Persists database files across container restarts |
| `taskapp-redisdata` | `/data` | redis | Persists Redis append-only file for cache durability |
**Data persistence:** Stopping and restarting the application (`./stop-app.sh` then `./start-app.sh`) preserves all data. Only `./remove-app.sh` deletes the volumes.
---
## Container Configuration
All containers are configured with:
- **Restart policy:** `unless-stopped` — containers automatically restart on failure or system reboot
- **Health checks:** PostgreSQL and Redis have health checks; the API and frontend wait for healthy dependencies before starting
- **Dependencies:** `docker compose` ensures services start in the correct order:
1. PostgreSQL + Redis (database layer)
2. API (depends on healthy postgres and redis)
3. Frontend + Adminer (depends on api and postgres respectively)
- **Environment variables:** Database credentials and connection parameters are passed via environment variables in `docker-compose.yaml`
---
## Quick Start
```bash
# 1. Prepare the application (build images, create networks/volumes)
./prepare-app.sh
# 2. Start the application
./start-app.sh
# 3. Open in browser
# Task Manager: http://localhost:5000
# Adminer: http://localhost:5001
```
---
## Usage Instructions
### Preparing the application
```bash
./prepare-app.sh
```
This script builds the custom Docker images (frontend, api), and creates the required networks and volumes.
### Starting the application
```bash
./start-app.sh
```
Starts all 5 containers in detached mode. Prints the URLs for accessing the application.
### Stopping the application
```bash
./stop-app.sh
```
Stops all containers **without removing data**. Your tasks and database state are preserved in the persistent volumes.
### Restarting after stop
```bash
./start-app.sh
```
Simply run the start script again. All your data will be intact.
### Removing the application
```bash
./remove-app.sh
```
**⚠️ Warning:** This removes ALL traces of the application including:
- All containers
- All networks
- All persistent volumes (data is lost)
- All locally built images
---
## Viewing the Application
### Task Manager (Main Application)
- **URL:** [http://localhost:5000](http://localhost:5000)
- Features: Create, complete, delete, and filter tasks
### Adminer (Database Management)
- **URL:** [http://localhost:5001](http://localhost:5001)
- **Login credentials:**
- System: PostgreSQL
- Server: `postgres`
- Username: `taskuser`
- Password: `taskpass`
- Database: `taskmanager`
---
## Example Workflow
```bash
# Prepare the application
$ ./prepare-app.sh
=============================================
Preparing Task Manager Application...
=============================================
[1/3] Building Docker images...
✓ Images built successfully
[2/3] Creating networks...
✓ Networks created
[3/3] Creating volumes...
✓ Volumes created
=============================================
✓ Application prepared successfully!
Run ./start-app.sh to start the application
=============================================
# Start the application
$ ./start-app.sh
=============================================
Starting Task Manager Application...
=============================================
[1/2] Starting containers...
[2/2] Waiting for services to be ready...
=============================================
✓ Application is running!
🌐 Task Manager: http://localhost:5000
🗄️ Adminer (DB): http://localhost:5001
=============================================
# Open http://localhost:5000 in a web browser and work with the application
# Create tasks, mark them complete, delete them, etc.
# Stop the application (data is preserved)
$ ./stop-app.sh
=============================================
Stopping Task Manager Application...
=============================================
✓ Application stopped.
Data is preserved in persistent volumes.
Run ./start-app.sh to restart.
# Start again - all tasks are still there!
$ ./start-app.sh
# When done, remove everything
$ ./remove-app.sh
=============================================
Removing Task Manager Application...
=============================================
✓ Application completely removed.
```
---
## Sources
1. **Docker Documentation** — [https://docs.docker.com/](https://docs.docker.com/)
2. **Docker Compose Documentation** — [https://docs.docker.com/compose/](https://docs.docker.com/compose/)
3. **Nginx Docker Image** — [https://hub.docker.com/_/nginx](https://hub.docker.com/_/nginx)
4. **Node.js Docker Image** — [https://hub.docker.com/_/node](https://hub.docker.com/_/node)
5. **PostgreSQL Docker Image** — [https://hub.docker.com/_/postgres](https://hub.docker.com/_/postgres)
6. **Redis Docker Image** — [https://hub.docker.com/_/redis](https://hub.docker.com/_/redis)
7. **Adminer Docker Image** — [https://hub.docker.com/_/adminer](https://hub.docker.com/_/adminer)
8. **Express.js Documentation** — [https://expressjs.com/](https://expressjs.com/)
9. **node-postgres (pg) Documentation** — [https://node-postgres.com/](https://node-postgres.com/)
10. **Node Redis Client** — [https://github.com/redis/node-redis](https://github.com/redis/node-redis)
---
## Project Structure
```
z1/
├── docker-compose.yaml # Main orchestration configuration
├── prepare-app.sh # Build images, create networks/volumes
├── start-app.sh # Start all services
├── stop-app.sh # Stop without data loss
├── remove-app.sh # Remove all traces
├── README.md # This documentation
├── frontend/ # Nginx + Static frontend
│ ├── Dockerfile # Nginx container build
│ ├── nginx.conf # Nginx configuration
│ └── public/
│ ├── index.html # Task Manager HTML
│ ├── style.css # Styles (dark theme)
│ └── app.js # Frontend JavaScript
├── api/ # Node.js REST API
│ ├── Dockerfile # API container build
│ ├── package.json # Node.js dependencies
│ ├── server.js # Express API server
│ └── db.js # PostgreSQL connection
└── db/
└── init.sql # Database initialization
```

20
z1/api/Dockerfile Normal file
View File

@ -0,0 +1,20 @@
FROM node:20-alpine
WORKDIR /app
# Copy package files and install dependencies
COPY package.json ./
RUN npm install --production
# Copy application source
COPY . .
# Expose API port
EXPOSE 3000
# Health check
HEALTHCHECK --interval=30s --timeout=5s --start-period=10s --retries=3 \
CMD wget --no-verbose --tries=1 --spider http://localhost:3000/api/health || exit 1
# Start the application
CMD ["node", "server.js"]

18
z1/api/db.js Normal file
View File

@ -0,0 +1,18 @@
const { Pool } = require('pg');
const pool = new Pool({
host: process.env.POSTGRES_HOST || 'postgres',
port: parseInt(process.env.POSTGRES_PORT || '5432'),
database: process.env.POSTGRES_DB || 'taskmanager',
user: process.env.POSTGRES_USER || 'taskuser',
password: process.env.POSTGRES_PASSWORD || 'taskpass',
max: 10,
idleTimeoutMillis: 30000,
connectionTimeoutMillis: 5000,
});
pool.on('error', (err) => {
console.error('Unexpected PostgreSQL pool error:', err);
});
module.exports = pool;

15
z1/api/package.json Normal file
View File

@ -0,0 +1,15 @@
{
"name": "taskmanager-api",
"version": "1.0.0",
"description": "Task Manager REST API",
"main": "server.js",
"scripts": {
"start": "node server.js"
},
"dependencies": {
"express": "^4.18.2",
"pg": "^8.12.0",
"redis": "^4.6.12",
"cors": "^2.8.5"
}
}

163
z1/api/server.js Normal file
View File

@ -0,0 +1,163 @@
const express = require('express');
const cors = require('cors');
const pool = require('./db');
const { createClient } = require('redis');
const app = express();
const PORT = process.env.PORT || 3000;
// Middleware
app.use(cors());
app.use(express.json());
// Redis client
let redisClient;
const CACHE_TTL = 30; // seconds
async function initRedis() {
try {
redisClient = createClient({
url: `redis://${process.env.REDIS_HOST || 'redis'}:${process.env.REDIS_PORT || 6379}`
});
redisClient.on('error', (err) => console.error('Redis error:', err));
await redisClient.connect();
console.log('Connected to Redis');
} catch (err) {
console.error('Failed to connect to Redis:', err.message);
redisClient = null;
}
}
// Helper: invalidate cache
async function invalidateCache() {
if (redisClient && redisClient.isOpen) {
try {
await redisClient.del('tasks:all');
} catch (err) {
console.error('Cache invalidation error:', err.message);
}
}
}
// Health check
app.get('/api/health', async (req, res) => {
try {
await pool.query('SELECT 1');
const redisOk = redisClient && redisClient.isOpen;
res.json({
status: 'healthy',
database: 'connected',
cache: redisOk ? 'connected' : 'disconnected',
timestamp: new Date().toISOString()
});
} catch (err) {
res.status(503).json({ status: 'unhealthy', error: err.message });
}
});
// GET /api/tasks — List all tasks (with Redis caching)
app.get('/api/tasks', async (req, res) => {
try {
// Try cache first
if (redisClient && redisClient.isOpen) {
const cached = await redisClient.get('tasks:all');
if (cached) {
return res.json({ tasks: JSON.parse(cached), source: 'cache' });
}
}
const result = await pool.query(
'SELECT * FROM tasks ORDER BY created_at DESC'
);
// Store in cache
if (redisClient && redisClient.isOpen) {
await redisClient.setEx('tasks:all', CACHE_TTL, JSON.stringify(result.rows));
}
res.json({ tasks: result.rows, source: 'database' });
} catch (err) {
console.error('Error fetching tasks:', err);
res.status(500).json({ error: 'Failed to fetch tasks' });
}
});
// POST /api/tasks — Create a new task
app.post('/api/tasks', async (req, res) => {
try {
const { title, description } = req.body;
if (!title || title.trim() === '') {
return res.status(400).json({ error: 'Title is required' });
}
const result = await pool.query(
'INSERT INTO tasks (title, description) VALUES ($1, $2) RETURNING *',
[title.trim(), (description || '').trim()]
);
await invalidateCache();
res.status(201).json({ task: result.rows[0] });
} catch (err) {
console.error('Error creating task:', err);
res.status(500).json({ error: 'Failed to create task' });
}
});
// PUT /api/tasks/:id — Update a task
app.put('/api/tasks/:id', async (req, res) => {
try {
const { id } = req.params;
const { title, description, completed } = req.body;
const result = await pool.query(
`UPDATE tasks
SET title = COALESCE($1, title),
description = COALESCE($2, description),
completed = COALESCE($3, completed),
updated_at = CURRENT_TIMESTAMP
WHERE id = $4 RETURNING *`,
[title, description, completed, id]
);
if (result.rows.length === 0) {
return res.status(404).json({ error: 'Task not found' });
}
await invalidateCache();
res.json({ task: result.rows[0] });
} catch (err) {
console.error('Error updating task:', err);
res.status(500).json({ error: 'Failed to update task' });
}
});
// DELETE /api/tasks/:id — Delete a task
app.delete('/api/tasks/:id', async (req, res) => {
try {
const { id } = req.params;
const result = await pool.query(
'DELETE FROM tasks WHERE id = $1 RETURNING *',
[id]
);
if (result.rows.length === 0) {
return res.status(404).json({ error: 'Task not found' });
}
await invalidateCache();
res.json({ message: 'Task deleted', task: result.rows[0] });
} catch (err) {
console.error('Error deleting task:', err);
res.status(500).json({ error: 'Failed to delete task' });
}
});
// Start server
async function start() {
await initRedis();
app.listen(PORT, '0.0.0.0', () => {
console.log(`Task Manager API running on port ${PORT}`);
});
}
start();

17
z1/db/init.sql Normal file
View File

@ -0,0 +1,17 @@
-- Task Manager Database Initialization
-- Creates the tasks table for the application
CREATE TABLE IF NOT EXISTS tasks (
id SERIAL PRIMARY KEY,
title VARCHAR(255) NOT NULL,
description TEXT DEFAULT '',
completed BOOLEAN DEFAULT FALSE,
created_at TIMESTAMP DEFAULT CURRENT_TIMESTAMP,
updated_at TIMESTAMP DEFAULT CURRENT_TIMESTAMP
);
-- Insert sample tasks so the app isn't empty on first load
INSERT INTO tasks (title, description, completed) VALUES
('Welcome to Task Manager', 'This is a sample task. You can edit or delete it.', FALSE),
('Try creating a new task', 'Click the "Add Task" button to create your own tasks.', FALSE),
('Mark tasks as complete', 'Click the checkbox to mark a task as done.', TRUE);

129
z1/docker-compose.yaml Normal file
View File

@ -0,0 +1,129 @@
services:
# =============================================
# PostgreSQL Database
# Primary persistent data store for tasks
# =============================================
postgres:
image: postgres:16-alpine
container_name: taskapp-postgres
restart: unless-stopped
environment:
POSTGRES_DB: taskmanager
POSTGRES_USER: taskuser
POSTGRES_PASSWORD: taskpass
volumes:
- taskapp-pgdata:/var/lib/postgresql/data
- ./db/init.sql:/docker-entrypoint-initdb.d/init.sql:ro
networks:
- backend-net
healthcheck:
test: ["CMD-SHELL", "pg_isready -U taskuser -d taskmanager"]
interval: 10s
timeout: 5s
retries: 5
start_period: 10s
# =============================================
# Redis Cache
# Caching layer for API responses
# =============================================
redis:
image: redis:7-alpine
container_name: taskapp-redis
restart: unless-stopped
command: redis-server --appendonly yes
volumes:
- taskapp-redisdata:/data
networks:
- backend-net
healthcheck:
test: ["CMD", "redis-cli", "ping"]
interval: 10s
timeout: 5s
retries: 5
start_period: 5s
# =============================================
# Node.js API Server
# REST API for task CRUD operations
# =============================================
api:
build:
context: ./api
dockerfile: Dockerfile
container_name: taskapp-api
restart: unless-stopped
environment:
PORT: 3000
POSTGRES_HOST: postgres
POSTGRES_PORT: 5432
POSTGRES_DB: taskmanager
POSTGRES_USER: taskuser
POSTGRES_PASSWORD: taskpass
REDIS_HOST: redis
REDIS_PORT: 6379
depends_on:
postgres:
condition: service_healthy
redis:
condition: service_healthy
networks:
- backend-net
# =============================================
# Nginx Frontend
# Serves static files + reverse proxies to API
# =============================================
frontend:
build:
context: ./frontend
dockerfile: Dockerfile
container_name: taskapp-frontend
restart: unless-stopped
ports:
- "5000:80"
depends_on:
- api
networks:
- frontend-net
- backend-net
# =============================================
# Adminer
# Web-based database management interface
# =============================================
adminer:
image: adminer:latest
container_name: taskapp-adminer
restart: unless-stopped
ports:
- "5001:8080"
environment:
ADMINER_DEFAULT_SERVER: postgres
ADMINER_DESIGN: dracula
depends_on:
postgres:
condition: service_healthy
networks:
- frontend-net
- backend-net
# =============================================
# Named Volumes (persistent storage)
# =============================================
volumes:
taskapp-pgdata:
name: taskapp-pgdata
taskapp-redisdata:
name: taskapp-redisdata
# =============================================
# Virtual Networks
# =============================================
networks:
frontend-net:
name: taskapp-frontend-net
driver: bridge
backend-net:
name: taskapp-backend-net
driver: bridge

19
z1/frontend/Dockerfile Normal file
View File

@ -0,0 +1,19 @@
FROM nginx:alpine
# Remove default nginx config
RUN rm /etc/nginx/conf.d/default.conf
# Copy custom nginx configuration
COPY nginx.conf /etc/nginx/conf.d/default.conf
# Copy static frontend files
COPY public/ /usr/share/nginx/html/
# Expose HTTP port
EXPOSE 80
# Health check
HEALTHCHECK --interval=30s --timeout=5s --start-period=5s --retries=3 \
CMD wget --no-verbose --tries=1 --spider http://localhost/ || exit 1
CMD ["nginx", "-g", "daemon off;"]

33
z1/frontend/nginx.conf Normal file
View File

@ -0,0 +1,33 @@
server {
listen 80;
server_name localhost;
# Serve static frontend files
location / {
root /usr/share/nginx/html;
index index.html;
try_files $uri $uri/ /index.html;
}
# Reverse proxy API requests to the Node.js backend
location /api/ {
proxy_pass http://api:3000/api/;
proxy_http_version 1.1;
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_connect_timeout 10s;
proxy_read_timeout 30s;
}
# 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;
# Gzip compression
gzip on;
gzip_types text/plain text/css application/json application/javascript text/xml;
gzip_min_length 256;
}

252
z1/frontend/public/app.js Normal file
View File

@ -0,0 +1,252 @@
// ===== Task Manager Frontend Application =====
const API_BASE = '/api';
let tasks = [];
let currentFilter = 'all';
// ===== DOM Elements =====
const taskList = document.getElementById('task-list');
const emptyState = document.getElementById('empty-state');
const loadingState = document.getElementById('loading-state');
const addTaskForm = document.getElementById('add-task-form');
const taskTitleInput = document.getElementById('task-title');
const taskDescInput = document.getElementById('task-description');
const filterTabs = document.querySelectorAll('.filter-tab');
const toast = document.getElementById('toast');
// ===== API Functions =====
async function apiRequest(url, options = {}) {
try {
const response = await fetch(`${API_BASE}${url}`, {
headers: { 'Content-Type': 'application/json' },
...options,
});
if (!response.ok) {
const err = await response.json().catch(() => ({}));
throw new Error(err.error || `HTTP ${response.status}`);
}
return await response.json();
} catch (err) {
if (err.name === 'TypeError') {
throw new Error('Cannot connect to server. Is the API running?');
}
throw err;
}
}
async function fetchTasks() {
const data = await apiRequest('/tasks');
tasks = data.tasks || [];
// Update source indicator
const sourceEl = document.querySelector('#stat-source .stat-value');
if (sourceEl) {
sourceEl.textContent = data.source === 'cache' ? '⚡ Cache' : '🗄️ DB';
}
return tasks;
}
async function createTask(title, description) {
const data = await apiRequest('/tasks', {
method: 'POST',
body: JSON.stringify({ title, description }),
});
return data.task;
}
async function updateTask(id, updates) {
const data = await apiRequest(`/tasks/${id}`, {
method: 'PUT',
body: JSON.stringify(updates),
});
return data.task;
}
async function deleteTask(id) {
await apiRequest(`/tasks/${id}`, { method: 'DELETE' });
}
// ===== UI Rendering =====
function getFilteredTasks() {
switch (currentFilter) {
case 'active':
return tasks.filter(t => !t.completed);
case 'completed':
return tasks.filter(t => t.completed);
default:
return tasks;
}
}
function formatDate(dateStr) {
const date = new Date(dateStr);
return date.toLocaleDateString('en-US', {
month: 'short', day: 'numeric', hour: '2-digit', minute: '2-digit'
});
}
function renderTasks() {
const filtered = getFilteredTasks();
// Update stats
const total = tasks.length;
const completed = tasks.filter(t => t.completed).length;
const active = total - completed;
document.querySelector('#stat-total .stat-value').textContent = total;
document.querySelector('#stat-active .stat-value').textContent = active;
document.querySelector('#stat-completed .stat-value').textContent = completed;
// Show/hide states
loadingState.style.display = 'none';
if (filtered.length === 0) {
taskList.innerHTML = '';
emptyState.style.display = 'block';
if (tasks.length > 0) {
emptyState.querySelector('p').textContent = `No ${currentFilter} tasks.`;
} else {
emptyState.querySelector('p').textContent = 'No tasks yet. Add your first task above!';
}
return;
}
emptyState.style.display = 'none';
taskList.innerHTML = filtered.map(task => `
<div class="task-item ${task.completed ? 'completed' : ''}" data-id="${task.id}">
<label class="task-checkbox">
<input type="checkbox"
${task.completed ? 'checked' : ''}
onchange="handleToggle(${task.id}, this.checked)"
aria-label="Mark task as ${task.completed ? 'incomplete' : 'complete'}">
<span class="checkmark">
<svg width="14" height="14" viewBox="0 0 24 24" fill="none" stroke="currentColor" stroke-width="3" stroke-linecap="round" stroke-linejoin="round">
<polyline points="20 6 9 17 4 12"/>
</svg>
</span>
</label>
<div class="task-content">
<div class="task-title">${escapeHtml(task.title)}</div>
${task.description ? `<div class="task-description">${escapeHtml(task.description)}</div>` : ''}
<div class="task-meta">${formatDate(task.created_at)}</div>
</div>
<div class="task-actions">
<button class="btn-icon" onclick="handleDelete(${task.id})" title="Delete task" aria-label="Delete task">
<svg width="18" height="18" viewBox="0 0 24 24" fill="none" stroke="currentColor" stroke-width="2" stroke-linecap="round" stroke-linejoin="round">
<polyline points="3 6 5 6 21 6"/>
<path d="M19 6v14a2 2 0 0 1-2 2H7a2 2 0 0 1-2-2V6m3 0V4a2 2 0 0 1 2-2h4a2 2 0 0 1 2 2v2"/>
<line x1="10" y1="11" x2="10" y2="17"/>
<line x1="14" y1="11" x2="14" y2="17"/>
</svg>
</button>
</div>
</div>
`).join('');
}
function escapeHtml(text) {
const div = document.createElement('div');
div.textContent = text;
return div.innerHTML;
}
// ===== Toast Notifications =====
let toastTimeout;
function showToast(message, type = 'info') {
clearTimeout(toastTimeout);
toast.textContent = message;
toast.className = `toast ${type} show`;
toastTimeout = setTimeout(() => {
toast.classList.remove('show');
}, 3000);
}
// ===== Event Handlers =====
async function handleToggle(id, completed) {
try {
await updateTask(id, { completed });
await fetchTasks();
renderTasks();
showToast(completed ? '✅ Task completed!' : '🔄 Task reopened', 'success');
} catch (err) {
showToast(err.message, 'error');
await fetchTasks();
renderTasks();
}
}
async function handleDelete(id) {
try {
await deleteTask(id);
const el = document.querySelector(`.task-item[data-id="${id}"]`);
if (el) {
el.style.transform = 'translateX(100px)';
el.style.opacity = '0';
await new Promise(r => setTimeout(r, 250));
}
await fetchTasks();
renderTasks();
showToast('🗑️ Task deleted', 'success');
} catch (err) {
showToast(err.message, 'error');
}
}
// ===== Form Submit =====
addTaskForm.addEventListener('submit', async (e) => {
e.preventDefault();
const title = taskTitleInput.value.trim();
const description = taskDescInput.value.trim();
if (!title) return;
try {
await createTask(title, description);
taskTitleInput.value = '';
taskDescInput.value = '';
taskTitleInput.focus();
await fetchTasks();
renderTasks();
showToast('✨ Task created!', 'success');
} catch (err) {
showToast(err.message, 'error');
}
});
// ===== Filter Tabs =====
filterTabs.forEach(tab => {
tab.addEventListener('click', () => {
filterTabs.forEach(t => t.classList.remove('active'));
tab.classList.add('active');
currentFilter = tab.dataset.filter;
renderTasks();
});
});
// ===== Initialize =====
async function init() {
loadingState.style.display = 'block';
emptyState.style.display = 'none';
try {
await fetchTasks();
renderTasks();
} catch (err) {
loadingState.style.display = 'none';
showToast('Failed to load tasks: ' + err.message, 'error');
emptyState.style.display = 'block';
emptyState.querySelector('p').textContent = 'Unable to connect to the server.';
}
}
// Start the application
init();
// Auto-refresh every 30 seconds
setInterval(async () => {
try {
await fetchTasks();
renderTasks();
} catch (err) {
// Silent fail on auto-refresh
}
}, 30000);

View File

@ -0,0 +1,113 @@
<!DOCTYPE html>
<html lang="en">
<head>
<meta charset="UTF-8">
<meta name="viewport" content="width=device-width, initial-scale=1.0">
<meta name="description" content="Task Manager - A modern task management web application built with Docker">
<title>Task Manager</title>
<link rel="preconnect" href="https://fonts.googleapis.com">
<link rel="preconnect" href="https://fonts.gstatic.com" crossorigin>
<link href="https://fonts.googleapis.com/css2?family=Inter:wght@300;400;500;600;700&display=swap" rel="stylesheet">
<link rel="stylesheet" href="style.css">
</head>
<body>
<div class="app-container">
<!-- Header -->
<header class="app-header">
<div class="header-content">
<div class="logo">
<div class="logo-icon">
<svg width="28" height="28" viewBox="0 0 24 24" fill="none" stroke="currentColor" stroke-width="2" stroke-linecap="round" stroke-linejoin="round">
<path d="M9 11l3 3L22 4"/>
<path d="M21 12v7a2 2 0 0 1-2 2H5a2 2 0 0 1-2-2V5a2 2 0 0 1 2-2h11"/>
</svg>
</div>
<h1>Task Manager</h1>
</div>
<div class="header-stats">
<div class="stat" id="stat-total">
<span class="stat-value">0</span>
<span class="stat-label">Total</span>
</div>
<div class="stat" id="stat-active">
<span class="stat-value">0</span>
<span class="stat-label">Active</span>
</div>
<div class="stat" id="stat-completed">
<span class="stat-value">0</span>
<span class="stat-label">Done</span>
</div>
<div class="stat" id="stat-source">
<span class="stat-value"></span>
<span class="stat-label">Source</span>
</div>
</div>
</div>
</header>
<!-- Main Content -->
<main class="main-content">
<!-- Add Task Form -->
<section class="add-task-section">
<form id="add-task-form" class="add-task-form">
<div class="form-row">
<div class="input-group input-group-title">
<input type="text" id="task-title" placeholder="What needs to be done?" required autocomplete="off">
</div>
<div class="input-group input-group-desc">
<input type="text" id="task-description" placeholder="Description (optional)" autocomplete="off">
</div>
<button type="submit" class="btn btn-add" id="btn-add-task">
<svg width="20" height="20" viewBox="0 0 24 24" fill="none" stroke="currentColor" stroke-width="2.5" stroke-linecap="round" stroke-linejoin="round">
<line x1="12" y1="5" x2="12" y2="19"/>
<line x1="5" y1="12" x2="19" y2="12"/>
</svg>
<span>Add Task</span>
</button>
</div>
</form>
</section>
<!-- Filter Tabs -->
<section class="filter-section">
<div class="filter-tabs">
<button class="filter-tab active" data-filter="all">All</button>
<button class="filter-tab" data-filter="active">Active</button>
<button class="filter-tab" data-filter="completed">Completed</button>
</div>
</section>
<!-- Task List -->
<section class="task-list-section">
<div id="task-list" class="task-list">
<!-- Tasks will be rendered here -->
</div>
<div id="empty-state" class="empty-state" style="display: none;">
<svg width="64" height="64" viewBox="0 0 24 24" fill="none" stroke="currentColor" stroke-width="1" stroke-linecap="round" stroke-linejoin="round">
<path d="M14 2H6a2 2 0 0 0-2 2v16a2 2 0 0 0 2 2h12a2 2 0 0 0 2-2V8z"/>
<polyline points="14 2 14 8 20 8"/>
<line x1="16" y1="13" x2="8" y2="13"/>
<line x1="16" y1="17" x2="8" y2="17"/>
<polyline points="10 9 9 9 8 9"/>
</svg>
<p>No tasks yet. Add your first task above!</p>
</div>
<div id="loading-state" class="loading-state">
<div class="spinner"></div>
<p>Loading tasks...</p>
</div>
</section>
</main>
<!-- Footer -->
<footer class="app-footer">
<p>Task Manager &mdash; Dockerized Web Application &bull; PostgreSQL &bull; Redis &bull; Node.js &bull; Nginx</p>
</footer>
</div>
<!-- Toast Notification -->
<div id="toast" class="toast"></div>
<script src="app.js"></script>
</body>
</html>

View File

@ -0,0 +1,535 @@
/* ===== CSS Variables & Reset ===== */
:root {
--bg-primary: #0f0f1a;
--bg-secondary: #1a1a2e;
--bg-card: #16213e;
--bg-card-hover: #1a2744;
--bg-input: #0d1b36;
--border-color: #2a2a4a;
--border-focus: #6c63ff;
--text-primary: #e8e8f0;
--text-secondary: #9898b0;
--text-muted: #6868880;
--accent: #6c63ff;
--accent-hover: #5a52e0;
--accent-glow: rgba(108, 99, 255, 0.3);
--success: #2ecc71;
--success-bg: rgba(46, 204, 113, 0.12);
--danger: #e74c3c;
--danger-hover: #c0392b;
--warning: #f39c12;
--gradient-1: linear-gradient(135deg, #6c63ff 0%, #3f8efc 100%);
--gradient-2: linear-gradient(135deg, #0f0f1a 0%, #1a1a2e 100%);
--shadow-sm: 0 2px 8px rgba(0, 0, 0, 0.2);
--shadow-md: 0 4px 16px rgba(0, 0, 0, 0.3);
--shadow-lg: 0 8px 32px rgba(0, 0, 0, 0.4);
--shadow-glow: 0 0 20px rgba(108, 99, 255, 0.15);
--radius-sm: 8px;
--radius-md: 12px;
--radius-lg: 16px;
--transition: 0.25s cubic-bezier(0.4, 0, 0.2, 1);
}
*, *::before, *::after {
box-sizing: border-box;
margin: 0;
padding: 0;
}
body {
font-family: 'Inter', -apple-system, BlinkMacSystemFont, sans-serif;
background: var(--bg-primary);
color: var(--text-primary);
min-height: 100vh;
line-height: 1.6;
-webkit-font-smoothing: antialiased;
}
/* ===== App Container ===== */
.app-container {
max-width: 800px;
margin: 0 auto;
padding: 0 20px;
min-height: 100vh;
display: flex;
flex-direction: column;
}
/* ===== Header ===== */
.app-header {
padding: 32px 0 24px;
border-bottom: 1px solid var(--border-color);
margin-bottom: 32px;
}
.header-content {
display: flex;
justify-content: space-between;
align-items: center;
flex-wrap: wrap;
gap: 16px;
}
.logo {
display: flex;
align-items: center;
gap: 12px;
}
.logo-icon {
width: 48px;
height: 48px;
background: var(--gradient-1);
border-radius: var(--radius-md);
display: flex;
align-items: center;
justify-content: center;
color: white;
box-shadow: var(--shadow-glow);
}
.logo h1 {
font-size: 1.75rem;
font-weight: 700;
background: var(--gradient-1);
-webkit-background-clip: text;
-webkit-text-fill-color: transparent;
background-clip: text;
}
.header-stats {
display: flex;
gap: 20px;
}
.stat {
text-align: center;
padding: 8px 16px;
background: var(--bg-secondary);
border-radius: var(--radius-sm);
border: 1px solid var(--border-color);
min-width: 70px;
transition: var(--transition);
}
.stat:hover {
border-color: var(--accent);
box-shadow: var(--shadow-glow);
}
.stat-value {
display: block;
font-size: 1.25rem;
font-weight: 700;
color: var(--accent);
}
.stat-label {
display: block;
font-size: 0.7rem;
font-weight: 500;
color: var(--text-secondary);
text-transform: uppercase;
letter-spacing: 0.5px;
}
/* ===== Add Task Form ===== */
.add-task-section {
margin-bottom: 24px;
}
.add-task-form {
background: var(--bg-secondary);
border: 1px solid var(--border-color);
border-radius: var(--radius-lg);
padding: 20px;
box-shadow: var(--shadow-md);
transition: var(--transition);
}
.add-task-form:focus-within {
border-color: var(--accent);
box-shadow: var(--shadow-glow), var(--shadow-md);
}
.form-row {
display: flex;
gap: 12px;
align-items: stretch;
}
.input-group {
flex: 1;
}
.input-group-title {
flex: 2;
}
.input-group input {
width: 100%;
padding: 12px 16px;
background: var(--bg-input);
border: 1px solid var(--border-color);
border-radius: var(--radius-sm);
color: var(--text-primary);
font-family: inherit;
font-size: 0.95rem;
transition: var(--transition);
outline: none;
}
.input-group input::placeholder {
color: var(--text-secondary);
}
.input-group input:focus {
border-color: var(--accent);
box-shadow: 0 0 0 3px var(--accent-glow);
}
.btn {
display: inline-flex;
align-items: center;
justify-content: center;
gap: 8px;
padding: 12px 24px;
border: none;
border-radius: var(--radius-sm);
font-family: inherit;
font-size: 0.9rem;
font-weight: 600;
cursor: pointer;
transition: var(--transition);
white-space: nowrap;
}
.btn-add {
background: var(--gradient-1);
color: white;
box-shadow: var(--shadow-sm);
}
.btn-add:hover {
transform: translateY(-1px);
box-shadow: var(--shadow-glow), var(--shadow-md);
}
.btn-add:active {
transform: translateY(0);
}
/* ===== Filter Tabs ===== */
.filter-section {
margin-bottom: 20px;
}
.filter-tabs {
display: flex;
background: var(--bg-secondary);
border-radius: var(--radius-sm);
padding: 4px;
border: 1px solid var(--border-color);
gap: 4px;
}
.filter-tab {
flex: 1;
padding: 10px 16px;
background: transparent;
border: none;
border-radius: 6px;
color: var(--text-secondary);
font-family: inherit;
font-size: 0.85rem;
font-weight: 500;
cursor: pointer;
transition: var(--transition);
}
.filter-tab:hover {
color: var(--text-primary);
background: rgba(108, 99, 255, 0.08);
}
.filter-tab.active {
background: var(--accent);
color: white;
box-shadow: var(--shadow-sm);
}
/* ===== Task List ===== */
.task-list {
display: flex;
flex-direction: column;
gap: 12px;
}
.task-item {
display: flex;
align-items: center;
gap: 16px;
padding: 16px 20px;
background: var(--bg-card);
border: 1px solid var(--border-color);
border-radius: var(--radius-md);
transition: var(--transition);
animation: slideIn 0.3s ease-out;
}
@keyframes slideIn {
from {
opacity: 0;
transform: translateY(-10px);
}
to {
opacity: 1;
transform: translateY(0);
}
}
.task-item:hover {
background: var(--bg-card-hover);
border-color: rgba(108, 99, 255, 0.3);
box-shadow: var(--shadow-sm);
transform: translateX(4px);
}
.task-item.completed {
opacity: 0.6;
}
.task-item.completed .task-title {
text-decoration: line-through;
color: var(--text-secondary);
}
/* Checkbox */
.task-checkbox {
position: relative;
width: 24px;
height: 24px;
flex-shrink: 0;
}
.task-checkbox input {
position: absolute;
opacity: 0;
width: 100%;
height: 100%;
cursor: pointer;
z-index: 1;
}
.task-checkbox .checkmark {
width: 24px;
height: 24px;
border: 2px solid var(--border-color);
border-radius: 6px;
display: flex;
align-items: center;
justify-content: center;
transition: var(--transition);
}
.task-checkbox input:hover + .checkmark {
border-color: var(--accent);
}
.task-checkbox input:checked + .checkmark {
background: var(--success);
border-color: var(--success);
}
.task-checkbox .checkmark svg {
opacity: 0;
transform: scale(0.5);
transition: var(--transition);
color: white;
}
.task-checkbox input:checked + .checkmark svg {
opacity: 1;
transform: scale(1);
}
/* Task Content */
.task-content {
flex: 1;
min-width: 0;
}
.task-title {
font-size: 1rem;
font-weight: 500;
color: var(--text-primary);
margin-bottom: 2px;
word-break: break-word;
}
.task-description {
font-size: 0.85rem;
color: var(--text-secondary);
word-break: break-word;
}
.task-meta {
font-size: 0.7rem;
color: var(--text-muted);
margin-top: 4px;
}
/* Task Actions */
.task-actions {
display: flex;
gap: 8px;
flex-shrink: 0;
opacity: 0;
transition: var(--transition);
}
.task-item:hover .task-actions {
opacity: 1;
}
.btn-icon {
width: 36px;
height: 36px;
border: none;
border-radius: var(--radius-sm);
background: transparent;
color: var(--text-secondary);
cursor: pointer;
display: flex;
align-items: center;
justify-content: center;
transition: var(--transition);
}
.btn-icon:hover {
background: rgba(231, 76, 60, 0.15);
color: var(--danger);
}
/* ===== Empty State ===== */
.empty-state {
text-align: center;
padding: 60px 20px;
color: var(--text-secondary);
}
.empty-state svg {
margin-bottom: 16px;
opacity: 0.3;
}
.empty-state p {
font-size: 1rem;
}
/* ===== Loading State ===== */
.loading-state {
text-align: center;
padding: 60px 20px;
color: var(--text-secondary);
}
.spinner {
width: 40px;
height: 40px;
border: 3px solid var(--border-color);
border-top-color: var(--accent);
border-radius: 50%;
margin: 0 auto 16px;
animation: spin 0.8s linear infinite;
}
@keyframes spin {
to { transform: rotate(360deg); }
}
/* ===== Toast Notification ===== */
.toast {
position: fixed;
bottom: 24px;
right: 24px;
padding: 14px 24px;
background: var(--bg-card);
border: 1px solid var(--border-color);
border-radius: var(--radius-sm);
color: var(--text-primary);
font-size: 0.9rem;
font-weight: 500;
box-shadow: var(--shadow-lg);
transform: translateY(100px);
opacity: 0;
transition: all 0.35s cubic-bezier(0.4, 0, 0.2, 1);
z-index: 1000;
max-width: 360px;
}
.toast.show {
transform: translateY(0);
opacity: 1;
}
.toast.success {
border-left: 4px solid var(--success);
}
.toast.error {
border-left: 4px solid var(--danger);
}
.toast.info {
border-left: 4px solid var(--accent);
}
/* ===== Footer ===== */
.app-footer {
margin-top: auto;
padding: 24px 0;
border-top: 1px solid var(--border-color);
text-align: center;
}
.app-footer p {
font-size: 0.8rem;
color: var(--text-secondary);
}
/* ===== Responsive ===== */
@media (max-width: 640px) {
.header-content {
flex-direction: column;
align-items: flex-start;
}
.header-stats {
width: 100%;
justify-content: space-between;
}
.stat {
min-width: 0;
flex: 1;
padding: 6px 8px;
}
.form-row {
flex-direction: column;
}
.btn-add {
width: 100%;
justify-content: center;
}
.task-actions {
opacity: 1;
}
.logo h1 {
font-size: 1.4rem;
}
}

34
z1/prepare-app.sh Executable file
View File

@ -0,0 +1,34 @@
#!/bin/bash
# =============================================
# prepare-app.sh
# Prepares the Task Manager application:
# - Builds Docker images
# - Creates networks and volumes via docker compose
# =============================================
set -e
echo "============================================="
echo " Preparing Task Manager Application..."
echo "============================================="
echo ""
# Navigate to the script's directory
cd "$(dirname "$0")"
# Pull base images
echo "[1/2] Pulling base images..."
docker compose pull postgres redis adminer
echo " ✓ Base images pulled"
echo ""
# Build all custom Docker images and create networks/volumes
echo "[2/2] Building custom Docker images..."
docker compose build --no-cache
echo " ✓ Images built successfully"
echo ""
echo "============================================="
echo " ✓ Application prepared successfully!"
echo " Run ./start-app.sh to start the application"
echo "============================================="

45
z1/remove-app.sh Executable file
View File

@ -0,0 +1,45 @@
#!/bin/bash
# =============================================
# remove-app.sh
# Removes ALL traces of the Task Manager app:
# - Stops and removes containers
# - Removes networks
# - Removes persistent volumes
# - Removes locally built images
# =============================================
set -e
echo "============================================="
echo " Removing Task Manager Application..."
echo "============================================="
echo ""
# Navigate to the script's directory
cd "$(dirname "$0")"
# Stop and remove containers, networks, volumes, and local images
echo "[1/3] Stopping and removing containers, networks, and volumes..."
docker compose down -v --remove-orphans 2>/dev/null || true
echo " ✓ Containers, networks, and volumes removed"
echo ""
# Remove locally built images
echo "[2/3] Removing locally built images..."
docker rmi z1-frontend z1-api 2>/dev/null || true
docker rmi taskapp-frontend taskapp-api 2>/dev/null || true
echo " ✓ Local images removed"
echo ""
# Clean up any remaining named resources
echo "[3/3] Cleaning up remaining resources..."
docker volume rm taskapp-pgdata 2>/dev/null || true
docker volume rm taskapp-redisdata 2>/dev/null || true
docker network rm taskapp-frontend-net 2>/dev/null || true
docker network rm taskapp-backend-net 2>/dev/null || true
echo " ✓ Cleanup complete"
echo ""
echo "============================================="
echo " ✓ Application completely removed."
echo "============================================="

45
z1/start-app.sh Executable file
View File

@ -0,0 +1,45 @@
#!/bin/bash
# =============================================
# start-app.sh
# Starts all Task Manager application services
# =============================================
set -e
echo "============================================="
echo " Starting Task Manager Application..."
echo "============================================="
echo ""
# Navigate to the script's directory
cd "$(dirname "$0")"
# Start all services in detached mode
echo "[1/2] Starting containers..."
docker compose up -d
echo ""
# Wait a moment for services to initialize
echo "[2/2] Waiting for services to be ready..."
sleep 5
# Check health status
echo ""
echo " Container Status:"
echo " -----------------"
docker compose ps --format "table {{.Name}}\t{{.Status}}\t{{.Ports}}"
echo ""
echo "============================================="
echo " ✓ Application is running!"
echo ""
echo " 🌐 Task Manager: http://localhost:5000"
echo " 🗄️ Adminer (DB): http://localhost:5001"
echo ""
echo " Adminer login credentials:"
echo " System: PostgreSQL"
echo " Server: postgres"
echo " Username: taskuser"
echo " Password: taskpass"
echo " Database: taskmanager"
echo "============================================="

26
z1/stop-app.sh Executable file
View File

@ -0,0 +1,26 @@
#!/bin/bash
# =============================================
# stop-app.sh
# Stops all Task Manager application services
# Data is preserved in persistent volumes
# =============================================
set -e
echo "============================================="
echo " Stopping Task Manager Application..."
echo "============================================="
echo ""
# Navigate to the script's directory
cd "$(dirname "$0")"
# Stop all services (but don't remove volumes)
docker compose stop
echo ""
echo "============================================="
echo " ✓ Application stopped."
echo " Data is preserved in persistent volumes."
echo " Run ./start-app.sh to restart."
echo "============================================="