Initial project setup for task-inventory
Node.js + Express 5 backend with flat NDJSON key-value store (borrowed from electronics-inventory). Sequential integer IDs per namespace. Vanilla JS SPA with filters for status, priority, and tags. Co-Authored-By: Claude Sonnet 4.6 <noreply@anthropic.com>
This commit is contained in:
267
public/app.mjs
Normal file
267
public/app.mjs
Normal file
@@ -0,0 +1,267 @@
|
||||
import * as api from './lib/api.mjs';
|
||||
import { qs, clone, set_text, show, hide } from './lib/dom.mjs';
|
||||
|
||||
// ---------------------------------------------------------------------------
|
||||
// State
|
||||
// ---------------------------------------------------------------------------
|
||||
|
||||
class App_State {
|
||||
constructor() {
|
||||
this.tasks = [];
|
||||
this.filter_status = 'open';
|
||||
this.filter_priority = '';
|
||||
this.filter_tag = '';
|
||||
this.search = '';
|
||||
}
|
||||
}
|
||||
|
||||
const state = new App_State();
|
||||
|
||||
// ---------------------------------------------------------------------------
|
||||
// Helpers
|
||||
// ---------------------------------------------------------------------------
|
||||
|
||||
function filtered_tasks() {
|
||||
return state.tasks.filter(t => {
|
||||
if (state.filter_status && t.status !== state.filter_status) { return false; }
|
||||
if (state.filter_priority && t.priority !== state.filter_priority) { return false; }
|
||||
if (state.filter_tag && !t.tags.includes(state.filter_tag)) { return false; }
|
||||
if (state.search) {
|
||||
const q = state.search.toLowerCase();
|
||||
if (!t.title.toLowerCase().includes(q) && !t.body.toLowerCase().includes(q)) { return false; }
|
||||
}
|
||||
return true;
|
||||
});
|
||||
}
|
||||
|
||||
function all_tags() {
|
||||
const tags = new Set();
|
||||
for (const t of state.tasks) {
|
||||
for (const tag of t.tags) { tags.add(tag); }
|
||||
}
|
||||
return [...tags].sort();
|
||||
}
|
||||
|
||||
// ---------------------------------------------------------------------------
|
||||
// Task dialog
|
||||
// ---------------------------------------------------------------------------
|
||||
|
||||
class Task_Dialog {
|
||||
constructor() {
|
||||
const el = clone('t-task-dialog');
|
||||
document.body.appendChild(el);
|
||||
this.dialog = el;
|
||||
this.form = el.querySelector('form');
|
||||
this.on_save = null;
|
||||
|
||||
el.querySelector('.btn-cancel').addEventListener('click', () => el.close());
|
||||
this.form.addEventListener('submit', (e) => {
|
||||
e.preventDefault();
|
||||
if (this.on_save) { this.on_save(this._read_form()); }
|
||||
el.close();
|
||||
});
|
||||
}
|
||||
|
||||
_read_form() {
|
||||
const fd = new FormData(this.form);
|
||||
const tags_raw = fd.get('tags').trim();
|
||||
return {
|
||||
title: fd.get('title').trim(),
|
||||
body: fd.get('body').trim(),
|
||||
status: fd.get('status'),
|
||||
priority: fd.get('priority'),
|
||||
tags: tags_raw ? tags_raw.split(',').map(s => s.trim()).filter(Boolean) : [],
|
||||
};
|
||||
}
|
||||
|
||||
open(title_text, initial = {}, on_save) {
|
||||
set_text(this.dialog, '.dialog-title', title_text);
|
||||
this.form.elements['title'].value = initial.title ?? '';
|
||||
this.form.elements['body'].value = initial.body ?? '';
|
||||
this.form.elements['status'].value = initial.status ?? 'open';
|
||||
this.form.elements['priority'].value = initial.priority ?? 'normal';
|
||||
this.form.elements['tags'].value = (initial.tags ?? []).join(', ');
|
||||
this.on_save = on_save;
|
||||
this.dialog.showModal();
|
||||
}
|
||||
}
|
||||
|
||||
const task_dialog = new Task_Dialog();
|
||||
|
||||
// ---------------------------------------------------------------------------
|
||||
// Render
|
||||
// ---------------------------------------------------------------------------
|
||||
|
||||
function render_tasks(container) {
|
||||
container.innerHTML = '';
|
||||
|
||||
// Toolbar
|
||||
const toolbar = document.createElement('div');
|
||||
toolbar.className = 'toolbar';
|
||||
toolbar.innerHTML = '<h2>Tasks</h2>';
|
||||
const add_btn = document.createElement('button');
|
||||
add_btn.className = 'btn-primary';
|
||||
add_btn.textContent = '+ New task';
|
||||
add_btn.addEventListener('click', () => open_add_dialog());
|
||||
toolbar.appendChild(add_btn);
|
||||
container.appendChild(toolbar);
|
||||
|
||||
// Filter bar
|
||||
const filter_bar = document.createElement('div');
|
||||
filter_bar.className = 'filter-bar';
|
||||
|
||||
const status_sel = document.createElement('select');
|
||||
for (const [val, label] of [['', 'All statuses'], ['open', 'Open'], ['deferred', 'Deferred'], ['done', 'Done'], ['cancelled', 'Cancelled']]) {
|
||||
const opt = document.createElement('option');
|
||||
opt.value = val; opt.textContent = label;
|
||||
if (val === state.filter_status) { opt.selected = true; }
|
||||
status_sel.appendChild(opt);
|
||||
}
|
||||
status_sel.addEventListener('change', () => { state.filter_status = status_sel.value; render(); });
|
||||
filter_bar.appendChild(status_sel);
|
||||
|
||||
const priority_sel = document.createElement('select');
|
||||
for (const [val, label] of [['', 'All priorities'], ['high', 'High'], ['normal', 'Normal'], ['low', 'Low']]) {
|
||||
const opt = document.createElement('option');
|
||||
opt.value = val; opt.textContent = label;
|
||||
if (val === state.filter_priority) { opt.selected = true; }
|
||||
priority_sel.appendChild(opt);
|
||||
}
|
||||
priority_sel.addEventListener('change', () => { state.filter_priority = priority_sel.value; render(); });
|
||||
filter_bar.appendChild(priority_sel);
|
||||
|
||||
const tag_sel = document.createElement('select');
|
||||
const all_opt = document.createElement('option');
|
||||
all_opt.value = ''; all_opt.textContent = 'All tags';
|
||||
tag_sel.appendChild(all_opt);
|
||||
for (const tag of all_tags()) {
|
||||
const opt = document.createElement('option');
|
||||
opt.value = tag; opt.textContent = tag;
|
||||
if (tag === state.filter_tag) { opt.selected = true; }
|
||||
tag_sel.appendChild(opt);
|
||||
}
|
||||
tag_sel.addEventListener('change', () => { state.filter_tag = tag_sel.value; render(); });
|
||||
filter_bar.appendChild(tag_sel);
|
||||
|
||||
const search_input = document.createElement('input');
|
||||
search_input.type = 'text';
|
||||
search_input.placeholder = 'Search…';
|
||||
search_input.value = state.search;
|
||||
search_input.addEventListener('input', () => { state.search = search_input.value; render(); });
|
||||
filter_bar.appendChild(search_input);
|
||||
|
||||
container.appendChild(filter_bar);
|
||||
|
||||
// Task list
|
||||
const tasks = filtered_tasks();
|
||||
const list = document.createElement('div');
|
||||
list.className = 'task-list';
|
||||
|
||||
if (tasks.length === 0) {
|
||||
const empty = document.createElement('div');
|
||||
empty.className = 'empty-state';
|
||||
empty.textContent = 'No tasks.';
|
||||
list.appendChild(empty);
|
||||
}
|
||||
|
||||
for (const task of tasks) {
|
||||
const row = clone('t-task-row');
|
||||
row.dataset.priority = task.priority;
|
||||
row.dataset.status = task.status;
|
||||
|
||||
set_text(row, '.task-id', `#${task.id}`);
|
||||
|
||||
const status_el = row.querySelector('.task-status');
|
||||
status_el.textContent = task.status;
|
||||
status_el.dataset.val = task.status;
|
||||
|
||||
const priority_el = row.querySelector('.task-priority');
|
||||
priority_el.textContent = task.priority;
|
||||
priority_el.dataset.val = task.priority;
|
||||
|
||||
set_text(row, '.task-title', task.title);
|
||||
|
||||
const tags_el = row.querySelector('.task-tags');
|
||||
for (const tag of task.tags) {
|
||||
const span = document.createElement('span');
|
||||
span.className = 'tag';
|
||||
span.textContent = tag;
|
||||
tags_el.appendChild(span);
|
||||
}
|
||||
|
||||
row.querySelector('.btn-edit').addEventListener('click', () => open_edit_dialog(task));
|
||||
row.querySelector('.btn-delete').addEventListener('click', () => confirm_delete(task));
|
||||
|
||||
list.appendChild(row);
|
||||
}
|
||||
|
||||
container.appendChild(list);
|
||||
}
|
||||
|
||||
function render() {
|
||||
const main = document.getElementById('main');
|
||||
render_tasks(main);
|
||||
}
|
||||
|
||||
// ---------------------------------------------------------------------------
|
||||
// Actions
|
||||
// ---------------------------------------------------------------------------
|
||||
|
||||
function open_add_dialog() {
|
||||
task_dialog.open('New task', {}, async (data) => {
|
||||
try {
|
||||
const { task } = await api.create_task(data);
|
||||
state.tasks.unshift(task);
|
||||
render();
|
||||
} catch (err) {
|
||||
alert(err.message);
|
||||
}
|
||||
});
|
||||
}
|
||||
|
||||
function open_edit_dialog(task) {
|
||||
task_dialog.open('Edit task', task, async (data) => {
|
||||
try {
|
||||
const { task: updated } = await api.update_task(task.id, data);
|
||||
const idx = state.tasks.findIndex(t => t.id === task.id);
|
||||
if (idx !== -1) { state.tasks[idx] = updated; }
|
||||
render();
|
||||
} catch (err) {
|
||||
alert(err.message);
|
||||
}
|
||||
});
|
||||
}
|
||||
|
||||
function confirm_delete(task) {
|
||||
if (!confirm(`Delete task #${task.id}: "${task.title}"?`)) { return; }
|
||||
api.delete_task(task.id).then(() => {
|
||||
state.tasks = state.tasks.filter(t => t.id !== task.id);
|
||||
render();
|
||||
}).catch(err => alert(err.message));
|
||||
}
|
||||
|
||||
// ---------------------------------------------------------------------------
|
||||
// Init
|
||||
// ---------------------------------------------------------------------------
|
||||
|
||||
async function init() {
|
||||
// Inject templates
|
||||
const tmpl_res = await fetch('/templates.html');
|
||||
const tmpl_html = await tmpl_res.text();
|
||||
const tmpl_container = document.createElement('div');
|
||||
tmpl_container.innerHTML = tmpl_html;
|
||||
for (const tmpl of tmpl_container.querySelectorAll('template')) {
|
||||
document.body.appendChild(tmpl);
|
||||
}
|
||||
|
||||
// Load data
|
||||
const { tasks } = await api.get_tasks();
|
||||
state.tasks = tasks;
|
||||
|
||||
render();
|
||||
}
|
||||
|
||||
init().catch(err => {
|
||||
console.error('Init failed:', err);
|
||||
document.getElementById('main').textContent = 'Failed to load: ' + err.message;
|
||||
});
|
||||
19
public/index.html
Normal file
19
public/index.html
Normal file
@@ -0,0 +1,19 @@
|
||||
<!DOCTYPE html>
|
||||
<html lang="en">
|
||||
<head>
|
||||
<meta charset="UTF-8">
|
||||
<meta name="viewport" content="width=device-width, initial-scale=1.0">
|
||||
<title>Task Inventory</title>
|
||||
<link rel="stylesheet" href="/style.css">
|
||||
</head>
|
||||
<body>
|
||||
<header>
|
||||
<h1>Task Inventory</h1>
|
||||
<nav>
|
||||
<button data-nav="tasks" class="nav-btn active">Tasks</button>
|
||||
</nav>
|
||||
</header>
|
||||
<main id="main"></main>
|
||||
<script type="module" src="/app.mjs"></script>
|
||||
</body>
|
||||
</html>
|
||||
16
public/lib/api.mjs
Normal file
16
public/lib/api.mjs
Normal file
@@ -0,0 +1,16 @@
|
||||
async function req(method, path, body) {
|
||||
const opts = { method, headers: {} };
|
||||
if (body !== undefined) {
|
||||
opts.headers['Content-Type'] = 'application/json';
|
||||
opts.body = JSON.stringify(body);
|
||||
}
|
||||
const res = await fetch(path, opts);
|
||||
const data = await res.json();
|
||||
if (!data.ok) { throw new Error(data.error ?? 'Request failed'); }
|
||||
return data;
|
||||
}
|
||||
|
||||
export const get_tasks = () => req('GET', '/api/tasks');
|
||||
export const create_task = (body) => req('POST', '/api/tasks', body);
|
||||
export const update_task = (id, body) => req('PUT', `/api/tasks/${id}`, body);
|
||||
export const delete_task = (id) => req('DELETE', `/api/tasks/${id}`);
|
||||
16
public/lib/dom.mjs
Normal file
16
public/lib/dom.mjs
Normal file
@@ -0,0 +1,16 @@
|
||||
export function qs(scope, selector) {
|
||||
if (typeof scope === 'string') { return document.querySelector(scope); }
|
||||
return scope.querySelector(selector);
|
||||
}
|
||||
|
||||
export function clone(template_id) {
|
||||
return document.getElementById(template_id).content.cloneNode(true).firstElementChild;
|
||||
}
|
||||
|
||||
export function set_text(el, selector, text) {
|
||||
const target = typeof selector === 'string' ? el.querySelector(selector) : selector;
|
||||
target.textContent = text;
|
||||
}
|
||||
|
||||
export function show(el) { el.hidden = false; }
|
||||
export function hide(el) { el.hidden = true; }
|
||||
204
public/style.css
Normal file
204
public/style.css
Normal file
@@ -0,0 +1,204 @@
|
||||
*, *::before, *::after { box-sizing: border-box; margin: 0; padding: 0; }
|
||||
|
||||
body {
|
||||
font-family: system-ui, sans-serif;
|
||||
font-size: 14px;
|
||||
background: #1a1a1a;
|
||||
color: #e0e0e0;
|
||||
min-height: 100vh;
|
||||
}
|
||||
|
||||
header {
|
||||
display: flex;
|
||||
align-items: center;
|
||||
gap: 1.5rem;
|
||||
padding: 0.75rem 1.25rem;
|
||||
background: #242424;
|
||||
border-bottom: 1px solid #333;
|
||||
}
|
||||
|
||||
header h1 {
|
||||
font-size: 1rem;
|
||||
font-weight: 600;
|
||||
color: #fff;
|
||||
}
|
||||
|
||||
nav { display: flex; gap: 0.5rem; }
|
||||
|
||||
.nav-btn {
|
||||
padding: 0.3rem 0.75rem;
|
||||
border: 1px solid #444;
|
||||
border-radius: 4px;
|
||||
background: transparent;
|
||||
color: #aaa;
|
||||
cursor: pointer;
|
||||
font-size: 13px;
|
||||
}
|
||||
|
||||
.nav-btn.active, .nav-btn:hover {
|
||||
background: #333;
|
||||
color: #fff;
|
||||
border-color: #555;
|
||||
}
|
||||
|
||||
main { padding: 1.25rem; }
|
||||
|
||||
/* Toolbar */
|
||||
.toolbar {
|
||||
display: flex;
|
||||
align-items: center;
|
||||
gap: 0.75rem;
|
||||
margin-bottom: 1rem;
|
||||
}
|
||||
|
||||
.toolbar h2 { font-size: 1rem; flex: 1; }
|
||||
|
||||
/* Filter bar */
|
||||
.filter-bar {
|
||||
display: flex;
|
||||
gap: 0.5rem;
|
||||
margin-bottom: 0.75rem;
|
||||
flex-wrap: wrap;
|
||||
}
|
||||
|
||||
.filter-bar select, .filter-bar input {
|
||||
padding: 0.3rem 0.5rem;
|
||||
background: #2a2a2a;
|
||||
border: 1px solid #444;
|
||||
border-radius: 4px;
|
||||
color: #e0e0e0;
|
||||
font-size: 13px;
|
||||
}
|
||||
|
||||
/* Task list */
|
||||
.task-list { display: flex; flex-direction: column; gap: 1px; }
|
||||
|
||||
.task-row {
|
||||
display: grid;
|
||||
grid-template-columns: 2.5rem 5rem 4.5rem 1fr auto auto;
|
||||
align-items: center;
|
||||
gap: 0.5rem;
|
||||
padding: 0.5rem 0.75rem;
|
||||
background: #242424;
|
||||
border-radius: 4px;
|
||||
border-left: 3px solid transparent;
|
||||
}
|
||||
|
||||
.task-row[data-priority='high'] { border-left-color: #e05555; }
|
||||
.task-row[data-priority='normal'] { border-left-color: #5588e0; }
|
||||
.task-row[data-priority='low'] { border-left-color: #555; }
|
||||
|
||||
.task-row[data-status='done'] { opacity: 0.5; }
|
||||
.task-row[data-status='cancelled'] { opacity: 0.4; text-decoration: line-through; }
|
||||
|
||||
.task-id { color: #666; font-size: 12px; }
|
||||
.task-title { font-weight: 500; }
|
||||
|
||||
.task-status, .task-priority {
|
||||
font-size: 11px;
|
||||
padding: 0.15rem 0.4rem;
|
||||
border-radius: 3px;
|
||||
text-align: center;
|
||||
text-transform: uppercase;
|
||||
letter-spacing: 0.03em;
|
||||
}
|
||||
|
||||
.task-status[data-val='open'] { background: #1e3a5f; color: #6ab0f5; }
|
||||
.task-status[data-val='done'] { background: #1a3a1a; color: #6ad06a; }
|
||||
.task-status[data-val='cancelled'] { background: #2a1a1a; color: #c06060; }
|
||||
.task-status[data-val='deferred'] { background: #2a2a1a; color: #c0b060; }
|
||||
|
||||
.task-priority[data-val='high'] { background: #3a1a1a; color: #e08080; }
|
||||
.task-priority[data-val='normal'] { background: transparent; color: #777; }
|
||||
.task-priority[data-val='low'] { background: transparent; color: #555; }
|
||||
|
||||
.task-tags { display: flex; gap: 0.25rem; flex-wrap: wrap; }
|
||||
|
||||
.tag {
|
||||
font-size: 11px;
|
||||
padding: 0.1rem 0.35rem;
|
||||
background: #2a2a3a;
|
||||
border-radius: 3px;
|
||||
color: #8899cc;
|
||||
}
|
||||
|
||||
.task-actions { display: flex; gap: 0.4rem; }
|
||||
|
||||
/* Buttons */
|
||||
button {
|
||||
cursor: pointer;
|
||||
border: none;
|
||||
border-radius: 4px;
|
||||
font-size: 13px;
|
||||
padding: 0.3rem 0.7rem;
|
||||
background: #333;
|
||||
color: #ccc;
|
||||
}
|
||||
|
||||
button:hover { background: #444; color: #fff; }
|
||||
|
||||
.btn-primary {
|
||||
background: #2a4a7a;
|
||||
color: #8bb8f0;
|
||||
}
|
||||
|
||||
.btn-primary:hover { background: #335a90; color: #c0d8ff; }
|
||||
|
||||
.btn-danger { background: #4a1a1a; color: #e08080; }
|
||||
.btn-danger:hover { background: #5a2020; color: #ffaaaa; }
|
||||
|
||||
.btn-edit { font-size: 12px; padding: 0.2rem 0.5rem; }
|
||||
.btn-delete { font-size: 12px; padding: 0.2rem 0.5rem; }
|
||||
|
||||
/* Dialog */
|
||||
dialog {
|
||||
background: #242424;
|
||||
border: 1px solid #444;
|
||||
border-radius: 8px;
|
||||
color: #e0e0e0;
|
||||
padding: 1.5rem;
|
||||
width: 480px;
|
||||
max-width: 95vw;
|
||||
}
|
||||
|
||||
dialog::backdrop { background: rgba(0,0,0,0.6); }
|
||||
|
||||
dialog h2 { margin-bottom: 1rem; font-size: 1rem; }
|
||||
|
||||
dialog label {
|
||||
display: flex;
|
||||
flex-direction: column;
|
||||
gap: 0.3rem;
|
||||
margin-bottom: 0.75rem;
|
||||
font-size: 12px;
|
||||
color: #999;
|
||||
}
|
||||
|
||||
dialog input, dialog textarea, dialog select {
|
||||
padding: 0.4rem 0.6rem;
|
||||
background: #1a1a1a;
|
||||
border: 1px solid #444;
|
||||
border-radius: 4px;
|
||||
color: #e0e0e0;
|
||||
font-size: 13px;
|
||||
font-family: inherit;
|
||||
resize: vertical;
|
||||
}
|
||||
|
||||
dialog input:focus, dialog textarea:focus, dialog select:focus {
|
||||
outline: none;
|
||||
border-color: #5588e0;
|
||||
}
|
||||
|
||||
.dialog-buttons {
|
||||
display: flex;
|
||||
gap: 0.5rem;
|
||||
justify-content: flex-end;
|
||||
margin-top: 1rem;
|
||||
}
|
||||
|
||||
.empty-state {
|
||||
color: #555;
|
||||
padding: 2rem;
|
||||
text-align: center;
|
||||
}
|
||||
51
public/templates.html
Normal file
51
public/templates.html
Normal file
@@ -0,0 +1,51 @@
|
||||
<!-- Task list item -->
|
||||
<template id="t-task-row">
|
||||
<div class="task-row">
|
||||
<span class="task-id"></span>
|
||||
<span class="task-status"></span>
|
||||
<span class="task-priority"></span>
|
||||
<span class="task-title"></span>
|
||||
<span class="task-tags"></span>
|
||||
<div class="task-actions">
|
||||
<button class="btn-edit">Edit</button>
|
||||
<button class="btn-delete">Delete</button>
|
||||
</div>
|
||||
</div>
|
||||
</template>
|
||||
|
||||
<!-- Task form dialog -->
|
||||
<template id="t-task-dialog">
|
||||
<dialog class="task-dialog">
|
||||
<form method="dialog">
|
||||
<h2 class="dialog-title"></h2>
|
||||
<label>Title
|
||||
<input name="title" type="text" required autocomplete="off">
|
||||
</label>
|
||||
<label>Body
|
||||
<textarea name="body" rows="4"></textarea>
|
||||
</label>
|
||||
<label>Status
|
||||
<select name="status">
|
||||
<option value="open">Open</option>
|
||||
<option value="deferred">Deferred</option>
|
||||
<option value="done">Done</option>
|
||||
<option value="cancelled">Cancelled</option>
|
||||
</select>
|
||||
</label>
|
||||
<label>Priority
|
||||
<select name="priority">
|
||||
<option value="high">High</option>
|
||||
<option value="normal" selected>Normal</option>
|
||||
<option value="low">Low</option>
|
||||
</select>
|
||||
</label>
|
||||
<label>Tags (comma-separated)
|
||||
<input name="tags" type="text" autocomplete="off">
|
||||
</label>
|
||||
<div class="dialog-buttons">
|
||||
<button type="submit" class="btn-save">Save</button>
|
||||
<button type="button" class="btn-cancel">Cancel</button>
|
||||
</div>
|
||||
</form>
|
||||
</dialog>
|
||||
</template>
|
||||
Reference in New Issue
Block a user