Add Task Manager Docker web application (z1)
This commit is contained in:
commit
341aa36b05
351
z1/README.md
Normal file
351
z1/README.md
Normal 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
20
z1/api/Dockerfile
Normal 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
18
z1/api/db.js
Normal 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
15
z1/api/package.json
Normal 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
163
z1/api/server.js
Normal 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
17
z1/db/init.sql
Normal 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
129
z1/docker-compose.yaml
Normal 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
19
z1/frontend/Dockerfile
Normal 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
33
z1/frontend/nginx.conf
Normal 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
252
z1/frontend/public/app.js
Normal 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);
|
||||
113
z1/frontend/public/index.html
Normal file
113
z1/frontend/public/index.html
Normal 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 — Dockerized Web Application • PostgreSQL • Redis • Node.js • Nginx</p>
|
||||
</footer>
|
||||
</div>
|
||||
|
||||
<!-- Toast Notification -->
|
||||
<div id="toast" class="toast"></div>
|
||||
|
||||
<script src="app.js"></script>
|
||||
</body>
|
||||
</html>
|
||||
535
z1/frontend/public/style.css
Normal file
535
z1/frontend/public/style.css
Normal 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
34
z1/prepare-app.sh
Executable 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
45
z1/remove-app.sh
Executable 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
45
z1/start-app.sh
Executable 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
26
z1/stop-app.sh
Executable 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 "============================================="
|
||||
Loading…
Reference in New Issue
Block a user