Add footer with copyright, links, and optional About modal

Footer shows: © 2026 Kristian Benestad • docs.benestad.net • kbenestad/invoice
and an About link when cfg.about is defined.

Clicking About opens a modal with a title, markdown-rendered body (via
marked.js from CDN), and a close button — all defined per language in
config.yml under the `about` key. Remove the section to hide the link.

Language switch updates the footer link title and the modal content.
Esc key closes the modal.

https://claude.ai/code/session_0151QtsUhzXmgzEhSvXG2SDt
This commit is contained in:
Claude 2026-06-01 18:11:12 +00:00
parent 131f6c5d25
commit 2795a593ef
No known key found for this signature in database
2 changed files with 148 additions and 0 deletions

View file

@ -21,6 +21,59 @@ languages:
name: Norsk
direction: ltr
# ── About modal ───────────────────────────────────────────────────────────────
# Optional. Remove this section entirely to hide the About link in the footer.
# Each language entry requires: title, content (Markdown), btn-close.
about:
en:
title: "About"
content: |
### Invoice Generator
A single-file browser app for generating freelance invoices as PDF or print preview.
No backend, no build step — just a browser and a config file.
**Source:** [kbenestad/invoice](https://github.com/kbenestad/invoice)
**Docs:** [docs.benestad.net/invoice](https://docs.benestad.net/invoice)
© 2026 Kristian Benestad. Licensed under the Apache 2 license.
btn-close: "Close"
de:
title: "Über"
content: |
### Rechnungsgenerator
Eine Single-File-Browser-App zur Erstellung von Freiberufler-Rechnungen als PDF oder Druckvorschau.
Kein Backend, kein Build-Schritt.
**Quellcode:** [kbenestad/invoice](https://github.com/kbenestad/invoice)
**Dokumentation:** [docs.benestad.net/invoice](https://docs.benestad.net/invoice)
© 2026 Kristian Benestad. Lizenziert unter Apache 2.
btn-close: "Schließen"
fr:
title: "À propos"
content: |
### Générateur de factures
Une application de navigateur en fichier unique pour générer des factures en PDF ou aperçu avant impression.
Aucun backend, aucune étape de build.
**Code source :** [kbenestad/invoice](https://github.com/kbenestad/invoice)
**Documentation :** [docs.benestad.net/invoice](https://docs.benestad.net/invoice)
© 2026 Kristian Benestad. Sous licence Apache 2.
btn-close: "Fermer"
"no":
title: "Om"
content: |
### Fakturagenerator
En nettleserapp i én fil for å generere frilansfakturaer som PDF eller utskriftsforhåndsvisning.
Ingen backend, ingen byggtrinn.
**Kildekode:** [kbenestad/invoice](https://github.com/kbenestad/invoice)
**Dokumentasjon:** [docs.benestad.net/invoice](https://docs.benestad.net/invoice)
© 2026 Kristian Benestad. Lisensiert under Apache 2.
btn-close: "Lukk"
# ── Payment info visibility ───────────────────────────────────────────────────
# Set to true to hide the payment info panel entirely (useful if payment info
# should not appear on invoices, e.g. per company policy).

View file

@ -220,6 +220,41 @@
/* ── Error / loading ────────────────────────────────────────────────────── */
#loading { padding: 48px; text-align: center; color: var(--text-muted); font-size: 14px; }
.error-box { background: #fef2f2; border: 1px solid #fca5a5; color: #991b1b; padding: 16px 20px; border-radius: var(--radius); margin: 20px 0; font-size: 13px; }
/* ── Footer ─────────────────────────────────────────────────────────────── */
#app-footer { text-align: center; font-size: 11px; color: var(--text-muted); padding: 18px 0 24px; }
#app-footer a { color: var(--text-muted); text-decoration: none; }
#app-footer a:hover { color: var(--accent); text-decoration: underline; }
/* ── About modal ────────────────────────────────────────────────────────── */
#about-modal { display: none; position: fixed; inset: 0; z-index: 1000; }
#about-overlay { position: absolute; inset: 0; background: rgba(0,0,0,.45); }
#about-dialog {
position: absolute; top: 50%; left: 50%; transform: translate(-50%,-50%);
background: var(--white); border-radius: 6px; box-shadow: 0 20px 60px rgba(0,0,0,.25);
width: min(560px, 92vw); max-height: 80vh; display: flex; flex-direction: column;
}
#about-dialog-header {
padding: 18px 20px 14px; border-bottom: 1px solid var(--border);
font-size: 15px; font-weight: 700; color: var(--navy);
}
#about-dialog-body {
padding: 16px 20px; overflow-y: auto; font-size: 13px; line-height: 1.6; color: var(--text);
}
#about-dialog-body h1,#about-dialog-body h2,#about-dialog-body h3 {
font-size: 13px; font-weight: 700; margin: 12px 0 4px; color: var(--navy);
}
#about-dialog-body p { margin-bottom: 8px; }
#about-dialog-body a { color: var(--accent); }
#about-dialog-body ul,#about-dialog-body ol { padding-left: 20px; margin-bottom: 8px; }
#about-dialog-footer {
padding: 12px 20px; border-top: 1px solid var(--border); text-align: right;
}
#about-btn-close {
background: var(--navy); color: var(--white); border: none; border-radius: var(--radius);
font-size: 13px; padding: 6px 18px; cursor: pointer;
}
#about-btn-close:hover { background: var(--accent); }
</style>
</head>
<body>
@ -246,8 +281,23 @@
<!-- Main form (injected by JS) -->
<div id="form-root"></div>
<!-- Footer -->
<footer id="app-footer"></footer>
</div><!-- /.wrap -->
<!-- About modal (outside .wrap so it overlays everything) -->
<div id="about-modal" role="dialog" aria-modal="true">
<div id="about-overlay" onclick="closeAbout()"></div>
<div id="about-dialog">
<div id="about-dialog-header"></div>
<div id="about-dialog-body"></div>
<div id="about-dialog-footer">
<button id="about-btn-close" onclick="closeAbout()">Close</button>
</div>
</div>
</div>
<!-- js-yaml -->
<script src="https://cdnjs.cloudflare.com/ajax/libs/js-yaml/4.1.0/js-yaml.min.js"
@ -255,6 +305,9 @@
<!-- jsPDF -->
<script src="https://cdnjs.cloudflare.com/ajax/libs/jspdf/2.5.1/jspdf.umd.min.js"
crossorigin="anonymous"></script>
<!-- marked -->
<script src="https://cdnjs.cloudflare.com/ajax/libs/marked/9.1.6/marked.min.js"
crossorigin="anonymous"></script>
<script>
"use strict";
@ -416,6 +469,7 @@ async function loadCfg() {
function boot() {
buildLangBar();
buildForm();
buildFooter();
restoreStorage();
if (!loadLines()) addLine();
document.getElementById("loading").style.display = "none";
@ -1088,6 +1142,7 @@ function relabel() {
ctPick.value = cur;
}
buildFooter();
}
// ── Generate invoice ──────────────────────────────────────────────────────────
@ -1541,6 +1596,46 @@ function resetLines() {
calcTotals();
}
// ── Footer & About modal ──────────────────────────────────────────────────────
function aboutData() {
const ab = cfg?.about;
if (!ab) return null;
return ab[lang] ?? ab[cfg["default-code"]] ?? Object.values(ab)[0] ?? null;
}
function buildFooter() {
const footer = document.getElementById("app-footer");
if (!footer) return;
const ab = aboutData();
const aboutLink = ab
? ` &bull; <a href="#" onclick="openAbout();return false">${h(ab.title ?? "About")}</a>`
: "";
footer.innerHTML =
`&copy; 2026 Kristian Benestad`
+ ` &bull; <a href="https://docs.benestad.net/invoice" target="_blank" rel="noopener">docs.benestad.net</a>`
+ ` &bull; <a href="https://github.com/kbenestad/invoice" target="_blank" rel="noopener">kbenestad/invoice</a>`
+ aboutLink;
}
function openAbout() {
const ab = aboutData();
if (!ab) return;
document.getElementById("about-dialog-header").textContent = ab.title ?? "";
document.getElementById("about-dialog-body").innerHTML =
typeof marked !== "undefined" ? marked.parse(ab.content ?? "") : h(ab.content ?? "");
document.getElementById("about-btn-close").textContent = ab["btn-close"] ?? "Close";
document.getElementById("about-modal").style.display = "block";
document.getElementById("about-btn-close").focus();
}
function closeAbout() {
document.getElementById("about-modal").style.display = "none";
}
document.addEventListener("keydown", e => {
if (e.key === "Escape") closeAbout();
});
// ── Start ─────────────────────────────────────────────────────────────────────
loadCfg();
</script>