Web-based Task Manager app on Linux

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('&', '&amp;')
        .replaceAll('<', '&lt;')
        .replaceAll('>', '&gt;')
        .replaceAll('"', '&quot;')
        .replaceAll("'", '&#039;');
    }

    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