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