Compare commits

...
10 Commits
Author SHA1 Message Date
ericandClaude Sonnet 5 cdde5bc952 fetch manifest with credentials so it works behind basic auth
Chrome fetches <link rel="manifest"> with credentials omitted by
default, so Caddy's basic_auth was returning 401 and the install
banner never showed. crossorigin="use-credentials" fixes it without
needing to carve auth exceptions into the Caddyfile.

Co-Authored-By: Claude Sonnet 5 <noreply@anthropic.com>
2026-07-18 15:21:29 -04:00
eric 6db47427ad add web app manifest so site can be installed as a desktop app (v4)
Adds manifest.json + a 512x512 icon and links them from layout.njk, so
Edge/Chrome can install the site as a standalone app window from a
desktop shortcut while it keeps loading live from the server.
2026-07-18 15:02:57 -04:00
eric 8ebbbadf98 add release notes for v2, v3 2026-07-16 18:05:00 -04:00
eric 0f58d2fb21 link customer address to a google maps search 2026-07-16 18:01:06 -04:00
eric b8e28e3f1a redirect to customer list after creating a new customer, not edit page 2026-07-16 18:01:06 -04:00
eric ac6f9ff6a3 add favicon 2026-07-16 18:01:06 -04:00
eric e9d4fe31b5 add new customer button and route
customers could only be created indirectly via new order. add a
standalone /customers/new (GET+POST), reusing edit.njk for both create
and edit by keying off customer.id.
2026-07-16 17:31:45 -04:00
eric f546431e5f fix nav overflow on mobile portrait
nav had no flex-wrap, so the brand + 3 links overflowed a phone-width
viewport at the site's 150% base font size - Customers (last link) got
clipped off-screen, only reachable by rotating to landscape. Nav now
wraps, and below 430px the brand drops to its own line above the links.
2026-07-16 17:18:21 -04:00
eric 78897dbc50 rename package to hardings-service, add build-and-tag script 2026-07-16 17:18:12 -04:00
eric 4dddfb9dc2 cleanshsutdown 2026-07-13 07:53:02 -04:00
16 changed files with 133 additions and 9 deletions
+23
View File
@@ -0,0 +1,23 @@
# Release Notes
## v4 — 2026-07-18
- Added a web app manifest (`manifest.json`) and a 512x512 icon, so the site
can be installed as a desktop app from Edge/Chrome — opens in its own
window instead of a browser tab, while still loading live from the server.
## v3 — 2026-07-16
- Added a favicon (clock + "H" mark), with a transparent icon set for
browser tabs and an opaque apple-touch-icon for iOS.
- New customer form now redirects to the customer list after saving,
instead of the new customer's edit page.
- Customer addresses in the customer list now link out to a Google Maps
search (URL-encoded, opens in a new tab).
## v2 — 2026-07-16
- Added a "+ New Customer" button and a standalone `/customers/new`
form, so a customer's contact info can be added without first
creating a service order. Reuses the edit-customer template for both
create and edit.
+13
View File
@@ -0,0 +1,13 @@
#!/usr/bin/env bash
# Build the container image and tag it for the registry.
# Usage: ./build-and-tag.sh <tag> e.g. ./build-and-tag.sh v1
set -e
IMAGE=hardings-service
REGISTRY=registry.digitalsorcery.net
TAG="${1:?usage: $0 <tag>}"
podman build -t "${IMAGE}:${TAG}" -f Containerfile .
podman tag "${IMAGE}:${TAG}" "${REGISTRY}/${IMAGE}:${TAG}"
echo "Push with: podman push ${REGISTRY}/${IMAGE}:${TAG}"
+1 -1
View File
@@ -1,5 +1,5 @@
{
"name": "hardingsclocks-atrex",
"name": "hardings-service",
"version": "1.0.0",
"description": "Repair tracker for Harding's Clocks",
"private": true,
+17 -1
View File
@@ -52,7 +52,8 @@ nav {
background: var(--color-wood-dark);
padding: 0.75rem 1.5rem;
display: flex;
gap: 1.5rem;
flex-wrap: wrap;
gap: 0.75rem 1.5rem;
align-items: center;
}
@@ -73,6 +74,21 @@ nav a:hover {
color: var(--color-wood-light);
}
/* Below ~430px (a phone in portrait, at this site's 150% base font size)
the brand name alone can approach full width - drop it to its own line
above the links instead of letting the flex row overflow the viewport. */
@media (max-width: 430px) {
nav {
padding: 0.6rem 1rem;
gap: 0.5rem 1rem;
}
nav a.brand {
flex-basis: 100%;
margin-right: 0;
}
}
main {
max-width: 960px;
margin: 0 auto;
Binary file not shown.

After

Width:  |  Height:  |  Size: 15 KiB

Binary file not shown.

After

Width:  |  Height:  |  Size: 25 KiB

Binary file not shown.

After

Width:  |  Height:  |  Size: 1.3 KiB

Binary file not shown.

After

Width:  |  Height:  |  Size: 2.5 KiB

Binary file not shown.

After

Width:  |  Height:  |  Size: 37 KiB

Binary file not shown.

After

Width:  |  Height:  |  Size: 166 KiB

+12
View File
@@ -0,0 +1,12 @@
{
"name": "Harding's Service",
"short_name": "Harding's Service",
"start_url": "/",
"display": "standalone",
"background_color": "#F2E8D5",
"theme_color": "#4A2810",
"icons": [
{ "src": "/icons/icon-192.png", "sizes": "192x192", "type": "image/png" },
{ "src": "/icons/icon-512.png", "sizes": "512x512", "type": "image/png" }
]
}
+30
View File
@@ -20,6 +20,36 @@ router.get('/', (req, res) => {
res.render('customers/list.njk', { customers, q });
});
router.get('/new', (req, res) => {
res.render('customers/edit.njk', { customer: {} });
});
router.post('/new', (req, res) => {
const b = req.body as Record<string, string>;
const name = (b.name || '').trim();
if (!name) return res.status(400).send('Name is required');
db.prepare(
`INSERT INTO customers
(name, phone, alt_phone, email, address_line1, address_line2, city, state, zip, internal_note)
VALUES (?, ?, ?, ?, ?, ?, ?, ?, ?, ?)`
).run(
name,
formatPhone(b.phone),
formatPhone(b.alt_phone),
b.email || null,
b.address_line1 || null,
b.address_line2 || null,
b.city || null,
b.state || null,
b.zip || null,
b.internal_note || null
);
res.redirect(303, '/customers');
});
router.get('/:id/edit', (req, res) => {
const customer = db.prepare('SELECT * FROM customers WHERE id = ?').get(req.params.id);
if (!customer) return res.status(404).send('Customer not found');
+18 -2
View File
@@ -3,6 +3,7 @@ import nunjucks from 'nunjucks';
import path from 'node:path';
import { fileURLToPath } from 'node:url';
import { formatCents } from './money.js';
import { db } from './db.js';
import ordersRouter from './routes/orders.js';
import customersRouter from './routes/customers.js';
import clocksRouter from './routes/clocks.js';
@@ -40,7 +41,22 @@ app.use((err: unknown, _req: express.Request, res: express.Response, _next: expr
});
const PORT = process.env.PORT ? Number(process.env.PORT) : 3000;
const HOST = '127.0.0.1';
app.listen(PORT, HOST, () => {
const HOST = process.env.HOST || '127.0.0.1';
const server = app.listen(PORT, HOST, () => {
console.log(`Harding's Clocks running at http://${HOST}:${PORT}`);
});
function shutdown(signal: string) {
console.log(`${signal} received, shutting down`);
server.close((err) => {
db.close();
if (err) {
console.error(err);
process.exit(1);
}
process.exit(0);
});
}
process.on('SIGTERM', () => shutdown('SIGTERM'));
process.on('SIGINT', () => shutdown('SIGINT'));
+4 -4
View File
@@ -1,15 +1,15 @@
{% extends "layout.njk" %}
{% block title %}Edit {{ customer.name }} — Harding's Clocks{% endblock %}
{% block title %}{% if customer.id %}Edit {{ customer.name }}{% else %}New Customer{% endif %} — Harding's Clocks{% endblock %}
{% block content %}
<div class="page-header">
<h1>Edit Customer</h1>
<h1>{% if customer.id %}Edit Customer{% else %}New Customer{% endif %}</h1>
</div>
<form method="post" action="/customers/{{ customer.id }}/edit" class="stacked-form">
<form method="post" action="{% if customer.id %}/customers/{{ customer.id }}/edit{% else %}/customers/new{% endif %}" class="stacked-form">
<fieldset>
<legend>Customer</legend>
<div class="grid-2">
<label>Name <input type="text" name="name" value="{{ customer.name }}" required></label>
<label>Name <input type="text" name="name" value="{{ customer.name or '' }}" required></label>
<label>Phone <input type="text" name="phone" value="{{ customer.phone or '' }}"></label>
<label>Alt phone <input type="text" name="alt_phone" value="{{ customer.alt_phone or '' }}"></label>
<label>Email <input type="email" name="email" value="{{ customer.email or '' }}"></label>
+8 -1
View File
@@ -3,6 +3,9 @@
{% block content %}
<div class="page-header">
<h1>Customers</h1>
<div class="header-actions">
<a class="button-link" href="/customers/new">+ New Customer</a>
</div>
</div>
<form class="search-form" method="get" action="/customers">
@@ -29,7 +32,11 @@
<td>{{ c.email or '' }}</td>
<td>
{%- if c.address_line1 -%}
{{ c.address_line1 }}{% if c.city %}, {{ c.city }}{% endif %}{% if c.state %}, {{ c.state }}{% endif %} {{ c.zip or '' }}
{% set addr = c.address_line1 %}
{% if c.city %}{% set addr = addr + ', ' + c.city %}{% endif %}
{% if c.state %}{% set addr = addr + ', ' + c.state %}{% endif %}
{% if c.zip %}{% set addr = addr + ' ' + c.zip %}{% endif %}
<a href="https://www.google.com/maps/search/?api=1&query={{ addr | urlencode }}" target="_blank" rel="noopener">{{ addr }}</a>
{%- endif -%}
</td>
<td><a class="button-link" href="/?customer_id={{ c.id }}">Orders</a></td>
+7
View File
@@ -4,6 +4,13 @@
<meta charset="utf-8">
<meta name="viewport" content="width=device-width, initial-scale=1">
<title>{% block title %}Harding's Clocks{% endblock %}</title>
<link rel="icon" href="/favicon.ico" sizes="any">
<link rel="icon" type="image/png" sizes="16x16" href="/icons/favicon-16x16.png">
<link rel="icon" type="image/png" sizes="32x32" href="/icons/favicon-32x32.png">
<link rel="icon" type="image/png" sizes="192x192" href="/icons/icon-192.png">
<link rel="apple-touch-icon" href="/icons/apple-touch-icon.png">
<link rel="manifest" href="/manifest.json" crossorigin="use-credentials">
<meta name="theme-color" content="#4A2810">
<link rel="stylesheet" href="/css/style.css">
<script src="/vendor/htmx/htmx.min.js" defer></script>
</head>