Initial commit: repair cafe kanban board
A per-event queue board for a repair cafe. Customers sign items in on a shared tablet; volunteers drag repairs through Queue, In progress and Done on a dashboard, then export the day's records as CSV. Zero npm dependencies: node:http plus JSON files on disk, no build step. Categories and house rules are set in config.json. Events are deleted 7 days after creation. Co-Authored-By: Claude Opus 5 <noreply@anthropic.com>
This commit is contained in:
+328
@@ -0,0 +1,328 @@
|
||||
/* Deliberately plain: flat background, plain buttons, no decoration. */
|
||||
|
||||
* { box-sizing: border-box; }
|
||||
|
||||
body {
|
||||
margin: 0;
|
||||
padding: 0;
|
||||
background: #eceae5;
|
||||
color: #1c1c1c;
|
||||
font: 16px/1.5 system-ui, -apple-system, "Segoe UI", sans-serif;
|
||||
}
|
||||
|
||||
h1 { font-size: 24px; margin: 0 0 4px; }
|
||||
h2 { font-size: 18px; margin: 0 0 12px; }
|
||||
p { margin: 0 0 12px; }
|
||||
a { color: #1f4f7a; }
|
||||
|
||||
.wrap {
|
||||
max-width: 620px;
|
||||
margin: 0 auto;
|
||||
padding: 32px 20px 60px;
|
||||
}
|
||||
|
||||
.subtitle { color: #555; margin-bottom: 28px; }
|
||||
|
||||
/* ------------------------------------------------------------------ inputs */
|
||||
|
||||
label {
|
||||
display: block;
|
||||
margin: 0 0 4px;
|
||||
font-weight: 600;
|
||||
}
|
||||
|
||||
input[type="text"],
|
||||
input[type="search"],
|
||||
textarea,
|
||||
select {
|
||||
width: 100%;
|
||||
padding: 9px 10px;
|
||||
border: 1px solid #9a958c;
|
||||
border-radius: 4px;
|
||||
background: #fff;
|
||||
color: inherit;
|
||||
font: inherit;
|
||||
}
|
||||
|
||||
textarea { resize: vertical; min-height: 90px; }
|
||||
|
||||
input:focus-visible,
|
||||
textarea:focus-visible,
|
||||
select:focus-visible,
|
||||
button:focus-visible,
|
||||
a:focus-visible,
|
||||
.card:focus-visible {
|
||||
outline: 2px solid #1f4f7a;
|
||||
outline-offset: 1px;
|
||||
}
|
||||
|
||||
.field { margin-bottom: 18px; }
|
||||
.hint { font-weight: 400; color: #555; font-size: 14px; }
|
||||
|
||||
.check {
|
||||
display: flex;
|
||||
align-items: flex-start;
|
||||
gap: 9px;
|
||||
font-weight: 400;
|
||||
}
|
||||
.check input { margin-top: 4px; width: 18px; height: 18px; flex: 0 0 auto; }
|
||||
|
||||
button {
|
||||
padding: 10px 18px;
|
||||
border: 1px solid #6d6862;
|
||||
border-radius: 4px;
|
||||
background: #dcd8d1;
|
||||
color: inherit;
|
||||
font: inherit;
|
||||
cursor: pointer;
|
||||
}
|
||||
button:hover { background: #d0ccc4; }
|
||||
button:active { background: #c4bfb6; }
|
||||
|
||||
button.primary {
|
||||
background: #1f4f7a;
|
||||
border-color: #163a5a;
|
||||
color: #fff;
|
||||
font-weight: 600;
|
||||
}
|
||||
button.primary:hover { background: #1a4468; }
|
||||
|
||||
button.big { padding: 16px 26px; font-size: 18px; }
|
||||
button.small { padding: 5px 10px; font-size: 14px; }
|
||||
|
||||
.row { display: flex; gap: 10px; align-items: center; flex-wrap: wrap; }
|
||||
|
||||
/* ------------------------------------------------------------------ notices */
|
||||
|
||||
.notice {
|
||||
padding: 10px 12px;
|
||||
border-radius: 4px;
|
||||
border: 1px solid #6d6862;
|
||||
background: #dcd8d1;
|
||||
margin-bottom: 16px;
|
||||
}
|
||||
.notice.ok { background: #d8e8d6; border-color: #4a7a46; }
|
||||
.notice.error { background: #f2d7d5; border-color: #a33c33; }
|
||||
.notice[hidden] { display: none; }
|
||||
|
||||
.url-preview {
|
||||
font-family: ui-monospace, "SFMono-Regular", Menlo, Consolas, monospace;
|
||||
background: #fff;
|
||||
border: 1px solid #cbc6bd;
|
||||
border-radius: 4px;
|
||||
padding: 8px 10px;
|
||||
word-break: break-all;
|
||||
}
|
||||
|
||||
/* ---------------------------------------------------------------- dashboard */
|
||||
|
||||
body.dashboard { height: 100vh; display: flex; flex-direction: column; overflow: hidden; }
|
||||
|
||||
.board-header {
|
||||
flex: 0 0 auto;
|
||||
display: flex;
|
||||
align-items: baseline;
|
||||
gap: 16px;
|
||||
flex-wrap: wrap;
|
||||
padding: 12px 18px;
|
||||
background: #dcd8d1;
|
||||
border-bottom: 1px solid #b8b3aa;
|
||||
}
|
||||
.board-header h1 { font-size: 20px; margin: 0; }
|
||||
.board-header .spacer { flex: 1 1 auto; }
|
||||
.board-header .meta { color: #555; font-size: 14px; }
|
||||
|
||||
.board {
|
||||
flex: 1 1 auto;
|
||||
display: grid;
|
||||
grid-template-columns: repeat(3, 1fr);
|
||||
gap: 14px;
|
||||
padding: 14px 18px 18px;
|
||||
min-height: 0;
|
||||
}
|
||||
|
||||
.column {
|
||||
display: flex;
|
||||
flex-direction: column;
|
||||
min-height: 0;
|
||||
background: #e2dfd9;
|
||||
border: 1px solid #b8b3aa;
|
||||
border-radius: 6px;
|
||||
}
|
||||
|
||||
.column-header {
|
||||
flex: 0 0 auto;
|
||||
display: flex;
|
||||
align-items: center;
|
||||
gap: 8px;
|
||||
padding: 8px 10px;
|
||||
border-bottom: 1px solid #b8b3aa;
|
||||
}
|
||||
.column-header h2 { margin: 0; font-size: 16px; }
|
||||
.column-header .count { color: #555; font-size: 14px; margin-right: auto; }
|
||||
.column-header select { width: auto; padding: 3px 6px; font-size: 13px; }
|
||||
|
||||
/* The scroll container the spec asks for: each column scrolls on its own. */
|
||||
.cards {
|
||||
flex: 1 1 auto;
|
||||
overflow-y: auto;
|
||||
padding: 10px;
|
||||
display: flex;
|
||||
flex-direction: column;
|
||||
gap: 10px;
|
||||
}
|
||||
|
||||
.card {
|
||||
background: #fff;
|
||||
border: 1px solid #b8b3aa;
|
||||
border-left: 4px solid #b8b3aa;
|
||||
border-radius: 6px;
|
||||
padding: 9px 11px;
|
||||
cursor: grab;
|
||||
/* Let a finger scroll the column; the grip below opts out for dragging. */
|
||||
touch-action: pan-y;
|
||||
user-select: none;
|
||||
}
|
||||
.card.incomplete { border-color: #c0392b; border-width: 2px; border-left-width: 4px; }
|
||||
|
||||
.card-top { display: flex; align-items: flex-start; gap: 8px; }
|
||||
.card-title {
|
||||
flex: 1 1 auto;
|
||||
font-weight: 600;
|
||||
cursor: pointer;
|
||||
text-decoration: underline;
|
||||
text-decoration-color: #b8b3aa;
|
||||
text-underline-offset: 2px;
|
||||
}
|
||||
.card-grip {
|
||||
flex: 0 0 auto;
|
||||
margin: -4px -4px 0 0;
|
||||
padding: 4px 6px;
|
||||
color: #9a958c;
|
||||
line-height: 1;
|
||||
cursor: grab;
|
||||
touch-action: none;
|
||||
}
|
||||
.card-grip:hover { color: #555; }
|
||||
.card-line { font-size: 14px; color: #444; margin-top: 3px; }
|
||||
.card-line b { font-weight: 600; color: #222; }
|
||||
|
||||
.chip {
|
||||
display: inline-block;
|
||||
margin-top: 7px;
|
||||
padding: 2px 9px;
|
||||
border-radius: 999px;
|
||||
background: #6d6862;
|
||||
color: #fff;
|
||||
font-size: 12px;
|
||||
font-weight: 600;
|
||||
letter-spacing: 0.01em;
|
||||
}
|
||||
|
||||
.card.dragging {
|
||||
position: fixed;
|
||||
z-index: 50;
|
||||
width: var(--drag-width);
|
||||
pointer-events: none;
|
||||
cursor: grabbing;
|
||||
box-shadow: 0 6px 16px rgba(0, 0, 0, 0.25);
|
||||
transform: rotate(1deg);
|
||||
}
|
||||
|
||||
/* Dashed box showing where the card would land. */
|
||||
.placeholder {
|
||||
border: 2px dashed #6d6862;
|
||||
border-radius: 6px;
|
||||
background: rgba(255, 255, 255, 0.35);
|
||||
}
|
||||
|
||||
.empty-note { color: #6d6862; font-size: 14px; font-style: italic; }
|
||||
|
||||
/* ----------------------------------------------------------------- dialogs */
|
||||
|
||||
dialog {
|
||||
border: 1px solid #6d6862;
|
||||
border-radius: 6px;
|
||||
background: #eceae5;
|
||||
color: inherit;
|
||||
padding: 0;
|
||||
max-width: 560px;
|
||||
width: calc(100vw - 32px);
|
||||
}
|
||||
dialog::backdrop { background: rgba(0, 0, 0, 0.45); }
|
||||
.dialog-body { padding: 20px; }
|
||||
.dialog-body h2 { margin-bottom: 14px; }
|
||||
.dialog-actions {
|
||||
display: flex;
|
||||
gap: 10px;
|
||||
justify-content: flex-end;
|
||||
margin-top: 18px;
|
||||
flex-wrap: wrap;
|
||||
}
|
||||
.dialog-actions .left { margin-right: auto; }
|
||||
|
||||
.rules-text { white-space: pre-wrap; max-height: 60vh; overflow-y: auto; }
|
||||
|
||||
/* ---------------------------------------------------- detail overlay fields */
|
||||
|
||||
#detail { max-width: 640px; }
|
||||
#detail .dialog-body { max-height: 85vh; overflow-y: auto; }
|
||||
|
||||
/* The record is long enough to scroll, so keep Close on screen. */
|
||||
#detail .dialog-actions {
|
||||
position: sticky;
|
||||
bottom: -20px;
|
||||
margin: 18px -20px -20px;
|
||||
padding: 12px 20px;
|
||||
background: #eceae5;
|
||||
border-top: 1px solid #cbc6bd;
|
||||
}
|
||||
|
||||
/* Two columns for the short fields, so the notes are visible without scrolling. */
|
||||
#detail-fields { display: grid; grid-template-columns: 1fr 1fr; column-gap: 14px; }
|
||||
.detail-field { margin-bottom: 10px; min-width: 0; }
|
||||
.detail-field.wide { grid-column: 1 / -1; }
|
||||
.detail-field > label {
|
||||
font-size: 12px;
|
||||
text-transform: uppercase;
|
||||
letter-spacing: 0.04em;
|
||||
color: #555;
|
||||
margin-bottom: 2px;
|
||||
}
|
||||
|
||||
/* Click-to-edit: the value looks like text until you click it. */
|
||||
.editable {
|
||||
display: block;
|
||||
width: 100%;
|
||||
text-align: left;
|
||||
padding: 7px 9px;
|
||||
border: 1px solid transparent;
|
||||
border-radius: 4px;
|
||||
background: #fff;
|
||||
font: inherit;
|
||||
color: inherit;
|
||||
cursor: text;
|
||||
white-space: pre-wrap;
|
||||
min-height: 34px;
|
||||
}
|
||||
.editable:hover { border-color: #9a958c; }
|
||||
.editable.empty { color: #8a857d; font-style: italic; }
|
||||
.editable.readonly { background: transparent; cursor: default; color: #555; }
|
||||
.editable.readonly:hover { border-color: transparent; }
|
||||
|
||||
.notes-list { list-style: none; margin: 0 0 10px; padding: 0; }
|
||||
.notes-list li {
|
||||
background: #fff;
|
||||
border: 1px solid #cbc6bd;
|
||||
border-radius: 4px;
|
||||
padding: 7px 9px;
|
||||
margin-bottom: 7px;
|
||||
white-space: pre-wrap;
|
||||
}
|
||||
.notes-list time { display: block; font-size: 12px; color: #666; margin-bottom: 2px; }
|
||||
|
||||
@media (max-width: 800px) {
|
||||
.board { grid-template-columns: 1fr; overflow-y: auto; }
|
||||
body.dashboard { overflow: auto; height: auto; }
|
||||
.column { min-height: 240px; }
|
||||
}
|
||||
@@ -0,0 +1,104 @@
|
||||
<!doctype html>
|
||||
<html lang="en">
|
||||
<head>
|
||||
<meta charset="utf-8">
|
||||
<meta name="viewport" content="width=device-width, initial-scale=1">
|
||||
<title>Dashboard</title>
|
||||
<link rel="stylesheet" href="/static/app.css">
|
||||
</head>
|
||||
<body class="dashboard">
|
||||
|
||||
<header class="board-header">
|
||||
<h1 id="event-name"> </h1>
|
||||
<span class="meta" id="expiry"></span>
|
||||
<span class="spacer"></span>
|
||||
<span class="meta" id="status-line"></span>
|
||||
<a id="intake-link" href="">Sign-in form</a>
|
||||
<a id="csv-link" href="">Download CSV</a>
|
||||
</header>
|
||||
|
||||
<main class="board" id="board"></main>
|
||||
|
||||
<template id="column-template">
|
||||
<section class="column">
|
||||
<div class="column-header">
|
||||
<h2></h2>
|
||||
<span class="count"></span>
|
||||
<label class="hint" style="font-weight:400">sort
|
||||
<select class="sort">
|
||||
<option value="time">time added</option>
|
||||
<option value="category">item category</option>
|
||||
</select>
|
||||
</label>
|
||||
</div>
|
||||
<div class="cards"></div>
|
||||
</section>
|
||||
</template>
|
||||
|
||||
<!-- queue -> in progress -->
|
||||
<dialog id="repairer-dialog">
|
||||
<form method="dialog" class="dialog-body">
|
||||
<h2>Who is repairing this?</h2>
|
||||
<div class="field">
|
||||
<label for="repairer-input">Volunteer name</label>
|
||||
<input type="text" id="repairer-input" maxlength="80" autocomplete="off">
|
||||
</div>
|
||||
<div class="dialog-actions">
|
||||
<button type="button" data-close="cancel">Cancel</button>
|
||||
<button value="save" class="primary">Save</button>
|
||||
</div>
|
||||
</form>
|
||||
</dialog>
|
||||
|
||||
<!-- in progress -> done -->
|
||||
<dialog id="done-dialog">
|
||||
<form method="dialog" class="dialog-body">
|
||||
<h2>How did it go?</h2>
|
||||
|
||||
<div class="field">
|
||||
<label>Result</label>
|
||||
<label class="check"><input type="radio" name="outcome" value="fixed"> <span>Fixed</span></label>
|
||||
<label class="check"><input type="radio" name="outcome" value="partially fixed"> <span>Partially fixed</span></label>
|
||||
<label class="check"><input type="radio" name="outcome" value="not fixed"> <span>Not fixed</span></label>
|
||||
</div>
|
||||
|
||||
<div class="field">
|
||||
<label for="tools-input">Tools / consumables used <span class="hint">— optional</span></label>
|
||||
<input type="text" id="tools-input" maxlength="300" autocomplete="off">
|
||||
</div>
|
||||
|
||||
<div class="field">
|
||||
<label for="done-note">Notes <span class="hint">— optional</span></label>
|
||||
<textarea id="done-note" maxlength="2000"></textarea>
|
||||
</div>
|
||||
|
||||
<div class="dialog-actions">
|
||||
<button type="button" data-close="cancel" class="left">Cancel</button>
|
||||
<button type="button" data-close="skip">Skip for now</button>
|
||||
<button value="save" class="primary">Save</button>
|
||||
</div>
|
||||
</form>
|
||||
</dialog>
|
||||
|
||||
<!-- card detail -->
|
||||
<dialog id="detail">
|
||||
<div class="dialog-body">
|
||||
<h2 id="detail-title"></h2>
|
||||
<div id="detail-fields"></div>
|
||||
|
||||
<div class="detail-field">
|
||||
<label>Notes</label>
|
||||
<ul class="notes-list" id="notes-list"></ul>
|
||||
<textarea id="new-note" placeholder="Add a note…" maxlength="2000" rows="2"></textarea>
|
||||
<div style="margin-top:8px"><button type="button" class="small" id="add-note">Add note</button></div>
|
||||
</div>
|
||||
|
||||
<div class="dialog-actions">
|
||||
<button type="button" id="detail-close" class="primary">Close</button>
|
||||
</div>
|
||||
</div>
|
||||
</dialog>
|
||||
|
||||
<script src="/static/dashboard.js"></script>
|
||||
</body>
|
||||
</html>
|
||||
@@ -0,0 +1,544 @@
|
||||
'use strict';
|
||||
|
||||
const slug = location.pathname.split('/').filter(Boolean)[0];
|
||||
const POLL_MS = 4000;
|
||||
|
||||
const COLUMNS = [
|
||||
{ status: 'queue', title: 'Queue' },
|
||||
{ status: 'in-progress', title: 'In progress' },
|
||||
{ status: 'done', title: 'Done' },
|
||||
];
|
||||
|
||||
const board = document.getElementById('board');
|
||||
const statusLine = document.getElementById('status-line');
|
||||
|
||||
let event = null;
|
||||
let categories = [];
|
||||
const colorOf = new Map();
|
||||
const columnEls = new Map(); // status -> { section, cards, count, sort }
|
||||
let drag = null;
|
||||
|
||||
/* --------------------------------------------------------------- utilities */
|
||||
|
||||
const byId = (id) => event.repairs.find((r) => r.id === id);
|
||||
const isIncomplete = (r) => r.status === 'done' && !r.outcome;
|
||||
|
||||
function el(tag, className, text) {
|
||||
const node = document.createElement(tag);
|
||||
if (className) node.className = className;
|
||||
if (text !== undefined) node.textContent = text;
|
||||
return node;
|
||||
}
|
||||
|
||||
function formatTime(iso) {
|
||||
const d = new Date(iso);
|
||||
return d.toLocaleString(undefined, {
|
||||
day: 'numeric', month: 'short', hour: '2-digit', minute: '2-digit',
|
||||
});
|
||||
}
|
||||
|
||||
function sortKey(status) {
|
||||
return localStorage.getItem(`sort:${slug}:${status}`) || 'time';
|
||||
}
|
||||
|
||||
function setSortKey(status, value) {
|
||||
localStorage.setItem(`sort:${slug}:${status}`, value);
|
||||
}
|
||||
|
||||
function sortRepairs(list, key) {
|
||||
const byTime = (a, b) => a.createdAt.localeCompare(b.createdAt);
|
||||
if (key === 'category') {
|
||||
return list.slice().sort((a, b) => a.category.localeCompare(b.category) || byTime(a, b));
|
||||
}
|
||||
return list.slice().sort(byTime);
|
||||
}
|
||||
|
||||
function showStatus(message, isError) {
|
||||
statusLine.textContent = message || '';
|
||||
statusLine.style.color = isError ? '#a33c33' : '#555';
|
||||
}
|
||||
|
||||
/* --------------------------------------------------------------- API calls */
|
||||
|
||||
async function api(path, options) {
|
||||
const res = await fetch(path, options);
|
||||
const data = await res.json().catch(() => ({}));
|
||||
if (!res.ok) throw new Error(data.error || 'The server said no.');
|
||||
return data;
|
||||
}
|
||||
|
||||
async function patchRepair(id, patch) {
|
||||
try {
|
||||
const updated = await api(`/api/events/${slug}/repairs/${id}`, {
|
||||
method: 'PATCH',
|
||||
headers: { 'Content-Type': 'application/json' },
|
||||
body: JSON.stringify(patch),
|
||||
});
|
||||
const index = event.repairs.findIndex((r) => r.id === id);
|
||||
if (index >= 0) event.repairs[index] = updated;
|
||||
showStatus('');
|
||||
render();
|
||||
return updated;
|
||||
} catch (err) {
|
||||
showStatus(err.message, true);
|
||||
await refresh(); // put the board back to whatever the server actually has
|
||||
throw err;
|
||||
}
|
||||
}
|
||||
|
||||
async function refresh() {
|
||||
const data = await api(`/api/events/${slug}`);
|
||||
event = data;
|
||||
render();
|
||||
}
|
||||
|
||||
/* ------------------------------------------------------------------ render */
|
||||
|
||||
function buildColumns() {
|
||||
const template = document.getElementById('column-template');
|
||||
for (const column of COLUMNS) {
|
||||
const section = template.content.firstElementChild.cloneNode(true);
|
||||
section.dataset.status = column.status;
|
||||
section.querySelector('h2').textContent = column.title;
|
||||
|
||||
const select = section.querySelector('.sort');
|
||||
select.value = sortKey(column.status);
|
||||
select.addEventListener('change', () => {
|
||||
setSortKey(column.status, select.value);
|
||||
render();
|
||||
});
|
||||
|
||||
board.append(section);
|
||||
columnEls.set(column.status, {
|
||||
section,
|
||||
cards: section.querySelector('.cards'),
|
||||
count: section.querySelector('.count'),
|
||||
});
|
||||
}
|
||||
}
|
||||
|
||||
function buildCard(repair) {
|
||||
const card = el('div', 'card');
|
||||
card.dataset.id = repair.id;
|
||||
if (isIncomplete(repair)) card.classList.add('incomplete');
|
||||
|
||||
const top = el('div', 'card-top');
|
||||
const title = el('div', 'card-title', repair.item);
|
||||
title.addEventListener('click', (e) => {
|
||||
e.stopPropagation();
|
||||
openDetail(repair.id);
|
||||
});
|
||||
const grip = el('span', 'card-grip', '⠿');
|
||||
grip.title = 'Drag to move';
|
||||
top.append(title, grip);
|
||||
card.append(top);
|
||||
|
||||
card.append(el('div', 'card-line', repair.customer));
|
||||
|
||||
if (repair.status === 'in-progress' && repair.repairer) {
|
||||
const line = el('div', 'card-line');
|
||||
line.append(el('b', null, 'Repairer: '), document.createTextNode(repair.repairer));
|
||||
card.append(line);
|
||||
}
|
||||
|
||||
if (repair.status === 'done') {
|
||||
const line = el('div', 'card-line');
|
||||
line.append(el('b', null, 'Result: '), document.createTextNode(repair.outcome || 'not recorded'));
|
||||
card.append(line);
|
||||
}
|
||||
|
||||
const chip = el('span', 'chip', repair.category);
|
||||
chip.style.background = colorOf.get(repair.category) || '#6d6862';
|
||||
card.append(chip);
|
||||
|
||||
return card;
|
||||
}
|
||||
|
||||
function render() {
|
||||
if (!event) return;
|
||||
|
||||
document.getElementById('event-name').textContent = event.name;
|
||||
document.title = `Dashboard — ${event.name}`;
|
||||
|
||||
for (const column of COLUMNS) {
|
||||
const { cards, count } = columnEls.get(column.status);
|
||||
const list = sortRepairs(event.repairs.filter((r) => r.status === column.status), sortKey(column.status));
|
||||
|
||||
const scroll = cards.scrollTop;
|
||||
cards.replaceChildren();
|
||||
for (const repair of list) cards.append(buildCard(repair));
|
||||
if (list.length === 0) cards.append(el('p', 'empty-note', 'Nothing here yet.'));
|
||||
cards.scrollTop = scroll;
|
||||
|
||||
count.textContent = String(list.length);
|
||||
}
|
||||
|
||||
// Keep an open detail overlay in step with whatever just changed.
|
||||
if (detailId && detail.open) renderDetail();
|
||||
}
|
||||
|
||||
/* ------------------------------------------------------------------ drag */
|
||||
|
||||
// Custom pointer dragging rather than HTML5 drag-and-drop, so this works on a
|
||||
// tablet as well as a laptop.
|
||||
function onPointerDown(e) {
|
||||
if (e.button !== undefined && e.button !== 0) return;
|
||||
const card = e.target.closest('.card');
|
||||
if (!card || !board.contains(card)) return;
|
||||
if (e.target.closest('.card-title')) return; // the title opens the detail view
|
||||
|
||||
// A mouse can grab the card anywhere. A finger has to use the grip, so that
|
||||
// sliding a finger up the column still scrolls it.
|
||||
if (e.pointerType !== 'mouse' && !e.target.closest('.card-grip')) return;
|
||||
|
||||
drag = {
|
||||
card,
|
||||
id: card.dataset.id,
|
||||
pointerId: e.pointerId,
|
||||
startX: e.clientX,
|
||||
startY: e.clientY,
|
||||
offsetX: 0,
|
||||
offsetY: 0,
|
||||
started: false,
|
||||
placeholder: null,
|
||||
};
|
||||
|
||||
window.addEventListener('pointermove', onPointerMove);
|
||||
window.addEventListener('pointerup', onPointerUp);
|
||||
window.addEventListener('pointercancel', onPointerUp);
|
||||
}
|
||||
|
||||
function startDrag(e) {
|
||||
const { card } = drag;
|
||||
const rect = card.getBoundingClientRect();
|
||||
|
||||
const placeholder = el('div', 'placeholder');
|
||||
placeholder.style.height = `${rect.height}px`;
|
||||
card.after(placeholder);
|
||||
drag.placeholder = placeholder;
|
||||
|
||||
drag.offsetX = drag.startX - rect.left;
|
||||
drag.offsetY = drag.startY - rect.top;
|
||||
card.style.setProperty('--drag-width', `${rect.width}px`);
|
||||
card.classList.add('dragging');
|
||||
document.body.style.cursor = 'grabbing';
|
||||
drag.started = true;
|
||||
moveCard(e);
|
||||
}
|
||||
|
||||
function moveCard(e) {
|
||||
drag.card.style.left = `${e.clientX - drag.offsetX}px`;
|
||||
drag.card.style.top = `${e.clientY - drag.offsetY}px`;
|
||||
}
|
||||
|
||||
// Nudge a column that the pointer is hovering near the edge of, so you can drag
|
||||
// into a part of a long list that is off screen.
|
||||
function autoScroll(container, y) {
|
||||
const rect = container.getBoundingClientRect();
|
||||
const zone = 48;
|
||||
if (y < rect.top + zone) container.scrollTop -= 12;
|
||||
else if (y > rect.bottom - zone) container.scrollTop += 12;
|
||||
}
|
||||
|
||||
function placeholderTarget(x, y) {
|
||||
const under = document.elementFromPoint(x, y);
|
||||
const container = under && under.closest ? under.closest('.cards') : null;
|
||||
if (!container) return null;
|
||||
|
||||
autoScroll(container, y);
|
||||
|
||||
const siblings = [...container.querySelectorAll('.card')].filter((c) => c !== drag.card);
|
||||
let before = null;
|
||||
for (const sibling of siblings) {
|
||||
const rect = sibling.getBoundingClientRect();
|
||||
if (y < rect.top + rect.height / 2) { before = sibling; break; }
|
||||
}
|
||||
return { container, before };
|
||||
}
|
||||
|
||||
function onPointerMove(e) {
|
||||
if (!drag) return;
|
||||
if (!drag.started) {
|
||||
if (Math.hypot(e.clientX - drag.startX, e.clientY - drag.startY) < 6) return;
|
||||
startDrag(e);
|
||||
}
|
||||
e.preventDefault();
|
||||
moveCard(e);
|
||||
|
||||
const target = placeholderTarget(e.clientX, e.clientY);
|
||||
if (target) {
|
||||
const { container, before } = target;
|
||||
if (before) container.insertBefore(drag.placeholder, before);
|
||||
else container.append(drag.placeholder);
|
||||
}
|
||||
}
|
||||
|
||||
function endDrag() {
|
||||
const { card, placeholder } = drag;
|
||||
card.classList.remove('dragging');
|
||||
card.style.left = card.style.top = '';
|
||||
card.style.removeProperty('--drag-width');
|
||||
if (placeholder) placeholder.remove();
|
||||
document.body.style.cursor = '';
|
||||
|
||||
window.removeEventListener('pointermove', onPointerMove);
|
||||
window.removeEventListener('pointerup', onPointerUp);
|
||||
window.removeEventListener('pointercancel', onPointerUp);
|
||||
}
|
||||
|
||||
async function onPointerUp(e) {
|
||||
if (!drag) return;
|
||||
const wasDragging = drag.started;
|
||||
const id = drag.id;
|
||||
const targetStatus = drag.placeholder
|
||||
? drag.placeholder.closest('.column').dataset.status
|
||||
: null;
|
||||
|
||||
endDrag();
|
||||
drag = null;
|
||||
|
||||
if (!wasDragging) return; // a stray click on a card changes nothing
|
||||
if (e.type === 'pointercancel') return render();
|
||||
|
||||
const repair = byId(id);
|
||||
if (!repair || !targetStatus || targetStatus === repair.status) return render();
|
||||
|
||||
await applyMove(repair, targetStatus);
|
||||
}
|
||||
|
||||
async function applyMove(repair, targetStatus) {
|
||||
if (targetStatus === 'in-progress') {
|
||||
// Pulled back out of Done: whoever was repairing it still is, so don't ask
|
||||
// again. Coming up from the queue we do ask, since that may be a hand-over.
|
||||
if (repair.status === 'done' && repair.repairer) {
|
||||
return patchRepair(repair.id, { status: targetStatus }).catch(() => {});
|
||||
}
|
||||
const answer = await askRepairer(repair.repairer || '');
|
||||
if (answer === null) return render();
|
||||
return patchRepair(repair.id, { status: targetStatus, repairer: answer }).catch(() => {});
|
||||
}
|
||||
|
||||
if (targetStatus === 'done') {
|
||||
const answer = await askDone(repair);
|
||||
if (answer === null) return render();
|
||||
const patch = { status: targetStatus };
|
||||
if (answer.filled) {
|
||||
patch.outcome = answer.outcome;
|
||||
patch.toolsUsed = answer.toolsUsed;
|
||||
if (answer.note) patch.addNote = answer.note;
|
||||
}
|
||||
return patchRepair(repair.id, patch).catch(() => {});
|
||||
}
|
||||
|
||||
return patchRepair(repair.id, { status: targetStatus }).catch(() => {});
|
||||
}
|
||||
|
||||
board.addEventListener('pointerdown', onPointerDown);
|
||||
|
||||
/* ---------------------------------------------------------------- dialogs */
|
||||
|
||||
// Cancel and Skip are plain buttons that close the dialog themselves, so the
|
||||
// only submit button left is Save — which makes Save the one Enter triggers.
|
||||
for (const button of document.querySelectorAll('dialog [data-close]')) {
|
||||
button.addEventListener('click', () => button.closest('dialog').close(button.dataset.close));
|
||||
}
|
||||
|
||||
function openDialog(dialog) {
|
||||
return new Promise((resolve) => {
|
||||
dialog.addEventListener('close', () => resolve(dialog.returnValue), { once: true });
|
||||
dialog.showModal();
|
||||
});
|
||||
}
|
||||
|
||||
// Resolves with the volunteer's name, or null if the move was cancelled.
|
||||
async function askRepairer(current) {
|
||||
const dialog = document.getElementById('repairer-dialog');
|
||||
const input = document.getElementById('repairer-input');
|
||||
input.value = current;
|
||||
const result = openDialog(dialog);
|
||||
input.focus();
|
||||
input.select();
|
||||
return (await result) === 'save' ? input.value.trim() : null;
|
||||
}
|
||||
|
||||
// Resolves with the recorded result, {filled:false} if skipped, or null if cancelled.
|
||||
async function askDone(repair) {
|
||||
const dialog = document.getElementById('done-dialog');
|
||||
const tools = document.getElementById('tools-input');
|
||||
const note = document.getElementById('done-note');
|
||||
const radios = [...dialog.querySelectorAll('input[name="outcome"]')];
|
||||
|
||||
for (const radio of radios) radio.checked = radio.value === repair.outcome;
|
||||
tools.value = repair.toolsUsed || '';
|
||||
note.value = '';
|
||||
|
||||
const result = openDialog(dialog);
|
||||
const first = radios.find((r) => r.checked) || radios[0];
|
||||
first.focus();
|
||||
|
||||
const action = await result;
|
||||
if (action === 'save') {
|
||||
const chosen = radios.find((r) => r.checked);
|
||||
return {
|
||||
filled: true,
|
||||
outcome: chosen ? chosen.value : null,
|
||||
toolsUsed: tools.value.trim(),
|
||||
note: note.value.trim(),
|
||||
};
|
||||
}
|
||||
if (action === 'skip') return { filled: false };
|
||||
return null;
|
||||
}
|
||||
|
||||
/* ---------------------------------------------------- detail overlay fields */
|
||||
|
||||
const detail = document.getElementById('detail');
|
||||
let detailId = null;
|
||||
|
||||
// A value that looks like plain text until you click it, then becomes an input.
|
||||
function editable(value, placeholder, makeEditor, onSave) {
|
||||
const view = el('div', 'editable');
|
||||
const setText = (text) => {
|
||||
view.textContent = text || placeholder;
|
||||
view.classList.toggle('empty', !text);
|
||||
};
|
||||
setText(value);
|
||||
|
||||
view.addEventListener('click', () => {
|
||||
const editor = makeEditor(value);
|
||||
let done = false;
|
||||
|
||||
const finish = async (save) => {
|
||||
if (done) return;
|
||||
done = true;
|
||||
const next = editor.value.trim();
|
||||
editor.replaceWith(view);
|
||||
if (save && next !== (value || '')) await onSave(next);
|
||||
else setText(value);
|
||||
};
|
||||
|
||||
editor.addEventListener('blur', () => finish(true));
|
||||
editor.addEventListener('keydown', (e) => {
|
||||
if (e.key === 'Escape') { e.preventDefault(); finish(false); }
|
||||
if (e.key === 'Enter' && editor.tagName !== 'TEXTAREA') { e.preventDefault(); finish(true); }
|
||||
});
|
||||
if (editor.tagName === 'SELECT') editor.addEventListener('change', () => finish(true));
|
||||
|
||||
view.replaceWith(editor);
|
||||
editor.focus();
|
||||
if (editor.select) editor.select();
|
||||
});
|
||||
|
||||
return view;
|
||||
}
|
||||
|
||||
function textEditor(value) {
|
||||
const input = el('input');
|
||||
input.type = 'text';
|
||||
input.value = value || '';
|
||||
return input;
|
||||
}
|
||||
|
||||
function areaEditor(value) {
|
||||
const area = el('textarea');
|
||||
area.value = value || '';
|
||||
area.rows = 4;
|
||||
return area;
|
||||
}
|
||||
|
||||
function selectEditor(options, allowBlank, blankLabel) {
|
||||
return (value) => {
|
||||
const select = el('select');
|
||||
if (allowBlank) select.append(new Option(blankLabel, ''));
|
||||
for (const option of options) select.append(new Option(option, option));
|
||||
select.value = value || '';
|
||||
return select;
|
||||
};
|
||||
}
|
||||
|
||||
// `wide` fields span both columns of the overlay grid.
|
||||
function detailField(label, view, wide) {
|
||||
const wrap = el('div', `detail-field${wide ? ' wide' : ''}`);
|
||||
wrap.append(el('label', null, label), view);
|
||||
return wrap;
|
||||
}
|
||||
|
||||
function renderDetail() {
|
||||
const repair = byId(detailId);
|
||||
if (!repair) return detail.close();
|
||||
|
||||
document.getElementById('detail-title').textContent = repair.item;
|
||||
|
||||
const save = (field) => (value) => patchRepair(repair.id, { [field]: value }).catch(() => {});
|
||||
|
||||
const fields = document.getElementById('detail-fields');
|
||||
fields.replaceChildren(
|
||||
detailField('Item', editable(repair.item, '—', textEditor, save('item'))),
|
||||
detailField('Customer', editable(repair.customer, '—', textEditor, save('customer'))),
|
||||
detailField('Problem', editable(repair.problem, 'No description given', areaEditor, save('problem')), true),
|
||||
detailField('Category', editable(repair.category, '—', selectEditor(categories, false), save('category'))),
|
||||
detailField('Status', editable(repair.status, '—', selectEditor(COLUMNS.map((c) => c.status), false), save('status'))),
|
||||
detailField('Repairer', editable(repair.repairer, 'Nobody yet', textEditor, save('repairer'))),
|
||||
detailField('Result', editable(repair.outcome, 'Not recorded', selectEditor(['fixed', 'partially fixed', 'not fixed'], true, 'Not recorded'), save('outcome'))),
|
||||
detailField('Tools / consumables', editable(repair.toolsUsed, 'None recorded', textEditor, save('toolsUsed'))),
|
||||
detailField('Signed in at', el('div', 'editable readonly', formatTime(repair.createdAt)))
|
||||
);
|
||||
|
||||
const list = document.getElementById('notes-list');
|
||||
list.replaceChildren();
|
||||
for (const note of repair.notes) {
|
||||
const li = el('li');
|
||||
li.append(el('time', null, formatTime(note.at)), document.createTextNode(note.text));
|
||||
list.append(li);
|
||||
}
|
||||
if (repair.notes.length === 0) list.append(el('li', 'empty-note', 'No notes yet.'));
|
||||
}
|
||||
|
||||
function openDetail(id) {
|
||||
detailId = id;
|
||||
renderDetail();
|
||||
detail.showModal();
|
||||
}
|
||||
|
||||
document.getElementById('detail-close').addEventListener('click', () => detail.close());
|
||||
detail.addEventListener('close', () => { detailId = null; });
|
||||
|
||||
document.getElementById('add-note').addEventListener('click', async () => {
|
||||
const box = document.getElementById('new-note');
|
||||
const text = box.value.trim();
|
||||
if (!text || !detailId) return;
|
||||
await patchRepair(detailId, { addNote: text }).catch(() => {});
|
||||
box.value = '';
|
||||
});
|
||||
|
||||
/* ------------------------------------------------------------------- boot */
|
||||
|
||||
document.getElementById('intake-link').href = `/${slug}`;
|
||||
document.getElementById('csv-link').href = `/${slug}/export.csv`;
|
||||
|
||||
function busy() {
|
||||
return drag !== null || document.querySelector('dialog[open]') !== null;
|
||||
}
|
||||
|
||||
async function init() {
|
||||
const config = await api('/api/config');
|
||||
categories = config.categories.map((c) => c.name);
|
||||
for (const category of config.categories) colorOf.set(category.name, category.color);
|
||||
|
||||
buildColumns();
|
||||
await refresh();
|
||||
|
||||
if (event.expiresAt) {
|
||||
document.getElementById('expiry').textContent =
|
||||
`deleted after ${new Date(event.expiresAt).toLocaleDateString()}`;
|
||||
}
|
||||
|
||||
// Two volunteers on two laptops should see roughly the same board. Pause
|
||||
// while someone is mid-drag or has a dialog open so nothing jumps.
|
||||
setInterval(() => {
|
||||
if (busy()) return;
|
||||
refresh().catch(() => showStatus('Lost contact with the server.', true));
|
||||
}, POLL_MS);
|
||||
}
|
||||
|
||||
init().catch((err) => showStatus(err.message, true));
|
||||
@@ -0,0 +1,17 @@
|
||||
<!doctype html>
|
||||
<html lang="en">
|
||||
<head>
|
||||
<meta charset="utf-8">
|
||||
<meta name="viewport" content="width=device-width, initial-scale=1">
|
||||
<title>Event expired — Repair Cafe Kanban</title>
|
||||
<link rel="stylesheet" href="/static/app.css">
|
||||
</head>
|
||||
<body>
|
||||
<div class="wrap">
|
||||
<h1>This event has expired</h1>
|
||||
<p>Repair cafe boards are kept for a short time and then deleted, along with their data.
|
||||
This one is gone.</p>
|
||||
<p><a href="/">Start a new one</a></p>
|
||||
</div>
|
||||
</body>
|
||||
</html>
|
||||
@@ -0,0 +1,17 @@
|
||||
<!doctype html>
|
||||
<html lang="en">
|
||||
<head>
|
||||
<meta charset="utf-8">
|
||||
<meta name="viewport" content="width=device-width, initial-scale=1">
|
||||
<title>Repair Cafe Kanban</title>
|
||||
<link rel="stylesheet" href="/static/app.css">
|
||||
</head>
|
||||
<body>
|
||||
<div class="wrap">
|
||||
<h1>Repair Cafe Kanban</h1>
|
||||
<p class="subtitle">A queue board for a repair cafe event.</p>
|
||||
|
||||
<p><button class="primary big" onclick="location.href='/new'">Create repair cafe kanban</button></p>
|
||||
</div>
|
||||
</body>
|
||||
</html>
|
||||
@@ -0,0 +1,64 @@
|
||||
<!doctype html>
|
||||
<html lang="en">
|
||||
<head>
|
||||
<meta charset="utf-8">
|
||||
<meta name="viewport" content="width=device-width, initial-scale=1">
|
||||
<title>Sign in a repair</title>
|
||||
<link rel="stylesheet" href="/static/app.css">
|
||||
</head>
|
||||
<body>
|
||||
<div class="wrap">
|
||||
<h1 id="event-name"> </h1>
|
||||
<p class="subtitle">Fill this in and a volunteer will call you when it is your turn.</p>
|
||||
|
||||
<div id="saved" class="notice ok" hidden>Added to the queue. Thank you!</div>
|
||||
<div id="error" class="notice error" hidden></div>
|
||||
|
||||
<form id="form" autocomplete="off">
|
||||
<div class="field">
|
||||
<label for="customer">Your name</label>
|
||||
<input type="text" id="customer" required maxlength="80">
|
||||
</div>
|
||||
|
||||
<div class="field">
|
||||
<label for="item">What have you brought?</label>
|
||||
<input type="text" id="item" required maxlength="120" placeholder="Toaster, table lamp, bike…">
|
||||
</div>
|
||||
|
||||
<div class="field">
|
||||
<label for="category">Category <span class="hint">— start typing, then pick one from the list</span></label>
|
||||
<input type="text" id="category" list="categories" required autocapitalize="none">
|
||||
<datalist id="categories"></datalist>
|
||||
</div>
|
||||
|
||||
<div class="field">
|
||||
<label for="problem">What is wrong with it?</label>
|
||||
<textarea id="problem" maxlength="2000" placeholder="Describe the problem, and anything you have already tried."></textarea>
|
||||
</div>
|
||||
|
||||
<div class="field">
|
||||
<label class="check">
|
||||
<input type="checkbox" id="agree" required>
|
||||
<span>I agree to the <a href="#" id="rules-link">house rules</a></span>
|
||||
</label>
|
||||
</div>
|
||||
|
||||
<button type="submit" class="primary big" id="submit">Submit</button>
|
||||
</form>
|
||||
|
||||
<p style="margin-top:40px"><a id="dashboard-link" href="">Volunteer dashboard</a></p>
|
||||
</div>
|
||||
|
||||
<dialog id="rules">
|
||||
<div class="dialog-body">
|
||||
<h2>House rules</h2>
|
||||
<div class="rules-text" id="rules-text"></div>
|
||||
<div class="dialog-actions">
|
||||
<button type="button" id="rules-ok" class="primary">OK</button>
|
||||
</div>
|
||||
</div>
|
||||
</dialog>
|
||||
|
||||
<script src="/static/intake.js"></script>
|
||||
</body>
|
||||
</html>
|
||||
@@ -0,0 +1,96 @@
|
||||
'use strict';
|
||||
|
||||
const slug = location.pathname.split('/').filter(Boolean)[0];
|
||||
|
||||
const form = document.getElementById('form');
|
||||
const customer = document.getElementById('customer');
|
||||
const item = document.getElementById('item');
|
||||
const category = document.getElementById('category');
|
||||
const problem = document.getElementById('problem');
|
||||
const agree = document.getElementById('agree');
|
||||
const submitBtn = document.getElementById('submit');
|
||||
const savedBox = document.getElementById('saved');
|
||||
const errorBox = document.getElementById('error');
|
||||
const rules = document.getElementById('rules');
|
||||
|
||||
let categories = [];
|
||||
let savedTimer = null;
|
||||
|
||||
document.getElementById('dashboard-link').href = `/${slug}/dashboard`;
|
||||
|
||||
async function init() {
|
||||
const [config, event] = await Promise.all([
|
||||
fetch('/api/config').then((r) => r.json()),
|
||||
fetch(`/api/events/${slug}`).then((r) => r.json()),
|
||||
]);
|
||||
|
||||
document.title = `Sign in a repair — ${event.name}`;
|
||||
document.getElementById('event-name').textContent = event.name;
|
||||
|
||||
categories = config.categories.map((c) => c.name);
|
||||
const list = document.getElementById('categories');
|
||||
for (const name of categories) {
|
||||
const option = document.createElement('option');
|
||||
option.value = name;
|
||||
list.append(option);
|
||||
}
|
||||
|
||||
document.getElementById('rules-text').textContent = config.houseRules;
|
||||
customer.focus();
|
||||
}
|
||||
|
||||
/* house rules popup */
|
||||
document.getElementById('rules-link').addEventListener('click', (e) => {
|
||||
e.preventDefault();
|
||||
rules.showModal();
|
||||
});
|
||||
document.getElementById('rules-ok').addEventListener('click', () => rules.close());
|
||||
|
||||
/* submit */
|
||||
function showError(message) {
|
||||
errorBox.textContent = message;
|
||||
errorBox.hidden = false;
|
||||
savedBox.hidden = true;
|
||||
}
|
||||
|
||||
form.addEventListener('submit', async (e) => {
|
||||
e.preventDefault();
|
||||
errorBox.hidden = true;
|
||||
|
||||
const chosen = category.value.trim().toLowerCase();
|
||||
if (!categories.includes(chosen)) {
|
||||
showError(`Please pick a category from the list: ${categories.join(', ')}.`);
|
||||
category.focus();
|
||||
return;
|
||||
}
|
||||
|
||||
submitBtn.disabled = true;
|
||||
try {
|
||||
const res = await fetch(`/api/events/${slug}/repairs`, {
|
||||
method: 'POST',
|
||||
headers: { 'Content-Type': 'application/json' },
|
||||
body: JSON.stringify({
|
||||
customer: customer.value,
|
||||
item: item.value,
|
||||
category: chosen,
|
||||
problem: problem.value,
|
||||
agreedRules: agree.checked,
|
||||
}),
|
||||
});
|
||||
const data = await res.json();
|
||||
if (!res.ok) throw new Error(data.error || 'Could not submit.');
|
||||
|
||||
// Clear everything and hand the tablet back, ready for the next person.
|
||||
form.reset();
|
||||
customer.focus();
|
||||
savedBox.hidden = false;
|
||||
clearTimeout(savedTimer);
|
||||
savedTimer = setTimeout(() => { savedBox.hidden = true; }, 6000);
|
||||
} catch (err) {
|
||||
showError(err.message);
|
||||
} finally {
|
||||
submitBtn.disabled = false;
|
||||
}
|
||||
});
|
||||
|
||||
init().catch(() => showError('Could not load this event. Try reloading the page.'));
|
||||
@@ -0,0 +1,86 @@
|
||||
<!doctype html>
|
||||
<html lang="en">
|
||||
<head>
|
||||
<meta charset="utf-8">
|
||||
<meta name="viewport" content="width=device-width, initial-scale=1">
|
||||
<title>New event — Repair Cafe Kanban</title>
|
||||
<link rel="stylesheet" href="/static/app.css">
|
||||
</head>
|
||||
<body>
|
||||
<div class="wrap">
|
||||
<h1>Create repair cafe kanban</h1>
|
||||
<p class="subtitle">Set up a board for one event.</p>
|
||||
|
||||
<div id="error" class="notice error" hidden></div>
|
||||
|
||||
<form id="form" autocomplete="off">
|
||||
<div class="field">
|
||||
<label for="name">Name of event</label>
|
||||
<input type="text" id="name" name="name" required maxlength="80" placeholder="Saturday Repair Day">
|
||||
</div>
|
||||
|
||||
<div class="field">
|
||||
<label class="check">
|
||||
<input type="checkbox" id="randomize">
|
||||
<span>Randomise the address<br>
|
||||
<span class="hint">Adds a few random characters to the end so the address is hard to
|
||||
guess or collide with. Useful if you run an event with the same name often.</span></span>
|
||||
</label>
|
||||
</div>
|
||||
|
||||
<div class="field">
|
||||
<label>The board will live at</label>
|
||||
<div class="url-preview" id="preview">…</div>
|
||||
</div>
|
||||
|
||||
<button type="submit" class="primary big" id="create">Create</button>
|
||||
<p style="margin-top:24px"><a href="/">Cancel</a></p>
|
||||
</form>
|
||||
</div>
|
||||
|
||||
<script>
|
||||
const nameInput = document.getElementById('name');
|
||||
const randomize = document.getElementById('randomize');
|
||||
const preview = document.getElementById('preview');
|
||||
const errorBox = document.getElementById('error');
|
||||
const createBtn = document.getElementById('create');
|
||||
|
||||
// Must match slugify() in lib/store.js.
|
||||
function slugify(name) {
|
||||
return name.toLowerCase().trim().replace(/[^a-z0-9]+/g, '-').replace(/^-+|-+$/g, '').slice(0, 60);
|
||||
}
|
||||
|
||||
function updatePreview() {
|
||||
const slug = slugify(nameInput.value);
|
||||
const suffix = randomize.checked ? '-xxxx' : '';
|
||||
preview.textContent = slug ? `${location.origin}/${slug}${suffix}` : '…';
|
||||
}
|
||||
|
||||
nameInput.addEventListener('input', updatePreview);
|
||||
randomize.addEventListener('change', updatePreview);
|
||||
updatePreview();
|
||||
|
||||
document.getElementById('form').addEventListener('submit', async (e) => {
|
||||
e.preventDefault();
|
||||
errorBox.hidden = true;
|
||||
createBtn.disabled = true;
|
||||
try {
|
||||
const res = await fetch('/api/events', {
|
||||
method: 'POST',
|
||||
headers: { 'Content-Type': 'application/json' },
|
||||
body: JSON.stringify({ name: nameInput.value, randomize: randomize.checked }),
|
||||
});
|
||||
const data = await res.json();
|
||||
if (!res.ok) throw new Error(data.error || 'Could not create the event.');
|
||||
location.href = '/' + data.slug;
|
||||
} catch (err) {
|
||||
errorBox.textContent = err.message;
|
||||
errorBox.hidden = false;
|
||||
createBtn.disabled = false;
|
||||
}
|
||||
});
|
||||
|
||||
nameInput.focus();
|
||||
</script>
|
||||
</body>
|
||||
</html>
|
||||
@@ -0,0 +1,16 @@
|
||||
<!doctype html>
|
||||
<html lang="en">
|
||||
<head>
|
||||
<meta charset="utf-8">
|
||||
<meta name="viewport" content="width=device-width, initial-scale=1">
|
||||
<title>Not found — Repair Cafe Kanban</title>
|
||||
<link rel="stylesheet" href="/static/app.css">
|
||||
</head>
|
||||
<body>
|
||||
<div class="wrap">
|
||||
<h1>Not found</h1>
|
||||
<p>There is no repair cafe at this address. Check the link, or start a new one.</p>
|
||||
<p><a href="/">Back to the start</a></p>
|
||||
</div>
|
||||
</body>
|
||||
</html>
|
||||
Reference in New Issue
Block a user