Author SHA1 Message Date
odweta be6809336a update: polish sign in screen 2026-09-10 18:26:41 +02:00
odweta 872d8f87c4 Merge pull request #14 from odweta/odweta-polish-sign-in-page
Center sign-in card content while keeping text left-aligned
2026-09-10 18:18:20 +02:00
odwetaandCopilot App 26827904db Center sign-in card content while keeping text left-aligned
The card's content (title, intro, form) hugged the left edge instead of
sitting centered within the wider card. Make .auth-card a centered
flex column so each element centers as a block, while individual
elements keep their own text-align: left. The eyebrow label stays
pinned to the left edge via align-self. The title's two-line layout is
unaffected.

Co-authored-by: Copilot App <223556219+Copilot@users.noreply.github.com>
2026-09-10 18:17:46 +02:00
odweta 2cae35bd05 Merge pull request #13 from odweta/odweta-polish-sign-in-page
Widen sign-in card so title fits on two lines
2026-09-10 18:14:15 +02:00
odwetaandCopilot App 310a7c5e0d Widen sign-in card so title reliably fits on two lines
The auth title's two spans could each wrap on wide viewports since the
card was capped at 460px while the heading font scaled up to 3.4rem,
producing three lines instead of two. Widen the card, tune the
heading's clamp, and force each title span to a single line above
420px so it never breaks unpredictably. The form and intro text keep
a comfortable reading width inside the wider card.

Co-authored-by: Copilot App <223556219+Copilot@users.noreply.github.com>
2026-09-10 18:13:18 +02:00
odweta 60be8331c1 Merge pull request #12 from odweta/odweta-single-saved-signature
Store a single saved signature per user, like supplier
2026-09-10 18:07:42 +02:00
odweta 8deec130dc Merge pull request #11 from odweta/odweta-polish-sign-in-page
Polish sign-in page for better visual hierarchy
2026-09-10 18:07:12 +02:00
odwetaandCopilot App bcb6d9df99 Store a single signature per user, like supplier profile
Replace the signature preset dropdown (multiple named signatures) with
a single saved signature per user, mirroring the supplier profile
behavior: Save/Load buttons, auto-loaded on the new invoice screen.

- New signature_profiles table (one row per user), migrated from any
  existing signature_presets data.
- New /api/signature GET/PUT/DELETE endpoints replacing
  /api/signature-presets.
- Updated frontend to save/load the single signature instead of a
  named preset dropdown.

Co-authored-by: Copilot App <223556219+Copilot@users.noreply.github.com>
2026-09-10 18:06:38 +02:00
odwetaandCopilot App 68abd5ccc8 Polish sign-in page for better visual hierarchy
- Force auth title onto two lines consistently for cleaner presentation
- Round card and button corners for a softer, modern look
- Add hover motion and larger touch target on submit button
- Add visible keyboard focus styles for auth controls

