Initial commit: vainplex TaskBoard
- Express backend with SQLite - Session-based authentication - Drag-and-drop Kanban board - API key auth for service integration - Dark vainplex theme
This commit is contained in:
commit
f158a3d09c
5 changed files with 1275 additions and 0 deletions
18
Dockerfile
Normal file
18
Dockerfile
Normal file
|
|
@ -0,0 +1,18 @@
|
||||||
|
FROM node:20-alpine
|
||||||
|
|
||||||
|
WORKDIR /app
|
||||||
|
|
||||||
|
# Install build dependencies for better-sqlite3
|
||||||
|
RUN apk add --no-cache python3 make g++
|
||||||
|
|
||||||
|
COPY package*.json ./
|
||||||
|
RUN npm install --production
|
||||||
|
|
||||||
|
COPY . .
|
||||||
|
|
||||||
|
# Create data directory
|
||||||
|
RUN mkdir -p /data
|
||||||
|
|
||||||
|
EXPOSE 3000
|
||||||
|
|
||||||
|
CMD ["node", "server.js"]
|
||||||
15
docker-compose.yml
Normal file
15
docker-compose.yml
Normal file
|
|
@ -0,0 +1,15 @@
|
||||||
|
services:
|
||||||
|
taskboard:
|
||||||
|
build: .
|
||||||
|
container_name: taskboard
|
||||||
|
restart: unless-stopped
|
||||||
|
ports:
|
||||||
|
- "3003:3000"
|
||||||
|
volumes:
|
||||||
|
- taskboard-data:/data
|
||||||
|
environment:
|
||||||
|
- NODE_ENV=production
|
||||||
|
- WEBHOOK_URL=${WEBHOOK_URL:-}
|
||||||
|
|
||||||
|
volumes:
|
||||||
|
taskboard-data:
|
||||||
16
package.json
Normal file
16
package.json
Normal file
|
|
@ -0,0 +1,16 @@
|
||||||
|
{
|
||||||
|
"name": "vainplex-taskboard",
|
||||||
|
"version": "1.0.0",
|
||||||
|
"description": "Kanban task board for Claudia",
|
||||||
|
"main": "server.js",
|
||||||
|
"scripts": {
|
||||||
|
"start": "node server.js",
|
||||||
|
"dev": "node server.js"
|
||||||
|
},
|
||||||
|
"dependencies": {
|
||||||
|
"express": "^4.18.2",
|
||||||
|
"better-sqlite3": "^9.4.3",
|
||||||
|
"cors": "^2.8.5",
|
||||||
|
"uuid": "^9.0.0"
|
||||||
|
}
|
||||||
|
}
|
||||||
725
public/index.html
Normal file
725
public/index.html
Normal file
|
|
@ -0,0 +1,725 @@
|
||||||
|
<!DOCTYPE html>
|
||||||
|
<html lang="en">
|
||||||
|
<head>
|
||||||
|
<meta charset="UTF-8">
|
||||||
|
<meta name="viewport" content="width=device-width, initial-scale=1.0">
|
||||||
|
<title>vainplex TaskBoard</title>
|
||||||
|
<link href="https://fonts.googleapis.com/css2?family=Inter:wght@400;500;600;700&display=swap" rel="stylesheet">
|
||||||
|
<style>
|
||||||
|
* {
|
||||||
|
margin: 0;
|
||||||
|
padding: 0;
|
||||||
|
box-sizing: border-box;
|
||||||
|
}
|
||||||
|
|
||||||
|
:root {
|
||||||
|
--bg-primary: #0a0a0f;
|
||||||
|
--bg-secondary: #12121a;
|
||||||
|
--bg-card: #1a1a24;
|
||||||
|
--bg-hover: #242430;
|
||||||
|
--border: #2a2a3a;
|
||||||
|
--text-primary: #ffffff;
|
||||||
|
--text-secondary: #a0a0b0;
|
||||||
|
--accent: #6366f1;
|
||||||
|
--accent-hover: #818cf8;
|
||||||
|
--success: #10b981;
|
||||||
|
--warning: #f59e0b;
|
||||||
|
--danger: #ef4444;
|
||||||
|
}
|
||||||
|
|
||||||
|
body {
|
||||||
|
font-family: 'Inter', -apple-system, sans-serif;
|
||||||
|
background: var(--bg-primary);
|
||||||
|
color: var(--text-primary);
|
||||||
|
min-height: 100vh;
|
||||||
|
overflow-x: auto;
|
||||||
|
}
|
||||||
|
|
||||||
|
.header {
|
||||||
|
background: var(--bg-secondary);
|
||||||
|
border-bottom: 1px solid var(--border);
|
||||||
|
padding: 1rem 2rem;
|
||||||
|
display: flex;
|
||||||
|
justify-content: space-between;
|
||||||
|
align-items: center;
|
||||||
|
position: sticky;
|
||||||
|
top: 0;
|
||||||
|
z-index: 100;
|
||||||
|
}
|
||||||
|
|
||||||
|
.logo {
|
||||||
|
display: flex;
|
||||||
|
align-items: center;
|
||||||
|
gap: 0.75rem;
|
||||||
|
}
|
||||||
|
|
||||||
|
.logo-icon {
|
||||||
|
width: 36px;
|
||||||
|
height: 36px;
|
||||||
|
background: linear-gradient(135deg, var(--accent), #8b5cf6);
|
||||||
|
border-radius: 8px;
|
||||||
|
display: flex;
|
||||||
|
align-items: center;
|
||||||
|
justify-content: center;
|
||||||
|
font-size: 1.25rem;
|
||||||
|
}
|
||||||
|
|
||||||
|
.logo-text {
|
||||||
|
font-size: 1.25rem;
|
||||||
|
font-weight: 700;
|
||||||
|
background: linear-gradient(135deg, var(--accent), #8b5cf6);
|
||||||
|
-webkit-background-clip: text;
|
||||||
|
-webkit-text-fill-color: transparent;
|
||||||
|
}
|
||||||
|
|
||||||
|
.board {
|
||||||
|
display: flex;
|
||||||
|
gap: 1.5rem;
|
||||||
|
padding: 2rem;
|
||||||
|
min-height: calc(100vh - 70px);
|
||||||
|
overflow-x: auto;
|
||||||
|
}
|
||||||
|
|
||||||
|
.column {
|
||||||
|
flex: 0 0 320px;
|
||||||
|
background: var(--bg-secondary);
|
||||||
|
border-radius: 12px;
|
||||||
|
border: 1px solid var(--border);
|
||||||
|
display: flex;
|
||||||
|
flex-direction: column;
|
||||||
|
max-height: calc(100vh - 110px);
|
||||||
|
}
|
||||||
|
|
||||||
|
.column.trigger {
|
||||||
|
border-color: var(--accent);
|
||||||
|
box-shadow: 0 0 20px rgba(99, 102, 241, 0.1);
|
||||||
|
}
|
||||||
|
|
||||||
|
.column-header {
|
||||||
|
padding: 1rem 1.25rem;
|
||||||
|
border-bottom: 1px solid var(--border);
|
||||||
|
display: flex;
|
||||||
|
justify-content: space-between;
|
||||||
|
align-items: center;
|
||||||
|
}
|
||||||
|
|
||||||
|
.column-title {
|
||||||
|
font-weight: 600;
|
||||||
|
font-size: 0.95rem;
|
||||||
|
}
|
||||||
|
|
||||||
|
.column-count {
|
||||||
|
background: var(--bg-hover);
|
||||||
|
padding: 0.25rem 0.6rem;
|
||||||
|
border-radius: 99px;
|
||||||
|
font-size: 0.8rem;
|
||||||
|
color: var(--text-secondary);
|
||||||
|
}
|
||||||
|
|
||||||
|
.column-tasks {
|
||||||
|
flex: 1;
|
||||||
|
padding: 0.75rem;
|
||||||
|
overflow-y: auto;
|
||||||
|
min-height: 200px;
|
||||||
|
}
|
||||||
|
|
||||||
|
.column-tasks.drag-over {
|
||||||
|
background: rgba(99, 102, 241, 0.05);
|
||||||
|
}
|
||||||
|
|
||||||
|
.task {
|
||||||
|
background: var(--bg-card);
|
||||||
|
border: 1px solid var(--border);
|
||||||
|
border-radius: 8px;
|
||||||
|
padding: 1rem;
|
||||||
|
margin-bottom: 0.75rem;
|
||||||
|
cursor: grab;
|
||||||
|
transition: all 0.15s ease;
|
||||||
|
}
|
||||||
|
|
||||||
|
.task:hover {
|
||||||
|
border-color: var(--accent);
|
||||||
|
transform: translateY(-1px);
|
||||||
|
}
|
||||||
|
|
||||||
|
.task.dragging {
|
||||||
|
opacity: 0.5;
|
||||||
|
cursor: grabbing;
|
||||||
|
}
|
||||||
|
|
||||||
|
.task-title {
|
||||||
|
font-weight: 500;
|
||||||
|
margin-bottom: 0.5rem;
|
||||||
|
word-break: break-word;
|
||||||
|
}
|
||||||
|
|
||||||
|
.task-description {
|
||||||
|
font-size: 0.85rem;
|
||||||
|
color: var(--text-secondary);
|
||||||
|
margin-bottom: 0.75rem;
|
||||||
|
word-break: break-word;
|
||||||
|
}
|
||||||
|
|
||||||
|
.task-meta {
|
||||||
|
display: flex;
|
||||||
|
justify-content: space-between;
|
||||||
|
align-items: center;
|
||||||
|
}
|
||||||
|
|
||||||
|
.task-priority {
|
||||||
|
font-size: 0.7rem;
|
||||||
|
padding: 0.2rem 0.5rem;
|
||||||
|
border-radius: 4px;
|
||||||
|
font-weight: 500;
|
||||||
|
text-transform: uppercase;
|
||||||
|
}
|
||||||
|
|
||||||
|
.task-priority.high {
|
||||||
|
background: rgba(239, 68, 68, 0.15);
|
||||||
|
color: var(--danger);
|
||||||
|
}
|
||||||
|
|
||||||
|
.task-priority.medium {
|
||||||
|
background: rgba(245, 158, 11, 0.15);
|
||||||
|
color: var(--warning);
|
||||||
|
}
|
||||||
|
|
||||||
|
.task-priority.low {
|
||||||
|
background: rgba(16, 185, 129, 0.15);
|
||||||
|
color: var(--success);
|
||||||
|
}
|
||||||
|
|
||||||
|
.task-actions {
|
||||||
|
display: flex;
|
||||||
|
gap: 0.5rem;
|
||||||
|
opacity: 0;
|
||||||
|
transition: opacity 0.15s ease;
|
||||||
|
}
|
||||||
|
|
||||||
|
.task:hover .task-actions {
|
||||||
|
opacity: 1;
|
||||||
|
}
|
||||||
|
|
||||||
|
.task-btn {
|
||||||
|
background: none;
|
||||||
|
border: none;
|
||||||
|
color: var(--text-secondary);
|
||||||
|
cursor: pointer;
|
||||||
|
padding: 0.25rem;
|
||||||
|
font-size: 0.9rem;
|
||||||
|
transition: color 0.15s ease;
|
||||||
|
}
|
||||||
|
|
||||||
|
.task-btn:hover {
|
||||||
|
color: var(--text-primary);
|
||||||
|
}
|
||||||
|
|
||||||
|
.task-btn.delete:hover {
|
||||||
|
color: var(--danger);
|
||||||
|
}
|
||||||
|
|
||||||
|
.add-task-btn {
|
||||||
|
width: 100%;
|
||||||
|
padding: 0.75rem;
|
||||||
|
background: transparent;
|
||||||
|
border: 1px dashed var(--border);
|
||||||
|
border-radius: 8px;
|
||||||
|
color: var(--text-secondary);
|
||||||
|
cursor: pointer;
|
||||||
|
font-size: 0.9rem;
|
||||||
|
transition: all 0.15s ease;
|
||||||
|
margin-top: auto;
|
||||||
|
}
|
||||||
|
|
||||||
|
.add-task-btn:hover {
|
||||||
|
border-color: var(--accent);
|
||||||
|
color: var(--accent);
|
||||||
|
}
|
||||||
|
|
||||||
|
.column-footer {
|
||||||
|
padding: 0.75rem;
|
||||||
|
border-top: 1px solid var(--border);
|
||||||
|
}
|
||||||
|
|
||||||
|
/* Modal */
|
||||||
|
.modal-overlay {
|
||||||
|
position: fixed;
|
||||||
|
inset: 0;
|
||||||
|
background: rgba(0, 0, 0, 0.7);
|
||||||
|
display: flex;
|
||||||
|
align-items: center;
|
||||||
|
justify-content: center;
|
||||||
|
z-index: 1000;
|
||||||
|
opacity: 0;
|
||||||
|
visibility: hidden;
|
||||||
|
transition: all 0.2s ease;
|
||||||
|
}
|
||||||
|
|
||||||
|
.modal-overlay.active {
|
||||||
|
opacity: 1;
|
||||||
|
visibility: visible;
|
||||||
|
}
|
||||||
|
|
||||||
|
.modal {
|
||||||
|
background: var(--bg-secondary);
|
||||||
|
border: 1px solid var(--border);
|
||||||
|
border-radius: 12px;
|
||||||
|
padding: 1.5rem;
|
||||||
|
width: 90%;
|
||||||
|
max-width: 480px;
|
||||||
|
transform: scale(0.95);
|
||||||
|
transition: transform 0.2s ease;
|
||||||
|
}
|
||||||
|
|
||||||
|
.modal-overlay.active .modal {
|
||||||
|
transform: scale(1);
|
||||||
|
}
|
||||||
|
|
||||||
|
.modal-title {
|
||||||
|
font-size: 1.25rem;
|
||||||
|
font-weight: 600;
|
||||||
|
margin-bottom: 1.5rem;
|
||||||
|
}
|
||||||
|
|
||||||
|
.form-group {
|
||||||
|
margin-bottom: 1rem;
|
||||||
|
}
|
||||||
|
|
||||||
|
.form-label {
|
||||||
|
display: block;
|
||||||
|
font-size: 0.85rem;
|
||||||
|
color: var(--text-secondary);
|
||||||
|
margin-bottom: 0.5rem;
|
||||||
|
}
|
||||||
|
|
||||||
|
.form-input, .form-textarea, .form-select {
|
||||||
|
width: 100%;
|
||||||
|
padding: 0.75rem 1rem;
|
||||||
|
background: var(--bg-card);
|
||||||
|
border: 1px solid var(--border);
|
||||||
|
border-radius: 8px;
|
||||||
|
color: var(--text-primary);
|
||||||
|
font-family: inherit;
|
||||||
|
font-size: 0.95rem;
|
||||||
|
transition: border-color 0.15s ease;
|
||||||
|
}
|
||||||
|
|
||||||
|
.form-input:focus, .form-textarea:focus, .form-select:focus {
|
||||||
|
outline: none;
|
||||||
|
border-color: var(--accent);
|
||||||
|
}
|
||||||
|
|
||||||
|
.form-textarea {
|
||||||
|
resize: vertical;
|
||||||
|
min-height: 100px;
|
||||||
|
}
|
||||||
|
|
||||||
|
.modal-actions {
|
||||||
|
display: flex;
|
||||||
|
justify-content: flex-end;
|
||||||
|
gap: 0.75rem;
|
||||||
|
margin-top: 1.5rem;
|
||||||
|
}
|
||||||
|
|
||||||
|
.btn {
|
||||||
|
padding: 0.6rem 1.25rem;
|
||||||
|
border-radius: 8px;
|
||||||
|
font-weight: 500;
|
||||||
|
font-size: 0.9rem;
|
||||||
|
cursor: pointer;
|
||||||
|
transition: all 0.15s ease;
|
||||||
|
border: none;
|
||||||
|
}
|
||||||
|
|
||||||
|
.btn-secondary {
|
||||||
|
background: var(--bg-card);
|
||||||
|
color: var(--text-primary);
|
||||||
|
border: 1px solid var(--border);
|
||||||
|
}
|
||||||
|
|
||||||
|
.btn-secondary:hover {
|
||||||
|
background: var(--bg-hover);
|
||||||
|
}
|
||||||
|
|
||||||
|
.btn-primary {
|
||||||
|
background: var(--accent);
|
||||||
|
color: white;
|
||||||
|
}
|
||||||
|
|
||||||
|
.btn-primary:hover {
|
||||||
|
background: var(--accent-hover);
|
||||||
|
}
|
||||||
|
|
||||||
|
/* Quick add */
|
||||||
|
.quick-add {
|
||||||
|
display: none;
|
||||||
|
flex-direction: column;
|
||||||
|
gap: 0.5rem;
|
||||||
|
}
|
||||||
|
|
||||||
|
.quick-add.active {
|
||||||
|
display: flex;
|
||||||
|
}
|
||||||
|
|
||||||
|
.quick-add input {
|
||||||
|
padding: 0.6rem 0.75rem;
|
||||||
|
background: var(--bg-card);
|
||||||
|
border: 1px solid var(--border);
|
||||||
|
border-radius: 6px;
|
||||||
|
color: var(--text-primary);
|
||||||
|
font-size: 0.9rem;
|
||||||
|
}
|
||||||
|
|
||||||
|
.quick-add input:focus {
|
||||||
|
outline: none;
|
||||||
|
border-color: var(--accent);
|
||||||
|
}
|
||||||
|
|
||||||
|
.quick-add-actions {
|
||||||
|
display: flex;
|
||||||
|
gap: 0.5rem;
|
||||||
|
}
|
||||||
|
|
||||||
|
.quick-add-actions button {
|
||||||
|
flex: 1;
|
||||||
|
padding: 0.5rem;
|
||||||
|
border-radius: 6px;
|
||||||
|
font-size: 0.85rem;
|
||||||
|
cursor: pointer;
|
||||||
|
border: none;
|
||||||
|
}
|
||||||
|
|
||||||
|
.quick-add-actions .save {
|
||||||
|
background: var(--accent);
|
||||||
|
color: white;
|
||||||
|
}
|
||||||
|
|
||||||
|
.quick-add-actions .cancel {
|
||||||
|
background: var(--bg-card);
|
||||||
|
color: var(--text-secondary);
|
||||||
|
border: 1px solid var(--border);
|
||||||
|
}
|
||||||
|
|
||||||
|
/* Empty state */
|
||||||
|
.empty-state {
|
||||||
|
text-align: center;
|
||||||
|
padding: 2rem 1rem;
|
||||||
|
color: var(--text-secondary);
|
||||||
|
font-size: 0.9rem;
|
||||||
|
}
|
||||||
|
</style>
|
||||||
|
</head>
|
||||||
|
<body>
|
||||||
|
<header class="header">
|
||||||
|
<div class="logo">
|
||||||
|
<div class="logo-icon">📋</div>
|
||||||
|
<span class="logo-text">vainplex TaskBoard</span>
|
||||||
|
</div>
|
||||||
|
<div style="display: flex; align-items: center; gap: 1rem;">
|
||||||
|
<button class="btn btn-primary" onclick="openModal()">+ New Task</button>
|
||||||
|
<div class="user-menu">
|
||||||
|
<span id="username" style="color: var(--text-secondary); font-size: 0.9rem;"></span>
|
||||||
|
<a href="/logout" class="btn btn-secondary" style="margin-left: 0.5rem; padding: 0.4rem 0.8rem; font-size: 0.85rem; text-decoration: none;">Logout</a>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
</header>
|
||||||
|
|
||||||
|
<div class="board" id="board"></div>
|
||||||
|
|
||||||
|
<div class="modal-overlay" id="modal">
|
||||||
|
<div class="modal">
|
||||||
|
<h2 class="modal-title" id="modalTitle">New Task</h2>
|
||||||
|
<form id="taskForm" onsubmit="saveTask(event)">
|
||||||
|
<input type="hidden" id="taskId">
|
||||||
|
<div class="form-group">
|
||||||
|
<label class="form-label">Title *</label>
|
||||||
|
<input type="text" class="form-input" id="taskTitle" required placeholder="What needs to be done?">
|
||||||
|
</div>
|
||||||
|
<div class="form-group">
|
||||||
|
<label class="form-label">Description</label>
|
||||||
|
<textarea class="form-textarea" id="taskDescription" placeholder="Add more details..."></textarea>
|
||||||
|
</div>
|
||||||
|
<div class="form-group">
|
||||||
|
<label class="form-label">Priority</label>
|
||||||
|
<select class="form-select" id="taskPriority">
|
||||||
|
<option value="low">🟢 Low</option>
|
||||||
|
<option value="medium" selected>🟡 Medium</option>
|
||||||
|
<option value="high">🔴 High</option>
|
||||||
|
</select>
|
||||||
|
</div>
|
||||||
|
<div class="form-group">
|
||||||
|
<label class="form-label">Column</label>
|
||||||
|
<select class="form-select" id="taskColumn"></select>
|
||||||
|
</div>
|
||||||
|
<div class="modal-actions">
|
||||||
|
<button type="button" class="btn btn-secondary" onclick="closeModal()">Cancel</button>
|
||||||
|
<button type="submit" class="btn btn-primary">Save Task</button>
|
||||||
|
</div>
|
||||||
|
</form>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
|
||||||
|
<script>
|
||||||
|
let board = [];
|
||||||
|
let draggedTask = null;
|
||||||
|
|
||||||
|
async function loadBoard() {
|
||||||
|
const res = await fetch('/api/board');
|
||||||
|
board = await res.json();
|
||||||
|
renderBoard();
|
||||||
|
populateColumnSelect();
|
||||||
|
}
|
||||||
|
|
||||||
|
function renderBoard() {
|
||||||
|
const boardEl = document.getElementById('board');
|
||||||
|
boardEl.innerHTML = board.map(column => `
|
||||||
|
<div class="column ${column.is_trigger ? 'trigger' : ''}" data-column-id="${column.id}">
|
||||||
|
<div class="column-header">
|
||||||
|
<span class="column-title">${column.name}</span>
|
||||||
|
<span class="column-count">${column.tasks.length}</span>
|
||||||
|
</div>
|
||||||
|
<div class="column-tasks"
|
||||||
|
ondragover="handleDragOver(event)"
|
||||||
|
ondragleave="handleDragLeave(event)"
|
||||||
|
ondrop="handleDrop(event, '${column.id}')">
|
||||||
|
${column.tasks.length === 0 ?
|
||||||
|
'<div class="empty-state">Drop tasks here</div>' :
|
||||||
|
column.tasks.map(task => renderTask(task)).join('')}
|
||||||
|
</div>
|
||||||
|
<div class="column-footer">
|
||||||
|
<div class="quick-add" id="quick-${column.id}">
|
||||||
|
<input type="text" placeholder="Task title..." onkeydown="handleQuickAddKey(event, '${column.id}')">
|
||||||
|
<div class="quick-add-actions">
|
||||||
|
<button class="cancel" onclick="toggleQuickAdd('${column.id}')">Cancel</button>
|
||||||
|
<button class="save" onclick="quickAddTask('${column.id}')">Add</button>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
<button class="add-task-btn" id="btn-${column.id}" onclick="toggleQuickAdd('${column.id}')">+ Add task</button>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
`).join('');
|
||||||
|
}
|
||||||
|
|
||||||
|
function renderTask(task) {
|
||||||
|
return `
|
||||||
|
<div class="task" draggable="true"
|
||||||
|
data-task-id="${task.id}"
|
||||||
|
ondragstart="handleDragStart(event, '${task.id}')"
|
||||||
|
ondragend="handleDragEnd(event)">
|
||||||
|
<div class="task-title">${escapeHtml(task.title)}</div>
|
||||||
|
${task.description ? `<div class="task-description">${escapeHtml(task.description)}</div>` : ''}
|
||||||
|
<div class="task-meta">
|
||||||
|
<span class="task-priority ${task.priority}">${task.priority}</span>
|
||||||
|
<div class="task-actions">
|
||||||
|
<button class="task-btn" onclick="editTask('${task.id}')" title="Edit">✏️</button>
|
||||||
|
<button class="task-btn delete" onclick="deleteTask('${task.id}')" title="Delete">🗑️</button>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
`;
|
||||||
|
}
|
||||||
|
|
||||||
|
function escapeHtml(text) {
|
||||||
|
const div = document.createElement('div');
|
||||||
|
div.textContent = text;
|
||||||
|
return div.innerHTML;
|
||||||
|
}
|
||||||
|
|
||||||
|
function handleDragStart(e, taskId) {
|
||||||
|
draggedTask = taskId;
|
||||||
|
e.target.classList.add('dragging');
|
||||||
|
}
|
||||||
|
|
||||||
|
function handleDragEnd(e) {
|
||||||
|
e.target.classList.remove('dragging');
|
||||||
|
draggedTask = null;
|
||||||
|
}
|
||||||
|
|
||||||
|
function handleDragOver(e) {
|
||||||
|
e.preventDefault();
|
||||||
|
e.currentTarget.classList.add('drag-over');
|
||||||
|
}
|
||||||
|
|
||||||
|
function handleDragLeave(e) {
|
||||||
|
e.currentTarget.classList.remove('drag-over');
|
||||||
|
}
|
||||||
|
|
||||||
|
async function handleDrop(e, columnId) {
|
||||||
|
e.preventDefault();
|
||||||
|
e.currentTarget.classList.remove('drag-over');
|
||||||
|
|
||||||
|
if (!draggedTask) return;
|
||||||
|
|
||||||
|
const column = board.find(c => c.id === columnId);
|
||||||
|
const position = column.tasks.length;
|
||||||
|
|
||||||
|
await fetch(`/api/tasks/${draggedTask}/move`, {
|
||||||
|
method: 'PUT',
|
||||||
|
headers: { 'Content-Type': 'application/json' },
|
||||||
|
body: JSON.stringify({ column_id: columnId, position })
|
||||||
|
});
|
||||||
|
|
||||||
|
loadBoard();
|
||||||
|
}
|
||||||
|
|
||||||
|
function populateColumnSelect() {
|
||||||
|
const select = document.getElementById('taskColumn');
|
||||||
|
select.innerHTML = board.map(col =>
|
||||||
|
`<option value="${col.id}">${col.name}</option>`
|
||||||
|
).join('');
|
||||||
|
}
|
||||||
|
|
||||||
|
function openModal(taskId = null) {
|
||||||
|
const modal = document.getElementById('modal');
|
||||||
|
const title = document.getElementById('modalTitle');
|
||||||
|
const form = document.getElementById('taskForm');
|
||||||
|
|
||||||
|
if (taskId) {
|
||||||
|
title.textContent = 'Edit Task';
|
||||||
|
const task = findTask(taskId);
|
||||||
|
if (task) {
|
||||||
|
document.getElementById('taskId').value = task.id;
|
||||||
|
document.getElementById('taskTitle').value = task.title;
|
||||||
|
document.getElementById('taskDescription').value = task.description || '';
|
||||||
|
document.getElementById('taskPriority').value = task.priority;
|
||||||
|
document.getElementById('taskColumn').value = task.column_id;
|
||||||
|
}
|
||||||
|
} else {
|
||||||
|
title.textContent = 'New Task';
|
||||||
|
form.reset();
|
||||||
|
document.getElementById('taskId').value = '';
|
||||||
|
}
|
||||||
|
|
||||||
|
modal.classList.add('active');
|
||||||
|
document.getElementById('taskTitle').focus();
|
||||||
|
}
|
||||||
|
|
||||||
|
function closeModal() {
|
||||||
|
document.getElementById('modal').classList.remove('active');
|
||||||
|
}
|
||||||
|
|
||||||
|
function findTask(taskId) {
|
||||||
|
for (const col of board) {
|
||||||
|
const task = col.tasks.find(t => t.id === taskId);
|
||||||
|
if (task) return { ...task, column_id: col.id };
|
||||||
|
}
|
||||||
|
return null;
|
||||||
|
}
|
||||||
|
|
||||||
|
async function saveTask(e) {
|
||||||
|
e.preventDefault();
|
||||||
|
|
||||||
|
const taskId = document.getElementById('taskId').value;
|
||||||
|
const data = {
|
||||||
|
title: document.getElementById('taskTitle').value,
|
||||||
|
description: document.getElementById('taskDescription').value,
|
||||||
|
priority: document.getElementById('taskPriority').value,
|
||||||
|
column_id: document.getElementById('taskColumn').value
|
||||||
|
};
|
||||||
|
|
||||||
|
if (taskId) {
|
||||||
|
await fetch(`/api/tasks/${taskId}`, {
|
||||||
|
method: 'PUT',
|
||||||
|
headers: { 'Content-Type': 'application/json' },
|
||||||
|
body: JSON.stringify(data)
|
||||||
|
});
|
||||||
|
|
||||||
|
// Move if column changed
|
||||||
|
const task = findTask(taskId);
|
||||||
|
if (task && task.column_id !== data.column_id) {
|
||||||
|
const column = board.find(c => c.id === data.column_id);
|
||||||
|
await fetch(`/api/tasks/${taskId}/move`, {
|
||||||
|
method: 'PUT',
|
||||||
|
headers: { 'Content-Type': 'application/json' },
|
||||||
|
body: JSON.stringify({ column_id: data.column_id, position: column.tasks.length })
|
||||||
|
});
|
||||||
|
}
|
||||||
|
} else {
|
||||||
|
await fetch('/api/tasks', {
|
||||||
|
method: 'POST',
|
||||||
|
headers: { 'Content-Type': 'application/json' },
|
||||||
|
body: JSON.stringify(data)
|
||||||
|
});
|
||||||
|
}
|
||||||
|
|
||||||
|
closeModal();
|
||||||
|
loadBoard();
|
||||||
|
}
|
||||||
|
|
||||||
|
function editTask(taskId) {
|
||||||
|
openModal(taskId);
|
||||||
|
}
|
||||||
|
|
||||||
|
async function deleteTask(taskId) {
|
||||||
|
if (!confirm('Delete this task?')) return;
|
||||||
|
|
||||||
|
await fetch(`/api/tasks/${taskId}`, { method: 'DELETE' });
|
||||||
|
loadBoard();
|
||||||
|
}
|
||||||
|
|
||||||
|
function toggleQuickAdd(columnId) {
|
||||||
|
const quickAdd = document.getElementById(`quick-${columnId}`);
|
||||||
|
const btn = document.getElementById(`btn-${columnId}`);
|
||||||
|
|
||||||
|
const isActive = quickAdd.classList.toggle('active');
|
||||||
|
btn.style.display = isActive ? 'none' : 'block';
|
||||||
|
|
||||||
|
if (isActive) {
|
||||||
|
quickAdd.querySelector('input').focus();
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
async function quickAddTask(columnId) {
|
||||||
|
const input = document.querySelector(`#quick-${columnId} input`);
|
||||||
|
const title = input.value.trim();
|
||||||
|
|
||||||
|
if (!title) return;
|
||||||
|
|
||||||
|
await fetch('/api/tasks', {
|
||||||
|
method: 'POST',
|
||||||
|
headers: { 'Content-Type': 'application/json' },
|
||||||
|
body: JSON.stringify({ title, column_id: columnId, priority: 'medium' })
|
||||||
|
});
|
||||||
|
|
||||||
|
input.value = '';
|
||||||
|
toggleQuickAdd(columnId);
|
||||||
|
loadBoard();
|
||||||
|
}
|
||||||
|
|
||||||
|
function handleQuickAddKey(e, columnId) {
|
||||||
|
if (e.key === 'Enter') {
|
||||||
|
quickAddTask(columnId);
|
||||||
|
} else if (e.key === 'Escape') {
|
||||||
|
toggleQuickAdd(columnId);
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
// Close modal on escape
|
||||||
|
document.addEventListener('keydown', e => {
|
||||||
|
if (e.key === 'Escape') closeModal();
|
||||||
|
});
|
||||||
|
|
||||||
|
// Close modal on overlay click
|
||||||
|
document.getElementById('modal').addEventListener('click', e => {
|
||||||
|
if (e.target.classList.contains('modal-overlay')) closeModal();
|
||||||
|
});
|
||||||
|
|
||||||
|
// Load current user
|
||||||
|
async function loadUser() {
|
||||||
|
try {
|
||||||
|
const res = await fetch('/api/me');
|
||||||
|
if (res.ok) {
|
||||||
|
const user = await res.json();
|
||||||
|
document.getElementById('username').textContent = user.username;
|
||||||
|
}
|
||||||
|
} catch (err) {
|
||||||
|
console.error('Failed to load user');
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
// Initial load
|
||||||
|
loadUser();
|
||||||
|
loadBoard();
|
||||||
|
</script>
|
||||||
|
</body>
|
||||||
|
</html>
|
||||||
501
server.js
Normal file
501
server.js
Normal file
|
|
@ -0,0 +1,501 @@
|
||||||
|
const express = require('express');
|
||||||
|
const Database = require('better-sqlite3');
|
||||||
|
const cors = require('cors');
|
||||||
|
const crypto = require('crypto');
|
||||||
|
const { v4: uuidv4 } = require('uuid');
|
||||||
|
const path = require('path');
|
||||||
|
|
||||||
|
const app = express();
|
||||||
|
const PORT = process.env.PORT || 3000;
|
||||||
|
const WEBHOOK_URL = process.env.WEBHOOK_URL || '';
|
||||||
|
const SESSION_SECRET = process.env.SESSION_SECRET || crypto.randomBytes(32).toString('hex');
|
||||||
|
const API_KEY = process.env.API_KEY || '';
|
||||||
|
|
||||||
|
// Middleware
|
||||||
|
app.use(cors());
|
||||||
|
app.use(express.json());
|
||||||
|
app.use(express.urlencoded({ extended: true }));
|
||||||
|
|
||||||
|
// Database setup
|
||||||
|
const db = new Database('/data/taskboard.db');
|
||||||
|
db.pragma('journal_mode = WAL');
|
||||||
|
|
||||||
|
// Initialize tables
|
||||||
|
db.exec(`
|
||||||
|
CREATE TABLE IF NOT EXISTS columns (
|
||||||
|
id TEXT PRIMARY KEY,
|
||||||
|
name TEXT NOT NULL,
|
||||||
|
position INTEGER NOT NULL,
|
||||||
|
is_trigger INTEGER DEFAULT 0
|
||||||
|
);
|
||||||
|
|
||||||
|
CREATE TABLE IF NOT EXISTS tasks (
|
||||||
|
id TEXT PRIMARY KEY,
|
||||||
|
title TEXT NOT NULL,
|
||||||
|
description TEXT,
|
||||||
|
column_id TEXT NOT NULL,
|
||||||
|
position INTEGER NOT NULL,
|
||||||
|
priority TEXT DEFAULT 'medium',
|
||||||
|
created_at DATETIME DEFAULT CURRENT_TIMESTAMP,
|
||||||
|
updated_at DATETIME DEFAULT CURRENT_TIMESTAMP,
|
||||||
|
FOREIGN KEY (column_id) REFERENCES columns(id)
|
||||||
|
);
|
||||||
|
|
||||||
|
CREATE TABLE IF NOT EXISTS users (
|
||||||
|
id TEXT PRIMARY KEY,
|
||||||
|
username TEXT UNIQUE NOT NULL,
|
||||||
|
password_hash TEXT NOT NULL,
|
||||||
|
created_at DATETIME DEFAULT CURRENT_TIMESTAMP
|
||||||
|
);
|
||||||
|
|
||||||
|
CREATE TABLE IF NOT EXISTS sessions (
|
||||||
|
id TEXT PRIMARY KEY,
|
||||||
|
user_id TEXT NOT NULL,
|
||||||
|
expires_at DATETIME NOT NULL,
|
||||||
|
FOREIGN KEY (user_id) REFERENCES users(id)
|
||||||
|
);
|
||||||
|
`);
|
||||||
|
|
||||||
|
// Initialize default columns if empty
|
||||||
|
const columnCount = db.prepare('SELECT COUNT(*) as count FROM columns').get();
|
||||||
|
if (columnCount.count === 0) {
|
||||||
|
const defaultColumns = [
|
||||||
|
{ id: 'backlog', name: '📋 Backlog', position: 0, is_trigger: 0 },
|
||||||
|
{ id: 'ready', name: '🚀 Ready for Claudia', position: 1, is_trigger: 1 },
|
||||||
|
{ id: 'in-progress', name: '⚡ In Progress', position: 2, is_trigger: 0 },
|
||||||
|
{ id: 'review', name: '👀 Review', position: 3, is_trigger: 0 },
|
||||||
|
{ id: 'done', name: '✅ Done', position: 4, is_trigger: 0 }
|
||||||
|
];
|
||||||
|
|
||||||
|
const insertColumn = db.prepare('INSERT INTO columns (id, name, position, is_trigger) VALUES (?, ?, ?, ?)');
|
||||||
|
for (const col of defaultColumns) {
|
||||||
|
insertColumn.run(col.id, col.name, col.position, col.is_trigger);
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
// Password hashing
|
||||||
|
function hashPassword(password) {
|
||||||
|
const salt = crypto.randomBytes(16).toString('hex');
|
||||||
|
const hash = crypto.pbkdf2Sync(password, salt, 10000, 64, 'sha512').toString('hex');
|
||||||
|
return `${salt}:${hash}`;
|
||||||
|
}
|
||||||
|
|
||||||
|
function verifyPassword(password, stored) {
|
||||||
|
const [salt, hash] = stored.split(':');
|
||||||
|
const verify = crypto.pbkdf2Sync(password, salt, 10000, 64, 'sha512').toString('hex');
|
||||||
|
return hash === verify;
|
||||||
|
}
|
||||||
|
|
||||||
|
// Create default admin if no users exist
|
||||||
|
const userCount = db.prepare('SELECT COUNT(*) as count FROM users').get();
|
||||||
|
if (userCount.count === 0) {
|
||||||
|
const defaultPassword = process.env.ADMIN_PASSWORD || crypto.randomBytes(12).toString('base64').slice(0, 16);
|
||||||
|
const hash = hashPassword(defaultPassword);
|
||||||
|
db.prepare('INSERT INTO users (id, username, password_hash) VALUES (?, ?, ?)')
|
||||||
|
.run(uuidv4(), 'albert', hash);
|
||||||
|
console.log(`🔐 Default user created - username: albert, password: ${defaultPassword}`);
|
||||||
|
console.log(' Change this password after first login!');
|
||||||
|
}
|
||||||
|
|
||||||
|
// Session middleware
|
||||||
|
function getSession(req) {
|
||||||
|
const cookie = req.headers.cookie || '';
|
||||||
|
const match = cookie.match(/session=([^;]+)/);
|
||||||
|
if (!match) return null;
|
||||||
|
|
||||||
|
const session = db.prepare("SELECT * FROM sessions WHERE id = ? AND expires_at > datetime('now')").get(match[1]);
|
||||||
|
if (!session) return null;
|
||||||
|
|
||||||
|
return db.prepare('SELECT id, username FROM users WHERE id = ?').get(session.user_id);
|
||||||
|
}
|
||||||
|
|
||||||
|
function requireAuth(req, res, next) {
|
||||||
|
// Check for API key (for internal services like monitoring)
|
||||||
|
const apiKey = req.headers['x-api-key'];
|
||||||
|
if (API_KEY && apiKey === API_KEY) {
|
||||||
|
req.user = { id: 'api', username: 'api-service' };
|
||||||
|
return next();
|
||||||
|
}
|
||||||
|
|
||||||
|
const user = getSession(req);
|
||||||
|
if (!user) {
|
||||||
|
if (req.path.startsWith('/api/')) {
|
||||||
|
return res.status(401).json({ error: 'Unauthorized' });
|
||||||
|
}
|
||||||
|
return res.redirect('/login');
|
||||||
|
}
|
||||||
|
req.user = user;
|
||||||
|
next();
|
||||||
|
}
|
||||||
|
|
||||||
|
// Auth routes
|
||||||
|
app.get('/login', (req, res) => {
|
||||||
|
if (getSession(req)) return res.redirect('/');
|
||||||
|
res.send(loginPage());
|
||||||
|
});
|
||||||
|
|
||||||
|
app.post('/login', (req, res) => {
|
||||||
|
const { username, password } = req.body;
|
||||||
|
|
||||||
|
const user = db.prepare('SELECT * FROM users WHERE username = ?').get(username);
|
||||||
|
if (!user || !verifyPassword(password, user.password_hash)) {
|
||||||
|
return res.send(loginPage('Invalid username or password'));
|
||||||
|
}
|
||||||
|
|
||||||
|
// Create session
|
||||||
|
const sessionId = crypto.randomBytes(32).toString('hex');
|
||||||
|
const expires = new Date(Date.now() + 30 * 24 * 60 * 60 * 1000); // 30 days
|
||||||
|
|
||||||
|
db.prepare('INSERT INTO sessions (id, user_id, expires_at) VALUES (?, ?, ?)')
|
||||||
|
.run(sessionId, user.id, expires.toISOString());
|
||||||
|
|
||||||
|
res.setHeader('Set-Cookie', `session=${sessionId}; Path=/; HttpOnly; SameSite=Strict; Max-Age=${30 * 24 * 60 * 60}`);
|
||||||
|
res.redirect('/');
|
||||||
|
});
|
||||||
|
|
||||||
|
app.get('/logout', (req, res) => {
|
||||||
|
const cookie = req.headers.cookie || '';
|
||||||
|
const match = cookie.match(/session=([^;]+)/);
|
||||||
|
if (match) {
|
||||||
|
db.prepare('DELETE FROM sessions WHERE id = ?').run(match[1]);
|
||||||
|
}
|
||||||
|
res.setHeader('Set-Cookie', 'session=; Path=/; HttpOnly; Max-Age=0');
|
||||||
|
res.redirect('/login');
|
||||||
|
});
|
||||||
|
|
||||||
|
// Change password
|
||||||
|
app.post('/api/change-password', requireAuth, (req, res) => {
|
||||||
|
const { currentPassword, newPassword } = req.body;
|
||||||
|
|
||||||
|
const user = db.prepare('SELECT * FROM users WHERE id = ?').get(req.user.id);
|
||||||
|
if (!verifyPassword(currentPassword, user.password_hash)) {
|
||||||
|
return res.status(400).json({ error: 'Current password is incorrect' });
|
||||||
|
}
|
||||||
|
|
||||||
|
const hash = hashPassword(newPassword);
|
||||||
|
db.prepare('UPDATE users SET password_hash = ? WHERE id = ?').run(hash, req.user.id);
|
||||||
|
|
||||||
|
res.json({ success: true });
|
||||||
|
});
|
||||||
|
|
||||||
|
// Webhook function
|
||||||
|
async function notifyWebhook(task, column) {
|
||||||
|
if (!WEBHOOK_URL) return;
|
||||||
|
|
||||||
|
try {
|
||||||
|
await fetch(WEBHOOK_URL, {
|
||||||
|
method: 'POST',
|
||||||
|
headers: { 'Content-Type': 'application/json' },
|
||||||
|
body: JSON.stringify({
|
||||||
|
event: 'task_ready',
|
||||||
|
task: task,
|
||||||
|
column: column,
|
||||||
|
timestamp: new Date().toISOString()
|
||||||
|
})
|
||||||
|
});
|
||||||
|
} catch (err) {
|
||||||
|
console.error('Webhook error:', err.message);
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
// Protected API Routes
|
||||||
|
app.get('/api/board', requireAuth, (req, res) => {
|
||||||
|
const columns = db.prepare('SELECT * FROM columns ORDER BY position').all();
|
||||||
|
const tasks = db.prepare('SELECT * FROM tasks ORDER BY position').all();
|
||||||
|
|
||||||
|
const board = columns.map(col => ({
|
||||||
|
...col,
|
||||||
|
is_trigger: !!col.is_trigger,
|
||||||
|
tasks: tasks.filter(t => t.column_id === col.id)
|
||||||
|
}));
|
||||||
|
|
||||||
|
res.json(board);
|
||||||
|
});
|
||||||
|
|
||||||
|
app.get('/api/me', requireAuth, (req, res) => {
|
||||||
|
res.json(req.user);
|
||||||
|
});
|
||||||
|
|
||||||
|
app.post('/api/tasks', requireAuth, (req, res) => {
|
||||||
|
const { title, description, column_id, priority } = req.body;
|
||||||
|
const id = uuidv4();
|
||||||
|
|
||||||
|
const maxPos = db.prepare('SELECT MAX(position) as max FROM tasks WHERE column_id = ?').get(column_id);
|
||||||
|
const position = (maxPos.max ?? -1) + 1;
|
||||||
|
|
||||||
|
db.prepare(`
|
||||||
|
INSERT INTO tasks (id, title, description, column_id, position, priority)
|
||||||
|
VALUES (?, ?, ?, ?, ?, ?)
|
||||||
|
`).run(id, title, description || '', column_id || 'backlog', position, priority || 'medium');
|
||||||
|
|
||||||
|
const task = db.prepare('SELECT * FROM tasks WHERE id = ?').get(id);
|
||||||
|
const column = db.prepare('SELECT * FROM columns WHERE id = ?').get(task.column_id);
|
||||||
|
|
||||||
|
if (column.is_trigger) {
|
||||||
|
notifyWebhook(task, column);
|
||||||
|
}
|
||||||
|
|
||||||
|
res.json(task);
|
||||||
|
});
|
||||||
|
|
||||||
|
app.put('/api/tasks/:id', requireAuth, (req, res) => {
|
||||||
|
const { id } = req.params;
|
||||||
|
const { title, description, priority } = req.body;
|
||||||
|
|
||||||
|
db.prepare(`
|
||||||
|
UPDATE tasks SET title = ?, description = ?, priority = ?, updated_at = CURRENT_TIMESTAMP
|
||||||
|
WHERE id = ?
|
||||||
|
`).run(title, description, priority, id);
|
||||||
|
|
||||||
|
const task = db.prepare('SELECT * FROM tasks WHERE id = ?').get(id);
|
||||||
|
res.json(task);
|
||||||
|
});
|
||||||
|
|
||||||
|
app.put('/api/tasks/:id/move', requireAuth, (req, res) => {
|
||||||
|
const { id } = req.params;
|
||||||
|
const { column_id, position } = req.body;
|
||||||
|
|
||||||
|
const oldTask = db.prepare('SELECT * FROM tasks WHERE id = ?').get(id);
|
||||||
|
const oldColumnId = oldTask.column_id;
|
||||||
|
|
||||||
|
db.prepare(`
|
||||||
|
UPDATE tasks SET position = position - 1
|
||||||
|
WHERE column_id = ? AND position > ?
|
||||||
|
`).run(oldColumnId, oldTask.position);
|
||||||
|
|
||||||
|
db.prepare(`
|
||||||
|
UPDATE tasks SET position = position + 1
|
||||||
|
WHERE column_id = ? AND position >= ?
|
||||||
|
`).run(column_id, position);
|
||||||
|
|
||||||
|
db.prepare(`
|
||||||
|
UPDATE tasks SET column_id = ?, position = ?, updated_at = CURRENT_TIMESTAMP
|
||||||
|
WHERE id = ?
|
||||||
|
`).run(column_id, position, id);
|
||||||
|
|
||||||
|
const task = db.prepare('SELECT * FROM tasks WHERE id = ?').get(id);
|
||||||
|
const column = db.prepare('SELECT * FROM columns WHERE id = ?').get(column_id);
|
||||||
|
|
||||||
|
if (column.is_trigger && oldColumnId !== column_id) {
|
||||||
|
notifyWebhook(task, column);
|
||||||
|
}
|
||||||
|
|
||||||
|
res.json(task);
|
||||||
|
});
|
||||||
|
|
||||||
|
app.delete('/api/tasks/:id', requireAuth, (req, res) => {
|
||||||
|
const { id } = req.params;
|
||||||
|
db.prepare('DELETE FROM tasks WHERE id = ?').run(id);
|
||||||
|
res.json({ success: true });
|
||||||
|
});
|
||||||
|
|
||||||
|
app.put('/api/columns/:id', requireAuth, (req, res) => {
|
||||||
|
const { id } = req.params;
|
||||||
|
const { name, is_trigger } = req.body;
|
||||||
|
|
||||||
|
db.prepare('UPDATE columns SET name = ?, is_trigger = ? WHERE id = ?')
|
||||||
|
.run(name, is_trigger ? 1 : 0, id);
|
||||||
|
|
||||||
|
const column = db.prepare('SELECT * FROM columns WHERE id = ?').get(id);
|
||||||
|
res.json(column);
|
||||||
|
});
|
||||||
|
|
||||||
|
// Serve static files (protected)
|
||||||
|
app.get('/', requireAuth, (req, res) => {
|
||||||
|
res.sendFile(path.join(__dirname, 'public', 'index.html'));
|
||||||
|
});
|
||||||
|
|
||||||
|
app.use(express.static('public'));
|
||||||
|
|
||||||
|
// Login page template
|
||||||
|
function loginPage(error = '') {
|
||||||
|
return `<!DOCTYPE html>
|
||||||
|
<html lang="en">
|
||||||
|
<head>
|
||||||
|
<meta charset="UTF-8">
|
||||||
|
<meta name="viewport" content="width=device-width, initial-scale=1.0">
|
||||||
|
<title>Login - vainplex TaskBoard</title>
|
||||||
|
<link href="https://fonts.googleapis.com/css2?family=Inter:wght@400;500;600;700&display=swap" rel="stylesheet">
|
||||||
|
<style>
|
||||||
|
* { margin: 0; padding: 0; box-sizing: border-box; }
|
||||||
|
|
||||||
|
:root {
|
||||||
|
--bg-primary: #0a0a0f;
|
||||||
|
--bg-secondary: #12121a;
|
||||||
|
--bg-card: #1a1a24;
|
||||||
|
--border: #2a2a3a;
|
||||||
|
--text-primary: #ffffff;
|
||||||
|
--text-secondary: #a0a0b0;
|
||||||
|
--accent: #6366f1;
|
||||||
|
--accent-hover: #818cf8;
|
||||||
|
--danger: #ef4444;
|
||||||
|
}
|
||||||
|
|
||||||
|
body {
|
||||||
|
font-family: 'Inter', -apple-system, sans-serif;
|
||||||
|
background: var(--bg-primary);
|
||||||
|
color: var(--text-primary);
|
||||||
|
min-height: 100vh;
|
||||||
|
display: flex;
|
||||||
|
align-items: center;
|
||||||
|
justify-content: center;
|
||||||
|
padding: 1rem;
|
||||||
|
}
|
||||||
|
|
||||||
|
.login-container {
|
||||||
|
width: 100%;
|
||||||
|
max-width: 400px;
|
||||||
|
}
|
||||||
|
|
||||||
|
.login-card {
|
||||||
|
background: var(--bg-secondary);
|
||||||
|
border: 1px solid var(--border);
|
||||||
|
border-radius: 16px;
|
||||||
|
padding: 2.5rem;
|
||||||
|
box-shadow: 0 25px 50px -12px rgba(0, 0, 0, 0.5);
|
||||||
|
}
|
||||||
|
|
||||||
|
.logo {
|
||||||
|
display: flex;
|
||||||
|
align-items: center;
|
||||||
|
justify-content: center;
|
||||||
|
gap: 0.75rem;
|
||||||
|
margin-bottom: 2rem;
|
||||||
|
}
|
||||||
|
|
||||||
|
.logo-icon {
|
||||||
|
width: 48px;
|
||||||
|
height: 48px;
|
||||||
|
background: linear-gradient(135deg, var(--accent), #8b5cf6);
|
||||||
|
border-radius: 12px;
|
||||||
|
display: flex;
|
||||||
|
align-items: center;
|
||||||
|
justify-content: center;
|
||||||
|
font-size: 1.5rem;
|
||||||
|
}
|
||||||
|
|
||||||
|
.logo-text {
|
||||||
|
font-size: 1.5rem;
|
||||||
|
font-weight: 700;
|
||||||
|
background: linear-gradient(135deg, var(--accent), #8b5cf6);
|
||||||
|
-webkit-background-clip: text;
|
||||||
|
-webkit-text-fill-color: transparent;
|
||||||
|
}
|
||||||
|
|
||||||
|
.login-title {
|
||||||
|
text-align: center;
|
||||||
|
font-size: 1.25rem;
|
||||||
|
font-weight: 600;
|
||||||
|
margin-bottom: 1.5rem;
|
||||||
|
color: var(--text-secondary);
|
||||||
|
}
|
||||||
|
|
||||||
|
.error-msg {
|
||||||
|
background: rgba(239, 68, 68, 0.1);
|
||||||
|
border: 1px solid rgba(239, 68, 68, 0.3);
|
||||||
|
color: var(--danger);
|
||||||
|
padding: 0.75rem 1rem;
|
||||||
|
border-radius: 8px;
|
||||||
|
margin-bottom: 1rem;
|
||||||
|
font-size: 0.9rem;
|
||||||
|
text-align: center;
|
||||||
|
}
|
||||||
|
|
||||||
|
.form-group {
|
||||||
|
margin-bottom: 1.25rem;
|
||||||
|
}
|
||||||
|
|
||||||
|
.form-label {
|
||||||
|
display: block;
|
||||||
|
font-size: 0.85rem;
|
||||||
|
color: var(--text-secondary);
|
||||||
|
margin-bottom: 0.5rem;
|
||||||
|
font-weight: 500;
|
||||||
|
}
|
||||||
|
|
||||||
|
.form-input {
|
||||||
|
width: 100%;
|
||||||
|
padding: 0.875rem 1rem;
|
||||||
|
background: var(--bg-card);
|
||||||
|
border: 1px solid var(--border);
|
||||||
|
border-radius: 10px;
|
||||||
|
color: var(--text-primary);
|
||||||
|
font-family: inherit;
|
||||||
|
font-size: 1rem;
|
||||||
|
transition: all 0.2s ease;
|
||||||
|
}
|
||||||
|
|
||||||
|
.form-input:focus {
|
||||||
|
outline: none;
|
||||||
|
border-color: var(--accent);
|
||||||
|
box-shadow: 0 0 0 3px rgba(99, 102, 241, 0.1);
|
||||||
|
}
|
||||||
|
|
||||||
|
.form-input::placeholder {
|
||||||
|
color: var(--text-secondary);
|
||||||
|
opacity: 0.5;
|
||||||
|
}
|
||||||
|
|
||||||
|
.btn-login {
|
||||||
|
width: 100%;
|
||||||
|
padding: 0.875rem;
|
||||||
|
background: linear-gradient(135deg, var(--accent), #8b5cf6);
|
||||||
|
border: none;
|
||||||
|
border-radius: 10px;
|
||||||
|
color: white;
|
||||||
|
font-family: inherit;
|
||||||
|
font-size: 1rem;
|
||||||
|
font-weight: 600;
|
||||||
|
cursor: pointer;
|
||||||
|
transition: all 0.2s ease;
|
||||||
|
margin-top: 0.5rem;
|
||||||
|
}
|
||||||
|
|
||||||
|
.btn-login:hover {
|
||||||
|
transform: translateY(-1px);
|
||||||
|
box-shadow: 0 10px 20px -10px rgba(99, 102, 241, 0.5);
|
||||||
|
}
|
||||||
|
|
||||||
|
.btn-login:active {
|
||||||
|
transform: translateY(0);
|
||||||
|
}
|
||||||
|
|
||||||
|
.footer-text {
|
||||||
|
text-align: center;
|
||||||
|
margin-top: 1.5rem;
|
||||||
|
font-size: 0.8rem;
|
||||||
|
color: var(--text-secondary);
|
||||||
|
opacity: 0.6;
|
||||||
|
}
|
||||||
|
</style>
|
||||||
|
</head>
|
||||||
|
<body>
|
||||||
|
<div class="login-container">
|
||||||
|
<div class="login-card">
|
||||||
|
<div class="logo">
|
||||||
|
<div class="logo-icon">📋</div>
|
||||||
|
<span class="logo-text">TaskBoard</span>
|
||||||
|
</div>
|
||||||
|
<h1 class="login-title">Sign in to continue</h1>
|
||||||
|
${error ? `<div class="error-msg">${error}</div>` : ''}
|
||||||
|
<form method="POST" action="/login">
|
||||||
|
<div class="form-group">
|
||||||
|
<label class="form-label">Username</label>
|
||||||
|
<input type="text" name="username" class="form-input" placeholder="Enter your username" required autofocus>
|
||||||
|
</div>
|
||||||
|
<div class="form-group">
|
||||||
|
<label class="form-label">Password</label>
|
||||||
|
<input type="password" name="password" class="form-input" placeholder="Enter your password" required>
|
||||||
|
</div>
|
||||||
|
<button type="submit" class="btn-login">Sign In</button>
|
||||||
|
</form>
|
||||||
|
<p class="footer-text">vainplex TaskBoard • Secure Access</p>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
</body>
|
||||||
|
</html>`;
|
||||||
|
}
|
||||||
|
|
||||||
|
app.listen(PORT, '0.0.0.0', () => {
|
||||||
|
console.log(`🚀 Taskboard running on port ${PORT}`);
|
||||||
|
});
|
||||||
Loading…
Reference in a new issue