Initial commit: Project Tracker - Node.js + Express + SQLite backend, dark-themed frontend\n\n- Express REST API (GET/POST/PUT/DELETE /api/projects)\n- SQLite database with better-sqlite3\n- Dark-themed single-page UI with filter bar and drill-down panel\n- nginx reverse proxy config\n- Deployment script

This commit is contained in:
Ada
2026-04-05 21:07:13 -04:00
commit bd658a822b
6 changed files with 889 additions and 0 deletions

14
backend/package.json Normal file
View File

@@ -0,0 +1,14 @@
{
"name": "project-tracker-backend",
"version": "1.0.0",
"description": "Project Tracker API",
"main": "server.js",
"scripts": {
"start": "node server.js"
},
"dependencies": {
"better-sqlite3": "^11.0.0",
"cors": "^2.8.5",
"express": "^4.18.2"
}
}

103
backend/server.js Normal file
View File

@@ -0,0 +1,103 @@
const express = require('express');
const sqlite3 = require('better-sqlite3');
const cors = require('cors');
const fs = require('fs');
const path = require('path');
const app = express();
const PORT = 3000;
const DB_PATH = '/opt/project-tracker/data/projects.db';
const DATA_DIR = '/opt/project-tracker/data';
// Ensure data directory exists
if (!fs.existsSync(DATA_DIR)) {
fs.mkdirSync(DATA_DIR, { recursive: true });
}
// Initialize database
const db = new sqlite3(DB_PATH);
// Create table
db.exec(`
CREATE TABLE IF NOT EXISTS projects (
id INTEGER PRIMARY KEY AUTOINCREMENT,
name TEXT NOT NULL,
priority TEXT NOT NULL,
url TEXT DEFAULT '',
notes TEXT DEFAULT '',
status TEXT DEFAULT 'Backlog',
owner TEXT DEFAULT 'Ada',
tags TEXT DEFAULT '',
created_at TEXT DEFAULT (datetime('now')),
updated_at TEXT DEFAULT (datetime('now'))
)
`);
// Middleware
app.use(cors());
app.use(express.json());
// GET all projects
app.get('/api/projects', (req, res) => {
const { status } = req.query;
let stmt = status
? db.prepare('SELECT * FROM projects WHERE status = ? ORDER BY CASE priority WHEN \'High\' THEN 1 WHEN \'Med-High\' THEN 2 WHEN \'Medium\' THEN 3 WHEN \'Low\' THEN 4 ELSE 5 END, created_at DESC')
: db.prepare('SELECT * FROM projects ORDER BY CASE priority WHEN \'High\' THEN 1 WHEN \'Med-High\' THEN 2 WHEN \'Medium\' THEN 3 WHEN \'Low\' THEN 4 ELSE 5 END, created_at DESC');
const rows = status ? stmt.all(status) : stmt.all();
res.json(rows);
});
// GET single project
app.get('/api/projects/:id', (req, res) => {
const stmt = db.prepare('SELECT * FROM projects WHERE id = ?');
const row = stmt.get(req.params.id);
if (row) res.json(row);
else res.status(404).json({ error: 'Not found' });
});
// POST create project
app.post('/api/projects', (req, res) => {
const { name, priority, url, notes, status, owner, tags } = req.body;
if (!name || !priority) {
return res.status(400).json({ error: 'name and priority are required' });
}
const stmt = db.prepare(`
INSERT INTO projects (name, priority, url, notes, status, owner, tags)
VALUES (?, ?, ?, ?, ?, ?, ?)
`);
const result = stmt.run(name, priority, url || '', notes || '', status || 'Backlog', owner || 'Ada', tags || '');
res.json({ id: result.lastInsertRowid, message: 'Created' });
});
// PUT update project
app.put('/api/projects/:id', (req, res) => {
const { name, priority, url, notes, status, owner, tags } = req.body;
const fields = [];
const values = [];
if (name !== undefined) { fields.push('name = ?'); values.push(name); }
if (priority !== undefined) { fields.push('priority = ?'); values.push(priority); }
if (url !== undefined) { fields.push('url = ?'); values.push(url); }
if (notes !== undefined) { fields.push('notes = ?'); values.push(notes); }
if (status !== undefined) { fields.push('status = ?'); values.push(status); }
if (owner !== undefined) { fields.push('owner = ?'); values.push(owner); }
if (tags !== undefined) { fields.push('tags = ?'); values.push(tags); }
fields.push('updated_at = datetime(\'now\')');
values.push(req.params.id);
if (fields.length === 1) {
return res.status(400).json({ error: 'No fields to update' });
}
const stmt = db.prepare(`UPDATE projects SET ${fields.join(', ')} WHERE id = ?`);
stmt.run(...values);
res.json({ message: 'Updated' });
});
// DELETE project
app.delete('/api/projects/:id', (req, res) => {
const stmt = db.prepare('DELETE FROM projects WHERE id = ?');
stmt.run(req.params.id);
res.json({ message: 'Deleted' });
});
app.listen(PORT, '0.0.0.0', () => {
console.log(`Project Tracker API running on port ${PORT}`);
});