Co-authored-by: Copilot App <223556219+Copilot@users.noreply.github.com>
2026-09-10 18:06:16 +02:00
odweta cd553bbaea update: auth intro on frontpage 2026-09-10 17:53:19 +02:00
odweta 2f9a7c587c Merge pull request #9 from odweta/odweta-default-invoice-date
Default issue date field to today's date
2026-09-10 17:44:15 +02:00
odweta cfcab16149 Merge pull request #10 from odweta/odweta-saved-signatures
Add saved signature presets
2026-09-10 17:43:59 +02:00
odwetaandCopilot App 1317c7297f Add saved signature presets
Co-authored-by: Copilot App <223556219+Copilot@users.noreply.github.com>
2026-09-10 17:43:23 +02:00
7 changed files with 158 additions and 17 deletions
+38 -1
View File
@@ -5,7 +5,7 @@ const archiver = require('archiver');
const PDFDocument = require('pdfkit'); const PDFDocument = require('pdfkit');
const QRCode = require('qrcode'); const QRCode = require('qrcode');
const { pool, initializeDatabase } = require('./db'); const { pool, initializeDatabase } = require('./db');
const { normalizeInvoiceData } = require('./invoice-data'); const { isValidSignatureDataUrl, normalizeInvoiceData } = require('./invoice-data');
const app = express(); const app = express();
const sessionDurationMs = 1000 * 60 * 60 * 24 * 30; const sessionDurationMs = 1000 * 60 * 60 * 24 * 30;
@@ -362,6 +362,43 @@ app.delete('/api/payment-presets/:id', requireAuth, async (req, res, next) => {
} }
}); });
app.get('/api/signature', requireAuth, async (req, res, next) => {
try {
const result = await pool.query('SELECT * FROM signature_profiles WHERE user_id = $1', [req.userId]);
res.json({ signature: result.rowCount ? result.rows[0].data_url : null });
} catch (error) {
next(error);
}
});
app.put('/api/signature', requireAuth, async (req, res, next) => {
try {
const dataUrl = req.body.dataUrl;
if (!isValidSignatureDataUrl(dataUrl)) {
res.status(400).json({ error: 'Podpis musí být platný obrázek JPG nebo PNG.' });
return;
}
const result = await pool.query(`
INSERT INTO signature_profiles (user_id, data_url)
VALUES ($1, $2)
ON CONFLICT (user_id) DO UPDATE SET data_url = EXCLUDED.data_url
RETURNING *
`, [req.userId, dataUrl]);
res.json({ signature: result.rows[0].data_url });
} catch (error) {
next(error);
}
});
app.delete('/api/signature', requireAuth, async (req, res, next) => {
try {
await pool.query('DELETE FROM signature_profiles WHERE user_id = $1', [req.userId]);
res.status(204).end();
} catch (error) {
next(error);
}
});
const invoiceResponse = (row) => ({ const invoiceResponse = (row) => ({
id: row.id, id: row.id,
invoiceNumber: row.invoice_number, invoiceNumber: row.invoice_number,
+20
View File
@@ -81,10 +81,30 @@ const initializeDatabase = () => {
UNIQUE (user_id, name) UNIQUE (user_id, name)
); );
CREATE TABLE IF NOT EXISTS signature_presets (
id SERIAL PRIMARY KEY,
user_id INTEGER NOT NULL REFERENCES users(id) ON DELETE CASCADE,
name TEXT NOT NULL,
data_url TEXT NOT NULL,
created_at TIMESTAMPTZ NOT NULL DEFAULT NOW(),
UNIQUE (user_id, name)
);
CREATE TABLE IF NOT EXISTS signature_profiles (
user_id INTEGER PRIMARY KEY REFERENCES users(id) ON DELETE CASCADE,
data_url TEXT NOT NULL
);
ALTER TABLE supplier_profiles ADD COLUMN IF NOT EXISTS nazev_spolecnosti TEXT NOT NULL DEFAULT ''; ALTER TABLE supplier_profiles ADD COLUMN IF NOT EXISTS nazev_spolecnosti TEXT NOT NULL DEFAULT '';
ALTER TABLE supplier_profiles ADD COLUMN IF NOT EXISTS typ_subjektu TEXT NOT NULL DEFAULT 'osoba'; ALTER TABLE supplier_profiles ADD COLUMN IF NOT EXISTS typ_subjektu TEXT NOT NULL DEFAULT 'osoba';
ALTER TABLE clients ADD COLUMN IF NOT EXISTS nazev_spolecnosti TEXT NOT NULL DEFAULT ''; ALTER TABLE clients ADD COLUMN IF NOT EXISTS nazev_spolecnosti TEXT NOT NULL DEFAULT '';
ALTER TABLE clients ADD COLUMN IF NOT EXISTS typ_subjektu TEXT NOT NULL DEFAULT 'osoba'; ALTER TABLE clients ADD COLUMN IF NOT EXISTS typ_subjektu TEXT NOT NULL DEFAULT 'osoba';
INSERT INTO signature_profiles (user_id, data_url)
SELECT DISTINCT ON (user_id) user_id, data_url
FROM signature_presets
ORDER BY user_id, created_at DESC
ON CONFLICT (user_id) DO NOTHING;
`).catch((error) => { `).catch((error) => {
schemaPromise = undefined; schemaPromise = undefined;
throw error; throw error;
+5 -2
View File
@@ -1,5 +1,8 @@
const SIGNATURE_DATA_URL_PATTERN = /^data:image\/(png|jpeg);base64,[A-Za-z0-9+/]+=*$/; const SIGNATURE_DATA_URL_PATTERN = /^data:image\/(png|jpeg);base64,[A-Za-z0-9+/]+=*$/;
const isValidSignatureDataUrl = (value) => typeof value === 'string'
&& SIGNATURE_DATA_URL_PATTERN.test(value);
const normalizeInvoiceData = (data) => { const normalizeInvoiceData = (data) => {
if (!data || !Array.isArray(data.items)) { if (!data || !Array.isArray(data.items)) {
return null; return null;
@@ -18,7 +21,7 @@ const normalizeInvoiceData = (data) => {
return null; return null;
} }
const signature = typeof data.signature === 'string' && SIGNATURE_DATA_URL_PATTERN.test(data.signature) const signature = isValidSignatureDataUrl(data.signature)
? data.signature ? data.signature
: null; : null;
@@ -32,4 +35,4 @@ const normalizeInvoiceData = (data) => {
}; };
}; };
module.exports = { normalizeInvoiceData }; module.exports = { isValidSignatureDataUrl, normalizeInvoiceData };
+14 -5
View File
@@ -9,9 +9,14 @@
<body> <body>
<section id="auth-screen" aria-labelledby="auth-title"> <section id="auth-screen" aria-labelledby="auth-title">
<div class="auth-card"> <div class="auth-card">
<p class="eyebrow">FAKTUROVAČ</p> <!--<p class="eyebrow">FAKTUROVAČ</p>-->
<h1 id="auth-title">Vaše faktury, bezpečně po ruce</h1> <h1 id="auth-title">
<p class="auth-intro">Přihlaste se a mějte dodavatele i odběratele uložené ve svém účtu.</p> <span>Fakturovač</span>
</h1>
<p class="auth-intro">
<span>Vaše faktury,</span>
<span>bezpečně po ruce.</span>
</p>
<form id="auth-form"> <form id="auth-form">
<div class="form-group"> <div class="form-group">
<label for="authEmail">E-mail</label> <label for="authEmail">E-mail</label>
@@ -339,14 +344,18 @@
<div class="form-group signature-group"> <div class="form-group signature-group">
<label for="signatureInput">Podpis (nepovinné)</label> <label for="signatureInput">Podpis (nepovinné)</label>
<div id="signatureDropzone" class="signature-dropzone" tabindex="0" role="button" aria-label="Nahrát podpis kliknutím nebo přetažením souboru"> <div id="signatureDropzone" class="signature-dropzone" tabindex="0" role="button" aria-label="Nahrát podpis kliknutím nebo přetažením souboru">
<img id="signaturePreview" class="signature-preview" alt="Náhled podpisu" hidden /> <img id="signaturePreview" class="signature-preview" alt="Náhled podpisu" src="data:image/gif;base64,R0lGODlhAQABAAD/ACwAAAAAAQABAAACADs=" hidden />
<p id="signatureDropzoneText" class="signature-dropzone-text">Přetáhněte sem obrázek podpisu (420×210 px, JPG nebo PNG) nebo klikněte pro výběr souboru</p> <p id="signatureDropzoneText" class="signature-dropzone-text">Přetáhněte sem obrázek podpisu (420×210 px, JPG nebo PNG) nebo klikněte pro výběr souboru</p>
<input type="file" id="signatureInput" accept="image/png,image/jpeg" hidden /> <input type="file" id="signatureInput" accept="image/png,image/jpeg" hidden />
</div> </div>
<div class="signature-actions"> <div class="signature-actions">
<button type="button" id="removeSignatureButton" class="secondary-button" hidden>Odstranit podpis</button> <button type="button" id="removeSignatureButton" class="secondary-button" hidden>Odstranit podpis</button>
</div> </div>
<p id="signatureStatus" class="signature-status" role="status" aria-live="polite"></p> <div class="saved-data-actions">
<button type="button" id="saveSignatureButton">Uložit podpis</button>
<button type="button" id="loadSignatureButton" class="secondary-button">Načíst uložený</button>
<span id="signatureStatus" class="signature-status" role="status" aria-live="polite"></span>
</div>
</div> </div>
</div> </div>
+42 -3
View File
@@ -57,6 +57,8 @@ const signaturePreview = document.getElementById("signaturePreview");
const signatureDropzoneText = document.getElementById("signatureDropzoneText"); const signatureDropzoneText = document.getElementById("signatureDropzoneText");
const removeSignatureButton = document.getElementById("removeSignatureButton"); const removeSignatureButton = document.getElementById("removeSignatureButton");
const signatureStatus = document.getElementById("signatureStatus"); const signatureStatus = document.getElementById("signatureStatus");
const saveSignatureButton = document.getElementById("saveSignatureButton");
const loadSignatureButton = document.getElementById("loadSignatureButton");
const loadPaymentPresetButton = document.getElementById("loadPaymentPresetButton"); const loadPaymentPresetButton = document.getElementById("loadPaymentPresetButton");
const deletePaymentPresetButton = document.getElementById("deletePaymentPresetButton"); const deletePaymentPresetButton = document.getElementById("deletePaymentPresetButton");
@@ -99,12 +101,39 @@ const applySignaturePreview = (dataUrl) => {
removeSignatureButton.hidden = false; removeSignatureButton.hidden = false;
} else { } else {
signaturePreview.hidden = true; signaturePreview.hidden = true;
signaturePreview.src = ""; signaturePreview.removeAttribute("src");
signatureDropzoneText.hidden = false; signatureDropzoneText.hidden = false;
removeSignatureButton.hidden = true; removeSignatureButton.hidden = true;
} }
}; };
const saveSignature = async () => {
if (!signatureDataUrl) {
setSignatureStatus("Nejprve nahrajte podpis.", true);
return;
}
try {
await apiRequest("/api/signature", {
method: "PUT",
body: JSON.stringify({ dataUrl: signatureDataUrl })
});
setSignatureStatus("Podpis byl uložen.");
} catch (error) {
setSignatureStatus(error.message, true);
}
};
const loadSignature = async () => {
const { signature } = await apiRequest("/api/signature");
if (signature) {
applySignaturePreview(signature);
setSignatureStatus("Uloženo na serveru");
return true;
}
setSignatureStatus("Zatím není uložený podpis");
return false;
};
const readImageDimensions = (dataUrl) => new Promise((resolve, reject) => { const readImageDimensions = (dataUrl) => new Promise((resolve, reject) => {
const image = new Image(); const image = new Image();
image.onload = () => resolve({ width: image.naturalWidth, height: image.naturalHeight }); image.onload = () => resolve({ width: image.naturalWidth, height: image.naturalHeight });
@@ -162,6 +191,15 @@ signatureInput.addEventListener("change", () => {
handleSignatureFile(signatureInput.files[0]); handleSignatureFile(signatureInput.files[0]);
signatureInput.value = ""; signatureInput.value = "";
}); });
saveSignatureButton.addEventListener("click", saveSignature);
loadSignatureButton.addEventListener("click", async () => {
try {
await loadSignature();
setSignatureStatus("Podpis načten.");
} catch (error) {
setSignatureStatus(error.message, true);
}
});
removeSignatureButton.addEventListener("click", (event) => { removeSignatureButton.addEventListener("click", (event) => {
event.stopPropagation(); event.stopPropagation();
applySignaturePreview(null); applySignaturePreview(null);
@@ -445,8 +483,9 @@ const loadInvoices = async () => {
renderInvoiceList(); renderInvoiceList();
}; };
const openNewInvoice = () => { const openNewInvoice = async () => {
clearInvoiceEditor(); clearInvoiceEditor();
await loadSignature();
showInvoiceScreen("editor"); showInvoiceScreen("editor");
}; };
@@ -633,7 +672,7 @@ deleteClientButton.addEventListener("click", async () => {
const showInvoiceApp = async () => { const showInvoiceApp = async () => {
authScreen.hidden = true; authScreen.hidden = true;
document.getElementById("main-container").hidden = false; document.getElementById("main-container").hidden = false;
await Promise.all([loadSupplier(), loadClients(), loadInvoices(), loadPaymentPresets(), loadNumberingSettings()]); await Promise.all([loadSupplier(), loadClients(), loadInvoices(), loadPaymentPresets(), loadSignature(), loadNumberingSettings()]);
showInvoiceScreen("list"); showInvoiceScreen("list");
}; };
+32 -5
View File
@@ -33,14 +33,19 @@ body {
} }
.auth-card { .auth-card {
width: min(100%, 460px); display: flex;
padding: clamp(1.75rem, 5vw, 3.5rem); flex-direction: column;
align-items: center;
width: min(100%, 760px);
padding: clamp(1.75rem, 4vw, 3.25rem) clamp(1.75rem, 6vw, 4.5rem);
background: var(--paper); background: var(--paper);
border: 1px solid rgba(24, 59, 86, 0.12); border: 1px solid rgba(24, 59, 86, 0.12);
border-radius: 1rem;
box-shadow: var(--shadow); box-shadow: var(--shadow);
} }
.eyebrow { .eyebrow {
align-self: flex-start;
margin: 0 0 0.75rem; margin: 0 0 0.75rem;
color: var(--blue); color: var(--blue);
font-size: 0.75rem; font-size: 0.75rem;
@@ -52,12 +57,24 @@ body {
margin: 0; margin: 0;
color: var(--navy); color: var(--navy);
font-family: Georgia, serif; font-family: Georgia, serif;
font-size: clamp(2rem, 6vw, 3.4rem); font-size: clamp(2rem, 5vw, 3.2rem);
font-weight: 500; font-weight: 500;
line-height: 1.05; line-height: 1.1;
}
.auth-card h1 span {
display: block;
white-space: nowrap;
}
@media (max-width: 420px) {
.auth-card h1 span {
white-space: normal;
}
} }
.auth-intro { .auth-intro {
max-width: 480px;
margin: 1.25rem 0 2rem; margin: 1.25rem 0 2rem;
color: var(--muted-ink); color: var(--muted-ink);
line-height: 1.6; line-height: 1.6;
@@ -66,6 +83,7 @@ body {
#auth-form { #auth-form {
display: grid; display: grid;
gap: 1rem; gap: 1rem;
width: min(100%, 420px);
} }
#auth-form .form-group { #auth-form .form-group {
@@ -74,18 +92,21 @@ body {
#authSubmitButton { #authSubmitButton {
margin-top: 0.5rem; margin-top: 0.5rem;
min-height: 3rem;
padding: 0.8rem 1rem; padding: 0.8rem 1rem;
color: #fff; color: #fff;
background: var(--navy); background: var(--navy);
border: 0; border: 0;
border-radius: 0.25rem; border-radius: 0.5rem;
cursor: pointer; cursor: pointer;
font: inherit; font: inherit;
font-weight: 700; font-weight: 700;
transition: background-color 160ms ease, transform 160ms ease;
} }
#authSubmitButton:hover:not(:disabled) { #authSubmitButton:hover:not(:disabled) {
background: var(--blue); background: var(--blue);
transform: translateY(-1px);
} }
.text-button { .text-button {
@@ -99,6 +120,12 @@ body {
text-align: left; text-align: left;
} }
.text-button:focus-visible,
#authSubmitButton:focus-visible {
outline: 3px solid rgba(43, 111, 143, 0.3);
outline-offset: 3px;
}
.text-button:hover { .text-button:hover {
color: var(--navy); color: var(--navy);
text-decoration: underline; text-decoration: underline;
+7 -1
View File
@@ -1,4 +1,4 @@
const { normalizeInvoiceData } = require('../src/invoice-data'); const { isValidSignatureDataUrl, normalizeInvoiceData } = require('../src/invoice-data');
describe('normalizeInvoiceData', () => { describe('normalizeInvoiceData', () => {
test('calculates the total from validated line items', () => { test('calculates the total from validated line items', () => {
@@ -49,4 +49,10 @@ describe('normalizeInvoiceData', () => {
}); });
expect(withoutSignature.signature).toBeNull(); expect(withoutSignature.signature).toBeNull();
}); });
test('validates only supported base64 signature image data URLs', () => {
expect(isValidSignatureDataUrl('data:image/jpeg;base64,aGVsbG8=')).toBe(true);
expect(isValidSignatureDataUrl('data:image/gif;base64,aGVsbG8=')).toBe(false);
expect(isValidSignatureDataUrl('data:image/png;base64,not valid')).toBe(false);
});
}); });