Add app footer with About modal

Adds a footer bar at the bottom of the page with copyright, a link to
docs.benestad.net/invoice, a repo link, and an About link that opens a
modal. Modal title, Markdown content, and button label are configurable
via config.yml (about-title, about-content, about-button).

https://claude.ai/code/session_01MNy1ymwx9URLgXSgHc9W3T
This commit is contained in:
Claude 2026-06-04 04:54:17 +00:00
parent 65bd989597
commit 711abd27e9
No known key found for this signature in database
2 changed files with 75 additions and 0 deletions

View file

@ -55,6 +55,16 @@ accounts:
- "3000 - Office Supplies" - "3000 - Office Supplies"
- "4000 - Professional Services" - "4000 - Professional Services"
# About modal (shown when "About" is clicked in the footer)
about-title: "About This Form"
about-button: "Close"
about-content: |
This reimbursement form lets you collect expense data and generate a PDF with attached receipts.
**Documentation:** [docs.benestad.net](https://docs.benestad.net/invoice)
**Source code:** [kbenestad/reimburse](https://github.com/kbenestad/reimburse)
# Programs (shown in dropdown — "Other" adds a text field) # Programs (shown in dropdown — "Other" adds a text field)
programs: programs:
- "General Operations" - "General Operations"

View file

@ -98,10 +98,34 @@ textarea { resize: vertical; min-height: 48px; width: 100%; }
.frow { flex-direction: column; gap: 10px; } .frow { flex-direction: column; gap: 10px; }
.form-hdr { flex-direction: column; gap: 8px; } .form-hdr { flex-direction: column; gap: 8px; }
} }
/* App footer */
.app-footer { text-align: center; padding: 18px 16px; font-size: 12px; color: var(--muted); }
.app-footer a { color: var(--muted); text-decoration: none; }
.app-footer a:hover { color: var(--accent); text-decoration: underline; }
.app-footer .sep { margin: 0 6px; opacity: .5; }
/* About modal prose */
.about-body h1,.about-body h2,.about-body h3 { font-size: 14px; font-weight: 700; margin: 12px 0 4px; color: var(--accent); }
.about-body p { margin: 0 0 10px; }
.about-body ul { margin: 0 0 10px 18px; }
.about-body li { margin-bottom: 3px; }
.about-body a { color: var(--accent); }
.about-body strong { font-weight: 700; }
.about-body em { font-style: italic; }
</style> </style>
</head> </head>
<body> <body>
<div id="app"><p class="loading">Loading configuration…</p></div> <div id="app"><p class="loading">Loading configuration…</p></div>
<footer class="app-footer">
<span>© 2026 Kristian Benestad</span>
<span class="sep"></span>
<a href="https://docs.benestad.net/invoice" target="_blank" rel="noopener">docs.benestad.net</a>
<span class="sep"></span>
<a href="https://github.com/kbenestad/reimburse" target="_blank" rel="noopener">kbenestad/reimburse</a>
<span class="sep"></span>
<a href="#" id="about-link">About</a>
</footer>
<script> <script>
(async function() { (async function() {
@ -184,6 +208,44 @@ function showWarningModal(msg) {
}); });
} }
function mdToHtml(md) {
if (!md) return '';
let html = md
.replace(/&/g, '&amp;').replace(/</g, '&lt;').replace(/>/g, '&gt;')
.replace(/^#{3}\s+(.+)$/gm, '<h3>$1</h3>')
.replace(/^#{2}\s+(.+)$/gm, '<h2>$1</h2>')
.replace(/^#{1}\s+(.+)$/gm, '<h1>$1</h1>')
.replace(/\*\*(.+?)\*\*/g, '<strong>$1</strong>')
.replace(/\*(.+?)\*/g, '<em>$1</em>')
.replace(/\[([^\]]+)\]\(([^)]+)\)/g, '<a href="$2" target="_blank" rel="noopener">$1</a>');
// Lists
html = html.replace(/((?:^- .+\n?)+)/gm, m => '<ul>' + m.replace(/^- (.+)$/gm, '<li>$1</li>') + '</ul>');
// Paragraphs (blocks not already wrapped in a tag)
html = html.split(/\n{2,}/).map(b => b.trim()).filter(Boolean).map(b => /^<[hul]/.test(b) ? b : `<p>${b.replace(/\n/g, '<br>')}</p>`).join('\n');
return html;
}
function showAboutModal() {
const title = (CFG && CFG['about-title']) || 'About';
const content = (CFG && CFG['about-content']) || '';
const btnLabel = (CFG && CFG['about-button']) || 'Close';
const overlay = el('div', {style:{position:'fixed',top:'0',right:'0',bottom:'0',left:'0',background:'rgba(0,0,0,.45)',zIndex:'9999',display:'flex',alignItems:'center',justifyContent:'center'}});
const box = el('div', {style:{background:'#fff',borderRadius:'8px',padding:'24px 28px',maxWidth:'480px',width:'90%',maxHeight:'80vh',overflowY:'auto',boxShadow:'0 8px 32px rgba(0,0,0,.25)'}});
const hdr = el('div', {style:{marginBottom:'16px'}});
hdr.appendChild(el('strong', {style:{fontSize:'16px',color:'var(--accent)'}}, title));
const body = el('div', {className:'about-body', style:{fontSize:'13px',lineHeight:'1.65',color:'var(--text)',marginBottom:'20px'}});
body.innerHTML = mdToHtml(content);
const foot = el('div', {style:{display:'flex',justifyContent:'flex-end'}});
const closeBtn = el('button', {className:'btn btn-gen', style:{width:'auto',padding:'8px 28px',marginTop:'0'}}, btnLabel);
closeBtn.addEventListener('click', () => overlay.remove());
overlay.addEventListener('click', e => { if (e.target === overlay) overlay.remove(); });
foot.appendChild(closeBtn);
box.append(hdr, body, foot);
overlay.appendChild(box);
document.body.appendChild(overlay);
closeBtn.focus();
}
// ========== CONFIG ========== // ========== CONFIG ==========
let CFG; let CFG;
@ -1298,6 +1360,9 @@ async function init() {
} }
} }
const aboutLink = document.getElementById('about-link');
if (aboutLink) aboutLink.addEventListener('click', e => { e.preventDefault(); showAboutModal(); });
init(); init();
})(); })();
</script> </script>