66
deploy.sh Normal file
View File

@@ -0,0 +1,66 @@
#!/bin/bash
# Project Tracker Deployment Script
# Run on project-tracker (10.10.11.103) as root
set -e
echo "=== Project Tracker Deployment ==="
# 1. Install dependencies
echo "[1/6] Installing nginx, node, npm..."
apt-get update -qq
apt-get install -y -qq nginx nodejs npm > /dev/null 2>&1
# 2. Create directories
echo "[2/6] Creating directories..."
mkdir -p /opt/project-tracker/backend /opt/project-tracker/data /opt/project-tracker/frontend
# 3. Install Node dependencies
echo "[3/6] Installing Node dependencies..."
cd /opt/project-tracker/backend
npm install --silent express better-sqlite3 cors
# 4. Deploy backend server.js (already created locally and needs to be copied)
# 5. Deploy frontend (already created locally and needs to be copied)
# 6. Start services...
# 7. Set up systemd service
echo "[4/6] Setting up systemd service..."
cat > /etc/systemd/system/project-tracker.service << 'EOF'
[Unit]
Description=Project Tracker API
After=network.target
[Service]
Type=simple
WorkingDirectory=/opt/project-tracker/backend
ExecStart=/usr/bin/node server.js
Restart=always
RestartSec=5
User=root
[Install]
WantedBy=multi-user.target
EOF
systemctl daemon-reload
systemctl enable project-tracker
systemctl restart project-tracker
# 8. Configure nginx
echo "[5/6] Configuring nginx..."
cp /opt/project-tracker/nginx.conf /etc/nginx/sites-available/project-tracker
ln -sf /etc/nginx/sites-available/project-tracker /etc/nginx/sites-enabled/project-tracker
nginx -t && systemctl reload nginx
# 9. Verify
echo "[6/6] Verifying..."
sleep 2
curl -s http://localhost:3000/api/projects | head -c 100
echo ""
curl -s -o /dev/null -w "Frontend: %{http_code}\n" http://localhost/
echo ""
systemctl status project-tracker --no-pager | grep -E 'Active|loaded'
echo ""
echo "=== Done! ==="
echo "Access at: http://10.10.11.103"

BIN
frontend/favicon.png Normal file

Binary file not shown.

After

Width:  |  Height:  |  Size: 99 KiB

684
frontend/index.html Normal file
View File

