Perfect 👍
Here’s a complete step-by-step document for setting up a web-based Task Manager on Ubuntu 20.04/22.04 using Node.js + Express + SQLite.
You can use this as an installation + deployment guide.
📘 Web-based Task Manager on Ubuntu (Express + SQLite)
1. System Preparation
Update Ubuntu:
sudo apt update && sudo apt upgrade -y
Install required tools:
sudo apt install -y curl sqlite3 build-essential python3
2. Install Node.js 20 (LTS)
Ubuntu’s default Node.js is too old. Replace it with Node.js 20:
# Remove old Node.js if installed
sudo apt remove -y nodejs npm
# Add NodeSource repo for Node.js 20
curl -fsSL https://deb.nodesource.com/setup_20.x | sudo -E bash -
# Install Node.js
sudo apt install -y nodejs
# Verify versions
node -v
npm -v
Expected output:
v20.x.x
10.x.x
3. Create Project Directory
mkdir ~/taskmgr-ubuntu
cd ~/taskmgr-ubuntu
npm init -y
Install dependencies:
npm install express sqlite3 body-parser cors
Create folders:
mkdir public
4. Backend – server.js
Create file:
nano server.js
Paste:
// server.js
const express = require('express');
const bodyParser = require('body-parser');
const cors = require('cors');
const sqlite3 = require('sqlite3').verbose();
const path = require('path');
const app = express();
app.use(cors());
app.use(bodyParser.json());
app.use(express.static(path.join(__dirname, 'public')));
// SQLite DB
const db = new sqlite3.Database('./tasks.db');
// Ensure tasks table exists
db.run(`CREATE TABLE IF NOT EXISTS tasks (
id INTEGER PRIMARY KEY AUTOINCREMENT,
title TEXT NOT NULL,
done INTEGER DEFAULT 0,
created_at TEXT DEFAULT (datetime('now')),
closed_at TEXT
)`);
// API: Get all tasks
app.get('/api/tasks', (req, res) => {
db.all('SELECT * FROM tasks ORDER BY created_at DESC', [], (err, rows) => {
if (err) return res.status(500).json({ error: err.message });
res.json(rows);
});
});
// API: Add task
app.post('/api/tasks', (req, res) => {
const { title } = req.body;
if (!title) return res.status(400).json({ error: 'Title required' });
db.run('INSERT INTO tasks (title) VALUES (?)', [title], function (err) {
if (err) return res.status(500).json({ error: err.message });
db.get('SELECT * FROM tasks WHERE id = ?', [this.lastID], (err, row) => {
if (err) return res.status(500).json({ error: err.message });
res.status(201).json(row);
});
});
});
// API: Toggle task (done/undone)
app.patch('/api/tasks/:id/toggle', (req, res) => {
const id = req.params.id;
db.get('SELECT done FROM tasks WHERE id = ?', [id], (err, row) => {
if (err) return res.status(500).json({ error: err.message });
if (!row) return res.status(404).json({ error: 'Not found' });
const newVal = row.done ? 0 : 1;
const closedAt = newVal ? new Date().toISOString() : null;
db.run('UPDATE tasks SET done = ?, closed_at = ? WHERE id = ?', [newVal, closedAt, id], function (err) {
if (err) return res.status(500).json({ error: err.message });
res.json({ id: Number(id), done: newVal, closed_at: closedAt });
});
});
});
// API: Delete task
app.delete('/api/tasks/:id', (req, res) => {
const id = req.params.id;
db.run('DELETE FROM tasks WHERE id = ?', [id], function (err) {
if (err) return res.status(500).json({ error: err.message });
res.json({ deleted: Number(id) });
});
});
// Serve index.html
app.get('/', (req, res) => {
res.sendFile(path.join(__dirname, 'public', 'index.html'));
});
const port = process.env.PORT || 3000;
app.listen(port, () => console.log(`✅ Task Manager running at http://localhost:${port}`));
5. Frontend – public/index.html
Create file:
nano public/index.html
Paste:
<!-- html -->
<!DOCTYPE html>
<html lang="en">
<head>
<title>Ubuntu Task Manager</title>
<meta charset="utf-8">
<meta name="viewport" content="width=device-width,initial-scale=1">
<style>
:root{
--bg: #f5f7fb;
--panel: #ffffff;
--text: #0f172a;
--muted: #6b7280;
--border: #e5e7eb;
--accent: #2563eb; /* Primary */
--accent-600: #1d4ed8;
--success: #16a34a;
--danger: #dc2626;
--warning: #d97706;
--shadow: 0 10px 25px rgba(2,8,23,0.08), 0 2px 8px rgba(2,8,23,0.05);
--radius: 12px;
}
* { box-sizing: border-box; }
html, body { height: 100%; }
body{
margin: 0;
font-family: -apple-system, BlinkMacSystemFont, "Segoe UI", Roboto, Inter, "Helvetica Neue", Arial, "Noto Sans", "Apple Color Emoji","Segoe UI Emoji", "Segoe UI Symbol", "Noto Color Emoji", sans-serif;
background: linear-gradient(180deg, #f8fafc 0%, #f3f4f6 100%);
color: var(--text);
}
/* Fixed header */
.app-header{
position: fixed;
top: 0; left: 0; right: 0;
background: rgba(255,255,255,0.8);
border-bottom: 1px solid var(--border);
backdrop-filter: saturate(180%) blur(12px);
z-index: 1000;
}
.container{
max-width: 1100px;
margin: 0 auto;
padding: 1rem 1.25rem;
}
.topbar{
display: grid;
grid-template-columns: 1fr auto;
align-items: center;
gap: 1rem;
margin-bottom: 0.75rem;
}
.brand{
display: flex;
align-items: center;
gap: 0.75rem;
}
.brand-logo{
width: 36px; height: 36px;
display: grid; place-items: center;
border-radius: 10px;
background: radial-gradient(120% 120% at 10% 10%, #93c5fd, #2563eb 60%, #1e293b 100%);
color: #fff;
font-weight: 800;
letter-spacing: 0.5px;
box-shadow: var(--shadow);
}
.brand h1{
margin: 0;
font-size: 1.25rem;
font-weight: 700;
letter-spacing: 0.2px;
}
.clock{
color: var(--muted);
font-weight: 600;
}
/* Controls row */
.controls{
display: grid;
grid-template-columns: 1fr auto;
gap: 0.75rem;
}
.task-form{
display: flex;
gap: 0.5rem;
}
.input{
flex: 1;
padding: 0.7rem 0.85rem;
border: 1px solid var(--border);
border-radius: 10px;
background: var(--panel);
outline: none;
transition: border-color 0.15s, box-shadow 0.15s;
font-size: 0.95rem;
}
.input:focus{
border-color: var(--accent);
box-shadow: 0 0 0 4px rgba(37, 99, 235, 0.1);
}
.btn{
display: inline-flex;
align-items: center;
gap: 0.5rem;
padding: 0.65rem 0.9rem;
border-radius: 10px;
border: 1px solid var(--border);
background: var(--panel);
color: var(--text);
cursor: pointer;
transition: background 0.15s, border-color 0.15s, transform 0.05s;
font-weight: 600;
}
.btn:hover{ background: #f8fafc; }
.btn:active{ transform: translateY(1px); }
.btn-primary{
background: var(--accent);
color: #fff;
border-color: var(--accent);
}
.btn-primary:hover{ background: var(--accent-600); border-color: var(--accent-600); }
.btn-danger{
background: #fff5f5;
color: var(--danger);
border-color: #fecaca;
}
.btn-danger:hover{
background: #fff0f0;
border-color: #fca5a5;
}
/* Segmented filters */
.filters{
display: inline-flex;
padding: 0.25rem;
background: #f1f5f9;
border: 1px solid var(--border);
border-radius: 999px;
gap: 0.25rem;
align-self: start;
}
.filter-btn{
padding: 0.45rem 0.9rem;
border-radius: 999px;
border: 0;
background: transparent;
color: #334155;
font-weight: 600;
cursor: pointer;
transition: background 0.15s, color 0.15s;
}
.filter-btn:hover{ background: #e2e8f0; }
.filter-btn.active{ background: #ffffff; color: var(--accent); box-shadow: 0 1px 3px rgba(2,8,23,0.06); }
/* Main content spacing below fixed header */
.page{
padding-top: 9.5rem;
}
/* Card + table */
.card{
background: var(--panel);
border: 1px solid var(--border);
border-radius: var(--radius);
box-shadow: var(--shadow);
overflow: hidden;
}
.table-wrap{
width: 100%;
overflow-x: auto;
}
table{
border-collapse: collapse;
width: 100%;
min-width: 780px;
background: var(--panel);
}
thead th{
position: sticky;
top: 0;
background: #f8fafc;
color: #334155;
border-bottom: 1px solid var(--border);
padding: 0.8rem 0.75rem;
text-align: left;
font-size: 0.85rem;
letter-spacing: 0.4px;
z-index: 1;
}
tbody td{
border-top: 1px solid var(--border);
padding: 0.75rem;
vertical-align: top;
}
tbody tr:hover{ background: #fafafa; }
.title.done{
text-decoration: line-through;
color: #9ca3af;
}
.actions{
display: flex;
gap: 0.35rem;
flex-wrap: wrap;
}
/* Badges */
.badge{
display: inline-flex;
align-items: center;
gap: 0.4rem;
padding: 0.25rem 0.55rem;
border-radius: 999px;
font-size: 0.8rem;
font-weight: 700;
letter-spacing: 0.2px;
border: 1px solid transparent;
}
.badge-open{
color: #92400e;
background: #fef3c7;
border-color: #fde68a;
}
.badge-closed{
color: #065f46;
background: #d1fae5;
border-color: #a7f3d0;
}
/* Empty state */
.empty{
text-align: center;
color: var(--muted);
}
.empty .hint{
margin-top: 0.25rem;
font-size: 0.95rem;
}
/* Accessibility */
.btn:focus, .filter-btn:focus{
outline: 2px solid transparent;
box-shadow: 0 0 0 4px rgba(37,99,235,0.25);
}
/* Responsive tweaks */
@media (max-width: 720px){
.controls{ grid-template-columns: 1fr; }
.filters{ justify-self: start; }
.page{ padding-top: 11rem; }
}
</style>
</head>
<body>
<!-- Fixed Header -->
<header class="app-header">
<div class="container">
<div class="topbar">
<div class="brand">
<div class="brand-logo">TM</div>
<h1>Task Manager</h1>
</div>
<div class="clock" id="clock">Loading time…</div>
</div>
<div class="controls">
<form id="taskForm" class="task-form" autocomplete="off">
<input class="input" type="text" id="title" placeholder="Add a new task and press Enter or click Add" required autofocus>
<button class="btn btn-primary" type="submit" title="Add task">➕ Add</button>
</form>
<div class="filters" id="filters" role="tablist" aria-label="Task filters">
<button class="filter-btn active" data-filter="all" role="tab" aria-selected="true">All</button>
<button class="filter-btn" data-filter="open" role="tab" aria-selected="false">Open</button>
<button class="filter-btn" data-filter="closed" role="tab" aria-selected="false">Closed</button>
<button class="filter-btn" data-filter="today" role="tab" aria-selected="false">Today</button>
</div>
</div>
</div>
</header>
<!-- Page Content -->
<main class="page container">
<div class="card">
<div class="table-wrap">
<table id="taskTable" aria-label="Tasks table">
<thead>
<tr>
<th style="width:80px">ID</th>
<th>Title</th>
<th style="width:120px">Status</th>
<th style="width:200px">Created</th>
<th style="width:200px">Closed</th>
<th style="width:180px">Actions</th>
</tr>
</thead>
<tbody></tbody>
</table>
</div>
</div>
</main>
<script>
const api = '/api/tasks';
const tableBody = document.querySelector('#taskTable tbody');
const filtersEl = document.getElementById('filters');
const titleEl = document.getElementById('title');
let currentFilter = 'all';
// Robust parsing for SQLite "YYYY-MM-DD HH:MM:SS" to local time string
function parseSqliteToLocalString(s) {
if (!s) return '';
const iso = s.includes('T') ? s : s.replace(' ', 'T') + 'Z';
const d = new Date(iso);
return isNaN(d) ? s : d.toLocaleString();
}
function isTodaySqlite(s) {
if (!s) return false;
const iso = s.includes('T') ? s : s.replace(' ', 'T') + 'Z';
const d = new Date(iso);
if (isNaN(d)) return false;
const now = new Date();
return d.getFullYear() === now.getFullYear() &&
d.getMonth() === now.getMonth() &&
d.getDate() === now.getDate();
}
function applyFilter(tasks) {
switch (currentFilter) {
case 'open': return tasks.filter(t => !t.done);
case 'closed': return tasks.filter(t => !!t.done);
case 'today': return tasks.filter(t => isTodaySqlite(t.created_at));
case 'all':
default: return tasks;
}
}
function statusBadge(t) {
return t.done
? `<span class="badge badge-closed">✅ Closed</span>`
: `<span class="badge badge-open">⏳ Open</span>`;
}
function emptyRow(cols) {
return `
<tr>
<td class="empty" colspan="${cols}">
<div>No tasks to display</div>
<div class="hint">Use the input above to add your first task.</div>
</td>
</tr>
`;
}
async function loadTasks() {
try {
const res = await fetch(api);
const tasks = await res.json();
const visible = applyFilter(tasks);
if (!visible.length) {
tableBody.innerHTML = emptyRow(6);
return;
}
tableBody.innerHTML = visible.map(t => `
<tr>
<td>${t.id}</td>
<td class="title ${t.done ? 'done' : ''}">${escapeHtml(t.title)}</td>
<td>${statusBadge(t)}</td>
<td>${parseSqliteToLocalString(t.created_at)}</td>
<td>${parseSqliteToLocalString(t.closed_at)}</td>
<td class="actions">
<button class="btn" onclick="toggleTask(${t.id})" title="Toggle status">↕ Toggle</button>
<button class="btn btn-danger" onclick="deleteTask(${t.id})" title="Delete task">🗑 Delete</button>
</td>
</tr>
`).join('');
} catch (e) {
tableBody.innerHTML = `
<tr><td colspan="6" class="empty">Failed to load tasks. Please try again.</td></tr>
`;
}
}
// Prevent HTML injection for task titles
function escapeHtml(str) {
return str
.replaceAll('&', '&')
.replaceAll('<', '<')
.replaceAll('>', '>')
.replaceAll('"', '"')
.replaceAll("'", ''');
}
async function addTask(e) {
e.preventDefault();
const title = titleEl.value.trim();
if (!title) return;
await fetch(api, {
method: 'POST',
headers: { 'Content-Type': 'application/json' },
body: JSON.stringify({ title })
});
titleEl.value = '';
titleEl.focus();
await loadTasks();
}
async function toggleTask(id) {
await fetch(`${api}/${id}/toggle`, { method: 'PATCH' });
await loadTasks();
}
async function deleteTask(id) {
const ok = confirm('Are you sure you want to delete this task? This action cannot be undone.');
if (!ok) return;
await fetch(`${api}/${id}`, { method: 'DELETE' });
await loadTasks();
}
// Filter button handlers
filtersEl.addEventListener('click', (e) => {
const btn = e.target.closest('button[data-filter]');
if (!btn) return;
currentFilter = btn.getAttribute('data-filter');
[...filtersEl.querySelectorAll('.filter-btn')].forEach(b => {
b.classList.toggle('active', b === btn);
b.setAttribute('aria-selected', b === btn ? 'true' : 'false');
});
loadTasks();
});
// Live date/time in header
function updateClock() {
document.getElementById('clock').textContent = new Date().toLocaleString();
}
setInterval(updateClock, 1000);
updateClock();
// Init
document.getElementById('taskForm').addEventListener('submit', addTask);
loadTasks();
</script>
</body>
</html>
6. Run the App
Inside your project folder:
node server.js
Output:
✅ Task Manager running at http://localhost:3000
Open browser:
http://<your-server-ip>:3000
7. Run in Background (Optional)
Install PM2 to keep it alive:
sudo npm install -g pm2
pm2 start server.js --name taskmgr
pm2 save
pm2 startup
This ensures the app runs even after reboot.
✅ Features
-
Add new tasks
-
Display tasks in a table format
-
Track Created At and Closed At timestamps
-
Toggle between Open ✅ / Closed ⏳
-
Delete tasks
-
Auto-persistent in SQLite (
tasks.db
)
Would you like me to also add a systemd service file (instead of PM2) so you can manage the Task Manager using systemctl start/stop/status taskmgr
like a native Ubuntu service?
========================================================================
Perfect 👍 — let’s put it all together and make a working web-based Task Manager app on Rocky Linux 8.7, using the Express + SQLite code we already fixed. I’ll give you the complete structure, backend (server.js
), frontend (index.html
), installation steps, and systemd + nginx setup so it runs as a service.
📂 Project Structure
We’ll place the app in /opt/taskmgr
:
/opt/taskmgr
│── package.json
│── server.js
│
├── node_modules/ (created by npm install)
└── public/
└── index.html (frontend page)
1. Install prerequisites
Run as root:
# Update system
dnf update -y
# Install Node.js 18 (supported in Rocky 8.7)
dnf module reset nodejs -y
dnf module enable nodejs:18 -y
dnf install -y nodejs git nginx sqlite
# Install build tools (optional, for node-gyp if needed later)
dnf groupinstall "Development Tools" -y
2. Create the app directory
mkdir -p /opt/taskmgr
cd /opt/taskmgr
npm init -y
3. Install dependencies
npm install express sqlite3 body-parser cors
4. Backend code (server.js
)
Create file /opt/taskmgr/server.js
:
// server.js
const express = require('express');
const bodyParser = require('body-parser');
const cors = require('cors');
const sqlite3 = require('sqlite3').verbose();
const path = require('path');
const app = express();
app.use(cors());
app.use(bodyParser.json());
// serve frontend
app.use(express.static(path.join(__dirname, 'public')));
// open DB
const db = new sqlite3.Database('./tasks.db');
// ensure table exists
db.run(`CREATE TABLE IF NOT EXISTS tasks (
id INTEGER PRIMARY KEY AUTOINCREMENT,
title TEXT NOT NULL,
done INTEGER DEFAULT 0,
created_at TEXT DEFAULT (datetime('now')),
closed_at TEXT
)`);
// list tasks
app.get('/api/tasks', (req, res) => {
db.all('SELECT * FROM tasks ORDER BY created_at DESC', [], (err, rows) => {
if (err) return res.status(500).json({ error: err.message });
res.json(rows);
});
});
// create task
app.post('/api/tasks', (req, res) => {
const { title } = req.body;
if (!title) return res.status(400).json({ error: 'title required' });
db.run('INSERT INTO tasks (title) VALUES (?)', [title], function(err) {
if (err) return res.status(500).json({ error: err.message });
db.get('SELECT * FROM tasks WHERE id = ?', [this.lastID], (err, row) => {
if (err) return res.status(500).json({ error: err.message });
res.status(201).json(row);
});
});
});
// toggle done
app.patch('/api/tasks/:id/toggle', (req, res) => {
const id = req.params.id;
db.get('SELECT done FROM tasks WHERE id = ?', [id], (err, row) => {
if (err) return res.status(500).json({ error: err.message });
if (!row) return res.status(404).json({ error: 'not found' });
const newVal = row.done ? 0 : 1;
db.run('UPDATE tasks SET done = ? WHERE id = ?', [newVal, id], function(err) {
if (err) return res.status(500).json({ error: err.message });
res.json({ id: Number(id), done: newVal });
});
});
});
// delete task
app.delete('/api/tasks/:id', (req, res) => {
const id = req.params.id;
db.run('DELETE FROM tasks WHERE id = ?', [id], function(err) {
if (err) return res.status(500).json({ error: err.message });
res.json({ deleted: Number(id) });
});
});
const port = process.env.PORT || 3000;
app.listen(port, () => console.log(`Task Manager running on port ${port}`));
5. Frontend (public/index.html
)
Create folder /opt/taskmgr/public/
Create /opt/taskmgr/public/index.html
:
HTML
<!DOCTYPE html>
<html>
<head>
<meta charset="utf-8">
<title>Task Manager</title>
<style>
:root {
--toolbar-height: 64px;
--headerbar-height: 44px;
/* Theme */
--brand1: #5b8dee;
--brand2: #7c4dff;
--card-bg: #ffffff;
--text: #1d2230;
--table-head-bg: #f3f6fb;
--btn-bg: #eef2ff;
--btn-border: #c7d2fe;
--btn-hover: #e0e7ff;
--btn-active-bg: #dbeafe;
--btn-active-border: #60a5fa;
--shadow-soft: 0 6px 24px rgba(17, 24, 39, 0.1);
--shadow-head: 0 6px 16px rgba(17, 24, 39, 0.12);
}
html, body { height: 100%; }
body {
font-family: Inter, -apple-system, BlinkMacSystemFont, "Segoe UI", Roboto, "Helvetica Neue", Arial, sans-serif;
margin: 0;
padding: 0;
color: var(--text);
background: linear-gradient(180deg, #f7f9fc 0%, #eef2f7 100%);
}
/* FIXED toolbar (title, add, filters, clock) */
#toolbar {
position: fixed;
top: 0;
left: 0; right: 0;
z-index: 1000;
display: flex;
align-items: center;
justify-content: space-between;
gap: 16px;
padding: 12px 20px;
background: linear-gradient(135deg, var(--brand1), var(--brand2));
color: #fff;
box-shadow: var(--shadow-head);
}
#toolbar .left {
display: inline-flex;
align-items: center;
gap: 14px;
flex-wrap: wrap;
}
.brand { display: inline-flex; align-items: center; gap: 10px; }
.brand svg { width: 28px; height: 28px; filter: drop-shadow(0 2px 6px rgba(0,0,0,0.2)); }
.brand h1 { margin: 0; font-size: 20px; letter-spacing: 0.2px; text-shadow: 0 1px 2px rgba(0,0,0,0.2); }
/* Add form */
#taskForm {
display: inline-flex;
gap: 8px;
margin: 0;
background: rgba(255,255,255,0.16);
padding: 6px 8px;
border-radius: 10px;
backdrop-filter: blur(4px);
}
#title {
min-width: 280px;
padding: 8px 10px;
border-radius: 8px;
border: 1px solid rgba(255,255,255,0.35);
outline: none;
color: #1e293b;
background: #fff;
}
#taskForm button {
padding: 8px 12px;
border-radius: 8px;
border: 1px solid rgba(255,255,255,0.6);
color: #1e293b;
background: #ffffff;
cursor: pointer;
}
#taskForm button:hover { background: #f5f7ff; }
/* Filters */
.filters {
display: inline-flex;
gap: 8px;
align-items: center;
background: rgba(255,255,255,0.12);
padding: 6px 8px;
border-radius: 12px;
}
.filters-label { color: #e7eaff; }
.filter-btn {
display: inline-flex;
align-items: center;
gap: 6px;
padding: 8px 12px;
border-radius: 999px;
border: 1px solid var(--btn-border);
background: var(--btn-bg);
color: #1e293b;
cursor: pointer;
transition: background 0.15s ease, box-shadow 0.15s ease;
box-shadow: 0 1px 2px rgba(0,0,0,0.06);
}
.filter-btn:hover { background: var(--btn-hover); }
.filter-btn.active {
background: var(--btn-active-bg);
border-color: var(--btn-active-border);
box-shadow: 0 2px 6px rgba(37,99,235,0.25);
color: #0f172a;
font-weight: 600;
}
/* Clock */
#clock {
margin-left: auto;
font-variant-numeric: tabular-nums;
font-family: ui-monospace, Menlo, Consolas, "SFMono-Regular", monospace;
color: #fff;
background: rgba(255,255,255,0.16);
padding: 6px 10px;
border-radius: 8px;
border: 1px solid rgba(255,255,255,0.35);
white-space: nowrap;
}
/* FIXED column header bar (always visible under toolbar) */
#colHeaderBar {
position: fixed;
left: 0; right: 0;
top: var(--toolbar-height);
z-index: 950;
background: var(--table-head-bg);
border-bottom: 1px solid #e5e7eb;
box-shadow: 0 4px 8px rgba(17,24,39,0.06);
padding: 0 20px;
height: var(--headerbar-height);
display: flex;
align-items: center;
}
/* Grid aligns with table column widths */
.col-grid {
display: grid;
width: 100%;
grid-template-columns: 80px auto 140px 180px 180px 200px;
column-gap: 0;
align-items: center;
font-weight: 600;
color: #0f172a;
}
.col-grid > div { padding: 0 12px; }
/* Content: push down by heights of fixed bars */
.content {
position: relative;
z-index: 1;
padding: 20px;
margin-top: calc(var(--toolbar-height) + var(--headerbar-height) + 12px);
}
.card {
background: var(--card-bg);
border-radius: 14px;
box-shadow: var(--shadow-soft);
overflow: visible; /* ensure nothing clips the fixed bars */
}
/* Table using fixed layout to match header grid */
table {
width: 100%;
border-collapse: collapse;
table-layout: fixed;
}
thead { display: none; } /* hide built-in header since we render our own fixed header bar */
tbody td {
border-bottom: 1px solid #eef2f7;
padding: 12px;
color: #111827;
vertical-align: middle;
background: #fff;
}
/* Keep columns aligned with header grid */
tbody td:nth-child(1) { width: 80px; }
tbody td:nth-child(2) { width: auto; }
tbody td:nth-child(3) { width: 140px; }
tbody td:nth-child(4) { width: 180px; }
tbody td:nth-child(5) { width: 180px; }
tbody td:nth-child(6) { width: 200px; }
tbody tr:nth-child(odd) { background: #fafbff; }
tbody tr:hover { background: #f5f7ff; }
.status-badge {
display: inline-block;
padding: 4px 10px;
border-radius: 999px;
font-size: 12px;
font-weight: 600;
color: #0f172a;
background: #e5f8ee;
border: 1px solid #a7e8c4;
}
.status-closed { background: #fdecec; border-color: #f6b5b5; }
.done { text-decoration: line-through; color: #6b7280; }
.controls { display: inline-flex; gap: 8px; }
.btn {
display: inline-flex;
align-items: center;
gap: 6px;
padding: 8px 12px;
border-radius: 8px;
border: 1px solid #e5e7eb;
background: #ffffff;
color: #111827;
cursor: pointer;
transition: background 0.15s ease, box-shadow 0.15s ease, border-color 0.15s ease;
box-shadow: 0 1px 2px rgba(0,0,0,0.06);
}
.btn:hover { background: #f8fafc; }
.btn-complete { border-color: #a7e8c4; }
.btn-complete:hover { background: #ecfdf5; }
.btn-delete { border-color: #f6b5b5; }
.btn-delete:hover { background: #fff5f5; }
@media (max-width: 820px) {
#title { min-width: 180px; }
.col-grid { grid-template-columns: 64px auto 110px 150px 150px 160px; }
tbody td { padding: 10px; }
}
</style>
</head>
<body>
<!-- Fixed toolbar -->
<div id="toolbar">
<div class="left">
<div class="brand" aria-label="Task Manager brand">
<svg viewBox="0 0 32 32" xmlns="http://www.w3.org/2000/svg" aria-hidden="true">
<defs>
<linearGradient id="g" x1="0" y1="0" x2="1" y2="1">
<stop offset="0" stop-color="#5b8dee"/>
<stop offset="1" stop-color="#7c4dff"/>
</linearGradient>
</defs>
<circle cx="16" cy="16" r="14" fill="url(#g)"/>
<path d="M10 16l4 4 8-8" fill="none" stroke="#fff" stroke-width="2.5" stroke-linecap="round" stroke-linejoin="round"/>
</svg>
<h1>Task Manager</h1>
</div>
<form id="taskForm">
<input id="title" placeholder="New task..." required>
<button type="submit" title="Add task">Add</button>
</form>
<div class="filters" role="group" aria-label="Filter tasks">
<span class="filters-label">Show:</span>
<button class="filter-btn active" id="filter-all" onclick="setFilter('all')">All</button>
<button class="filter-btn" id="filter-open" onclick="setFilter('open')">Open</button>
<button class="filter-btn" id="filter-closed" onclick="setFilter('closed')">Closed</button>
<button class="filter-btn" id="filter-today" onclick="setFilter('today')">Today</button>
</div>
</div>
<div id="clock" aria-label="Current date and time"></div>
</div>
<!-- Fixed column header bar -->
<div id="colHeaderBar" role="presentation">
<div class="col-grid" aria-hidden="true">
<div>ID</div>
<div>Title</div>
<div>Status</div>
<div>Created</div>
<div>Closed</div>
<div>Actions</div>
</div>
</div>
<!-- Main content -->
<div class="content">
<div class="card">
<table>
<thead>
<tr>
<th>ID</th>
<th>Title</th>
<th>Status</th>
<th>Created</th>
<th>Closed</th>
<th>Actions</th>
</tr>
</thead>
<tbody id="tasksBody"></tbody>
</table>
</div>
</div>
<script>
const api = '/api/tasks';
let tasksCache = [];
let currentFilter = 'all';
function updateClock() {
const el = document.getElementById('clock');
const now = new Date();
el.textContent = now.toLocaleString(undefined, {
dateStyle: 'medium',
timeStyle: 'medium'
});
}
function setFixedOffsets() {
const toolbar = document.getElementById('toolbar');
const colBar = document.getElementById('colHeaderBar');
const th = toolbar ? toolbar.offsetHeight : 64;
const hb = colBar ? colBar.offsetHeight : 44;
document.documentElement.style.setProperty('--toolbar-height', th + 'px');
document.documentElement.style.setProperty('--headerbar-height', hb + 'px');
const content = document.querySelector('.content');
if (content) {
content.style.marginTop = `calc(${th}px + ${hb}px + 12px)`;
}
}
function parseSqliteDatetime(s) {
if (!s) return null;
return new Date(s.replace(' ', 'T') + 'Z');
}
function isToday(ts) {
const d = parseSqliteDatetime(ts);
if (!d) return false;
const now = new Date();
return d.toDateString() === now.toDateString();
}
function setFilter(f) {
currentFilter = f;
['all', 'open', 'closed', 'today'].forEach(id => {
const btn = document.getElementById('filter-' + id);
if (btn) btn.classList.toggle('active', id === f);
});
renderTasks();
setFixedOffsets(); // in case toolbar wraps and changes height
}
function filteredTasks() {
switch (currentFilter) {
case 'open': return tasksCache.filter(t => !t.done);
case 'closed': return tasksCache.filter(t => !!t.done);
case 'today': return tasksCache.filter(t => isToday(t.created_at));
case 'all':
default: return tasksCache;
}
}
function renderTasks() {
const tbody = document.getElementById('tasksBody');
tbody.innerHTML = '';
const tasks = filteredTasks();
tasks.forEach(t => {
const tr = document.createElement('tr');
const tdId = document.createElement('td');
tdId.textContent = t.id;
const tdTitle = document.createElement('td');
const span = document.createElement('span');
span.textContent = t.title;
if (t.done) span.classList.add('done');
tdTitle.appendChild(span);
const tdStatus = document.createElement('td');
const status = document.createElement('span');
status.className = 'status-badge' + (t.done ? ' status-closed' : '');
status.textContent = t.done ? 'Completed' : 'Open';
tdStatus.appendChild(status);
const tdCreated = document.createElement('td');
tdCreated.textContent = t.created_at || '—';
const tdClosed = document.createElement('td');
tdClosed.textContent = t.closed_at || '';
const tdActions = document.createElement('td');
const controls = document.createElement('div');
controls.className = 'controls';
const toggleBtn = document.createElement('button');
toggleBtn.className = 'btn btn-complete';
toggleBtn.title = t.done ? 'Reopen task' : 'Complete task';
toggleBtn.textContent = t.done ? 'Reopen' : 'Complete';
toggleBtn.onclick = () => toggleTask(t.id);
const deleteBtn = document.createElement('button');
deleteBtn.className = 'btn btn-delete';
deleteBtn.title = 'Delete task';
deleteBtn.textContent = 'Delete';
deleteBtn.onclick = () => deleteTask(t.id);
controls.appendChild(toggleBtn);
controls.appendChild(deleteBtn);
tdActions.appendChild(controls);
tr.appendChild(tdId);
tr.appendChild(tdTitle);
tr.appendChild(tdStatus);
tr.appendChild(tdCreated);
tr.appendChild(tdClosed);
tr.appendChild(tdActions);
tbody.appendChild(tr);
});
}
async function loadTasks() {
const res = await fetch(api);
tasksCache = await res.json();
renderTasks();
}
async function addTask(e) {
e.preventDefault();
const title = document.getElementById('title').value.trim();
if (!title) return;
await fetch(api, {
method: 'POST',
headers: {'Content-Type': 'application/json'},
body: JSON.stringify({ title })
});
document.getElementById('title').value = '';
loadTasks();
}
async function toggleTask(id) {
await fetch(`${api}/${id}/toggle`, { method: 'PATCH' });
loadTasks();
}
async function deleteTask(id) {
await fetch(`${api}/${id}`, { method: 'DELETE' });
loadTasks();
}
document.getElementById('taskForm').addEventListener('submit', addTask);
// Initialize
updateClock();
setFixedOffsets();
loadTasks();
setInterval(updateClock, 1000);
window.addEventListener('resize', setFixedOffsets);
window.addEventListener('load', setFixedOffsets);
</script>
</body>
</html>
6. Test manually
cd /opt/taskmgr
node server.js
Now open in browser:
http://<server-ip>:3000/
7. Run as a service
Create systemd unit /etc/systemd/system/taskmgr.service
:
[Unit]
Description=Task Manager Node.js App
After=network.target
[Service]
Type=simple
User=root
WorkingDirectory=/opt/taskmgr
ExecStart=/usr/bin/node server.js
Restart=on-failure
Environment=PORT=3000
[Install]
WantedBy=multi-user.target
Enable and start:
systemctl daemon-reload
systemctl enable --now taskmgr
8. (Optional) Reverse proxy with Nginx
Create /etc/nginx/conf.d/taskmgr.conf
:
server {
listen 80;
server_name your-domain.com; # or server IP
location / {
proxy_pass http://127.0.0.1:3000;
proxy_set_header Host $host;
proxy_set_header X-Real-IP $remote_addr;
}
}
Check and reload:
nginx -t && systemctl reload nginx
Now you can access it via:
http://your-domain.com/
✅ You now have a fully working web-based Task Manager app running on Rocky Linux 8.7, accessible via browser, managed as a service.
Do you want me to also add HTTPS setup with Certbot so it runs securely with SSL?
No comments:
Post a Comment