@@ -0,0 +1,684 @@
<!DOCTYPE html>
<html lang="en">
<head>
<meta charset="UTF-8">
<meta name="viewport" content="width=device-width, initial-scale=1.0">
<title>Project Tracker</title>
<link rel="icon" type="image/png" href="/favicon.png">
<style>
:root {
--bg: #0d1117;
--bg2: #161b22;
--bg3: #21262d;
--border: #30363d;
--text: #e6edf3;
--text2: #8b949e;
--accent: #58a6ff;
--green: #3fb950;
--yellow: #d29922;
--red: #f85149;
--purple: #a371f7;
}
* { box-sizing: border-box; margin: 0; padding: 0; }
body {
font-family: -apple-system, BlinkMacSystemFont, 'Segoe UI', Helvetica, Arial, sans-serif;
background: var(--bg);
color: var(--text);
min-height: 100vh;
}
/* HEADER */
header {
background: var(--bg2);
border-bottom: 1px solid var(--border);
padding: 1rem 1.5rem;
display: flex;
align-items: center;
justify-content: space-between;
position: sticky;
top: 0;
z-index: 100;
}
header h1 {
font-size: 1.25rem;
font-weight: 600;
color: var(--accent);
}
header button {
background: var(--accent);
color: var(--bg);
border: none;
padding: 0.5rem 1rem;
border-radius: 6px;
font-size: 0.875rem;
font-weight: 600;
cursor: pointer;
}
header button:hover { opacity: 0.85; }
/* FILTER BAR */
.filter-bar {
padding: 0.75rem 1.5rem;
display: flex;
gap: 0.5rem;
border-bottom: 1px solid var(--border);
background: var(--bg2);
flex-wrap: wrap;
align-items: center;
}
.filter-bar label {
font-size: 0.8rem;
color: var(--text2);
margin-right: 0.25rem;
}
.filter-btn {
background: var(--bg3);
color: var(--text2);
border: 1px solid var(--border);
padding: 0.3rem 0.75rem;
border-radius: 20px;
font-size: 0.8rem;
cursor: pointer;
}
.filter-btn.active {
background: var(--accent);
color: var(--bg);
border-color: var(--accent);
}
.filter-btn:hover:not(.active) { border-color: var(--text2); }
/* PROJECT LIST */
.project-list {
padding: 1rem 1.5rem;
display: flex;
flex-direction: column;
gap: 0.5rem;
}
.project-card {
background: var(--bg2);
border: 1px solid var(--border);
border-radius: 8px;
padding: 0.875rem 1rem;
cursor: pointer;
display: grid;
grid-template-columns: 1fr auto auto;
gap: 1rem;
align-items: center;
transition: border-color 0.15s, background 0.15s;
}
.project-card:hover {
border-color: var(--accent);
background: var(--bg3);
}
.project-name {
font-weight: 500;
font-size: 0.95rem;
}
.project-url {
font-size: 0.75rem;
color: var(--text2);
margin-top: 0.2rem;
overflow: hidden;
text-overflow: ellipsis;
white-space: nowrap;
max-width: 300px;
}
.priority-badge {
font-size: 0.7rem;
font-weight: 600;
padding: 0.2rem 0.6rem;
border-radius: 12px;
text-transform: uppercase;
letter-spacing: 0.03em;
white-space: nowrap;
}
.priority-High { background: #f851491a; color: var(--red); border: 1px solid #f8514930; }
.priority-Med-High { background: #d299221a; color: var(--yellow); border: 1px solid #d2992230; }
.priority-Medium { background: #a371f71a; color: var(--purple); border: 1px solid #a371f730; }
.priority-Low { background: #3fb9501a; color: var(--green); border: 1px solid #3fb95030; }
.status-badge {
font-size: 0.7rem;
padding: 0.2rem 0.6rem;
border-radius: 12px;
background: var(--bg3);
color: var(--text2);
border: 1px solid var(--border);
white-space: nowrap;
}
.status-Active { color: var(--green); border-color: #3fb95030; background: #3fb9501a; }
.status-Backlog { color: var(--text2); }
.status-On%20Hold { color: var(--yellow); border-color: #d2992230; background: #d299221a; }
.status-Completed { color: var(--accent); border-color: #58a6ff30; background: #58a6ff1a; }
/* DETAIL PANEL (overlay) */
.overlay {
display: none;
position: fixed;
inset: 0;
background: rgba(0,0,0,0.6);
z-index: 200;
backdrop-filter: blur(2px);
}
.overlay.open { display: block; }
.detail-panel {
position: fixed;
top: 0;
right: 0;
bottom: 0;
width: 480px;
max-width: 100vw;
background: var(--bg2);
border-left: 1px solid var(--border);
z-index: 201;
overflow-y: auto;
transform: translateX(100%);
transition: transform 0.25s ease;
}
.detail-panel.open { transform: translateX(0); }
.panel-header {
padding: 1rem 1.25rem;
border-bottom: 1px solid var(--border);
display: flex;
justify-content: space-between;
align-items: center;
position: sticky;
top: 0;
background: var(--bg2);
z-index: 1;
}
.panel-header h2 {
font-size: 1rem;
font-weight: 600;
}
.panel-header .close-btn {
background: none;
border: none;
color: var(--text2);
font-size: 1.5rem;
cursor: pointer;
line-height: 1;
padding: 0.25rem;
}
.panel-header .close-btn:hover { color: var(--text); }
.panel-body {
padding: 1.25rem;
display: flex;
flex-direction: column;
gap: 1.25rem;
}
.field { display: flex; flex-direction: column; gap: 0.35rem; }
.field label {
font-size: 0.75rem;
font-weight: 600;
color: var(--text2);
text-transform: uppercase;
letter-spacing: 0.05em;
}
.field .value {
font-size: 0.9rem;
color: var(--text);
word-break: break-all;
}
.field .value.empty { color: var(--text2); font-style: italic; }
.field .value a {
color: var(--accent);
text-decoration: none;
}
.field .value a:hover { text-decoration: underline; }
.field input, .field textarea, .field select {
background: var(--bg);
border: 1px solid var(--border);
border-radius: 6px;
color: var(--text);
font-size: 0.875rem;
padding: 0.5rem 0.75rem;
width: 100%;
font-family: inherit;
}
.field input:focus, .field textarea:focus, .field select:focus {
outline: none;
border-color: var(--accent);
}
.field textarea { resize: vertical; min-height: 80px; }
.panel-actions {
display: flex;
gap: 0.5rem;
padding: 1rem 1.25rem;
border-top: 1px solid var(--border);
position: sticky;
bottom: 0;
background: var(--bg2);
}
.btn {
padding: 0.5rem 1rem;
border-radius: 6px;
font-size: 0.875rem;
font-weight: 500;
cursor: pointer;
border: none;
}
.btn-primary { background: var(--accent); color: var(--bg); }
.btn-danger { background: #f851491a; color: var(--red); border: 1px solid #f8514930; }
.btn-secondary { background: var(--bg3); color: var(--text); border: 1px solid var(--border); }
.btn:hover { opacity: 0.85; }
/* NEW PROJECT MODAL */
.modal-overlay {
display: none;
position: fixed;
inset: 0;
background: rgba(0,0,0,0.6);
z-index: 300;
backdrop-filter: blur(2px);
align-items: center;
justify-content: center;
}
.modal-overlay.open { display: flex; }
.modal {
background: var(--bg2);
border: 1px solid var(--border);
border-radius: 12px;
width: 480px;
max-width: 95vw;
max-height: 90vh;
overflow-y: auto;
}
.modal-header {
padding: 1rem 1.25rem;
border-bottom: 1px solid var(--border);
display: flex;
justify-content: space-between;
align-items: center;
}
.modal-header h2 { font-size: 1rem; font-weight: 600; }
.modal-header .close-btn {
background: none;
border: none;
color: var(--text2);
font-size: 1.5rem;
cursor: pointer;
line-height: 1;
}
.modal-body { padding: 1.25rem; display: flex; flex-direction: column; gap: 1rem; }
.modal-footer { padding: 1rem 1.25rem; border-top: 1px solid var(--border); display: flex; gap: 0.5rem; justify-content: flex-end; }
/* REQUIRED ASTERISK */
.required::after { content: ' *'; color: var(--red); }
/* TAGS */
.tag {
display: inline-block;
background: var(--bg3);
color: var(--text2);
font-size: 0.7rem;
padding: 0.15rem 0.5rem;
border-radius: 10px;
border: 1px solid var(--border);
margin: 0.1rem;
}
/* EMPTY STATE */
.empty-state {
text-align: center;
padding: 3rem 1rem;
color: var(--text2);
}
.empty-state p { margin-top: 0.5rem; font-size: 0.9rem; }
/* META ROW */
.meta-row {
display: flex;
gap: 1rem;
}
.meta-row .field { flex: 1; }
/* LOADING */
.loading {
text-align: center;
padding: 2rem;
color: var(--text2);
}
</style>
</head>
<body>
<header>
<h1>Project Tracker</h1>
<button onclick="openNewModal()">+ New Project</button>
</header>
<div class="filter-bar">
<label>Status:</label>
<button class="filter-btn active" data-filter="all" onclick="setFilter('all')">All</button>
<button class="filter-btn" data-filter="Active" onclick="setFilter('Active')">Active</button>
<button class="filter-btn" data-filter="Backlog" onclick="setFilter('Backlog')">Backlog</button>
<button class="filter-btn" data-filter="On Hold" onclick="setFilter('On Hold')">On Hold</button>
<button class="filter-btn" data-filter="Completed" onclick="setFilter('Completed')">Completed</button>
</div>
<div id="project-list" class="project-list">
<div class="loading">Loading...</div>
</div>
<!-- DETAIL PANEL -->
<div class="overlay" id="detail-overlay" onclick="closeDetail()"></div>
<div class="detail-panel" id="detail-panel">
<div class="panel-header">
<h2 id="panel-title">Project</h2>
<button class="close-btn" onclick="closeDetail()">&times;</button>
</div>
<div class="panel-body" id="panel-body">
<!-- fields injected by JS -->
</div>
<div class="panel-actions" id="panel-actions">
<button class="btn btn-danger" onclick="deleteProject()">Delete</button>
<button class="btn btn-primary" onclick="saveProject()">Save Changes</button>
</div>
</div>
<!-- NEW PROJECT MODAL -->
<div class="modal-overlay" id="new-modal">
<div class="modal">
<div class="modal-header">
<h2>New Project</h2>
<button class="close-btn" onclick="closeNewModal()">&times;</button>
</div>
<div class="modal-body">
<div class="field">
<label class="required">Name</label>
<input type="text" id="new-name" placeholder="Project name">
</div>
<div class="field">
<label class="required">Priority</label>
<select id="new-priority">
<option value="">Select priority...</option>
<option value="High">High</option>
<option value="Med-High">Med-High</option>
<option value="Medium">Medium</option>
<option value="Low">Low</option>
</select>
</div>
<div class="field">
<label>URL</label>
<input type="url" id="new-url" placeholder="https://...">
</div>
<div class="field">
<label>Notes</label>
<textarea id="new-notes" placeholder="Description, links, ideas..."></textarea>
</div>
<div class="meta-row">
<div class="field">
<label>Status</label>
<select id="new-status">
<option value="Backlog">Backlog</option>
<option value="Active">Active</option>
<option value="On Hold">On Hold</option>
<option value="Completed">Completed</option>
</select>
</div>
<div class="field">
<label>Owner</label>
<input type="text" id="new-owner" placeholder="Ada">
</div>
</div>
<div class="field">
<label>Tags (comma-separated)</label>
<input type="text" id="new-tags" placeholder="homelab, automation, ... ">
</div>
</div>
<div class="modal-footer">
<button class="btn btn-secondary" onclick="closeNewModal()">Cancel</button>
<button class="btn btn-primary" onclick="createProject()">Create Project</button>
</div>
</div>
</div>
<script>
const API = 'http://10.10.11.103/api';
let projects = [];
let currentFilter = 'all';
let editingId = null;
async function loadProjects() {
const el = document.getElementById('project-list');
el.innerHTML = '<div class="loading">Loading...</div>';
try {
const res = await fetch(API + '/projects');
projects = await res.json();
render();
} catch (e) {
el.innerHTML = '<div class="empty-state"><strong>Cannot connect to API</strong><p>Is the backend running on 10.10.11.103:3000?</p></div>';
}
}
function setFilter(f) {
currentFilter = f;
document.querySelectorAll('.filter-btn').forEach(b => b.classList.toggle('active', b.dataset.filter === f));
render();
}
function render() {
const el = document.getElementById('project-list');
const filtered = currentFilter === 'all' ? projects : projects.filter(p => p.status === currentFilter);
if (filtered.length === 0) {
el.innerHTML = `<div class="empty-state"><strong>No projects</strong><p>${currentFilter === 'all' ? 'Add your first project above.' : 'No projects with status "' + currentFilter + '".'}</p></div>`;
return;
}
el.innerHTML = filtered.map(p => `
<div class="project-card" onclick="openDetail(${p.id})">
<div>
<div class="project-name">${esc(p.name)}</div>
${p.url ? `<div class="project-url">${esc(p.url)}</div>` : ''}
</div>
<span class="priority-badge priority-${esc(p.priority)}">${esc(p.priority)}</span>
<span class="status-badge status-${encodeURIComponent(p.status)}">${esc(p.status)}</span>
</div>
`).join('');
}
function esc(s) {
if (!s) return '';
return String(s).replace(/&/g,'&amp;').replace(/</g,'&lt;').replace(/>/g,'&gt;').replace(/"/g,'&quot;');
}
function getTagsHtml(tags) {
if (!tags) return '';
return tags.split(',').map(t => `<span class="tag">${esc(t.trim())}</span>`).join('');
}
// DETAIL PANEL
async function openDetail(id) {
editingId = id;
const p = projects.find(x => x.id === id);
if (!p) return;
document.getElementById('panel-title').textContent = p.name;
document.getElementById('panel-body').innerHTML = `
<div class="field">
<label class="required">Name</label>
<input type="text" id="edit-name" value="${esc(p.name)}">
</div>
<div class="meta-row">
<div class="field">
<label class="required">Priority</label>
<select id="edit-priority">
${['High','Med-High','Medium','Low'].map(x => `<option value="${x}" ${p.priority===x?'selected':''}>${x}</option>`).join('')}
</select>
</div>
<div class="field">
<label>Status</label>
<select id="edit-status">
${['Active','Backlog','On Hold','Completed'].map(x => `<option value="${x}" ${p.status===x?'selected':''}>${x}</option>`).join('')}
</select>
</div>
</div>
<div class="field">
<label>URL</label>
<input type="url" id="edit-url" value="${esc(p.url || '')}" placeholder="https://...">
</div>
<div class="field">
<label>Notes</label>
<textarea id="edit-notes">${esc(p.notes || '')}</textarea>
</div>
<div class="meta-row">
<div class="field">
<label>Owner</label>
<input type="text" id="edit-owner" value="${esc(p.owner || 'Ada')}">
</div>
<div class="field">
<label>Tags</label>
<input type="text" id="edit-tags" value="${esc(p.tags || '')}" placeholder="comma-separated">
</div>
</div>
<div class="field">
<label>Created</label>
<div class="value">${p.created_at ? new Date(p.created_at).toLocaleString() : '—'}</div>
</div>
<div class="field">
<label>Tags</label>
<div class="value">${p.tags ? getTagsHtml(p.tags) : '<span class="empty">none</span>'}</div>
</div>
`;
document.getElementById('detail-overlay').classList.add('open');
document.getElementById('detail-panel').classList.add('open');
}
function closeDetail() {
document.getElementById('detail-overlay').classList.remove('open');
document.getElementById('detail-panel').classList.remove('open');
editingId = null;
}
async function saveProject() {
const name = document.getElementById('edit-name').value.trim();
const priority = document.getElementById('edit-priority').value;
if (!name || !priority) { alert('Name and Priority are required.'); return; }
const body = {
name,
priority,
url: document.getElementById('edit-url').value.trim(),
notes: document.getElementById('edit-notes').value.trim(),
status: document.getElementById('edit-status').value,
owner: document.getElementById('edit-owner').value.trim() || 'Ada',
tags: document.getElementById('edit-tags').value.trim()
};
try {
await fetch(API + '/projects/' + editingId, {
method: 'PUT',
headers: { 'Content-Type': 'application/json' },
body: JSON.stringify(body)
});
closeDetail();
loadProjects();
} catch (e) { alert('Error saving: ' + e.message); }
}
async function deleteProject() {
if (!confirm('Delete this project?')) return;
try {
await fetch(API + '/projects/' + editingId, { method: 'DELETE' });
closeDetail();
loadProjects();
} catch (e) { alert('Error deleting: ' + e.message); }
}
// NEW PROJECT MODAL
function openNewModal() {
document.getElementById('new-name').value = '';
document.getElementById('new-priority').value = '';
document.getElementById('new-url').value = '';
document.getElementById('new-notes').value = '';
document.getElementById('new-status').value = 'Backlog';
document.getElementById('new-owner').value = 'Ada';
document.getElementById('new-tags').value = '';
document.getElementById('new-modal').classList.add('open');
}
function closeNewModal() {
document.getElementById('new-modal').classList.remove('open');
}
async function createProject() {
const name = document.getElementById('new-name').value.trim();
const priority = document.getElementById('new-priority').value;
if (!name || !priority) { alert('Name and Priority are required.'); return; }
const body = {
name,
priority,
url: document.getElementById('new-url').value.trim(),
notes: document.getElementById('new-notes').value.trim(),
status: document.getElementById('new-status').value,
owner: document.getElementById('new-owner').value.trim() || 'Ada',
tags: document.getElementById('new-tags').value.trim()
};
try {
await fetch(API + '/projects', {
method: 'POST',
headers: { 'Content-Type': 'application/json' },
body: JSON.stringify(body)
});
closeNewModal();
loadProjects();
} catch (e) { alert('Error creating: ' + e.message); }
}
// Close modals on ESC
document.addEventListener('keydown', e => {
if (e.key === 'Escape') { closeDetail(); closeNewModal(); }
});
loadProjects();
</script>
</body>
</html>

22
nginx.conf Normal file
View File

@@ -0,0 +1,22 @@
server {
listen 80;
server_name _;
# Serve frontend static files
root /opt/project-tracker/frontend;
index index.html;
# API proxy to Node backend
location /api/ {
proxy_pass http://127.0.0.1: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;
}
# Static files
location / {
try_files $uri $uri/ /index.html;
}
}