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 3081b2a99b Default issue date field to today's date
Prefill 'Datum vystaveni' with the current local date when opening a
new invoice, instead of leaving it blank. Also switch date formatting
to use local date components instead of toISOString() to avoid UTC
timezone shifting the displayed date.

Co-authored-by: Copilot App <223556219+Copilot@users.noreply.github.com>
2026-09-10 17:42:31 +02:00
5 changed files with 105 additions and 170 deletions
+11 -25
View File
@@ -362,51 +362,37 @@ app.delete('/api/payment-presets/:id', requireAuth, async (req, res, next) => {
}
});
const signaturePresetResponse = (row) => ({
id: row.id,
name: row.name,
dataUrl: row.data_url
});
app.get('/api/signature-presets', requireAuth, async (req, res, next) => {
app.get('/api/signature', requireAuth, async (req, res, next) => {
try {
const result = await pool.query(
'SELECT * FROM signature_presets WHERE user_id = $1 ORDER BY name',
[req.userId]
);
res.json({ presets: result.rows.map(signaturePresetResponse) });
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.post('/api/signature-presets', requireAuth, async (req, res, next) => {
app.put('/api/signature', requireAuth, async (req, res, next) => {
try {
const name = String(req.body.name || '').trim();
const dataUrl = req.body.dataUrl;
if (!name) {
res.status(400).json({ error: 'Zadejte název podpisu.' });
return;
}
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_presets (user_id, name, data_url)
VALUES ($1, $2, $3)
ON CONFLICT (user_id, name) DO UPDATE SET data_url = EXCLUDED.data_url
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, name, dataUrl]);
res.status(201).json({ preset: signaturePresetResponse(result.rows[0]) });
`, [req.userId, dataUrl]);
res.json({ signature: result.rows[0].data_url });
} catch (error) {
next(error);
}
});
app.delete('/api/signature-presets/:id', requireAuth, async (req, res, next) => {
app.delete('/api/signature', requireAuth, async (req, res, next) => {
try {
await pool.query('DELETE FROM signature_presets WHERE id = $1 AND user_id = $2', [req.params.id, req.userId]);
await pool.query('DELETE FROM signature_profiles WHERE user_id = $1', [req.userId]);
res.status(204).end();
} catch (error) {
next(error);
+11
View File
@@ -90,10 +90,21 @@ const initializeDatabase = () => {
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 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 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) => {
schemaPromise = undefined;
throw error;
+13 -15
View File
@@ -9,9 +9,14 @@
<body>
<section id="auth-screen" aria-labelledby="auth-title">
<div class="auth-card">
<p class="eyebrow">FAKTUROVAČ</p>
<h1 id="auth-title">Vaše faktury, bezpečně po ruce</h1>
<p class="auth-intro">Přihlaste se a mějte dodavatele i odběratele uložené ve svém účtu.</p>
<!--<p class="eyebrow">FAKTUROVAČ</p>-->
<h1 id="auth-title">
<span>Fakturovač</span>
</h1>
<p class="auth-intro">
<span>Vaše faktury,</span>
<span>bezpečně po ruce.</span>
</p>
<form id="auth-form">
<div class="form-group">
<label for="authEmail">E-mail</label>
@@ -338,26 +343,19 @@
<div class="form-chapter signature-row">
<div class="form-group signature-group">
<label for="signatureInput">Podpis (nepovinné)</label>
<div class="form-group">
<label for="signaturePresetSelect">Uložený podpis</label>
<select id="signaturePresetSelect">
<option value="">Vyberte podpis</option>
</select>
</div>
<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" 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>
<input type="file" id="signatureInput" accept="image/png,image/jpeg" hidden />
</div>
<div class="signature-save-row">
<input id="signaturePresetName" type="text" placeholder="Název podpisu" aria-label="Název podpisu" />
<button type="button" id="saveSignaturePresetButton">Uložit podpis</button>
<button type="button" id="deleteSignaturePresetButton" class="secondary-button" disabled>Smazat</button>
</div>
<div class="signature-actions">
<button type="button" id="removeSignatureButton" class="secondary-button" hidden>Odstranit podpis</button>
</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>
+37 -70
View File
@@ -57,10 +57,8 @@ const signaturePreview = document.getElementById("signaturePreview");
const signatureDropzoneText = document.getElementById("signatureDropzoneText");
const removeSignatureButton = document.getElementById("removeSignatureButton");
const signatureStatus = document.getElementById("signatureStatus");
const signaturePresetSelect = document.getElementById("signaturePresetSelect");
const signaturePresetName = document.getElementById("signaturePresetName");
const saveSignaturePresetButton = document.getElementById("saveSignaturePresetButton");
const deleteSignaturePresetButton = document.getElementById("deleteSignaturePresetButton");
const saveSignatureButton = document.getElementById("saveSignatureButton");
const loadSignatureButton = document.getElementById("loadSignatureButton");
const loadPaymentPresetButton = document.getElementById("loadPaymentPresetButton");
const deletePaymentPresetButton = document.getElementById("deletePaymentPresetButton");
@@ -82,7 +80,6 @@ let itemIndex = 0;
let clients = [];
let invoices = [];
let paymentPresets = [];
let signaturePresets = [];
let currentInvoiceId = null;
let isRegistrationMode = false;
let signatureDataUrl = null;
@@ -110,22 +107,31 @@ const applySignaturePreview = (dataUrl) => {
}
};
const renderSignaturePresetOptions = (selectedId = signaturePresetSelect.value) => {
signaturePresetSelect.innerHTML = "<option value=\"\">Vyberte podpis</option>";
signaturePresets.forEach((preset) => {
const option = document.createElement("option");
option.value = preset.id;
option.textContent = preset.name;
signaturePresetSelect.appendChild(option);
const saveSignature = async () => {
if (!signatureDataUrl) {
setSignatureStatus("Nejprve nahrajte podpis.", true);
return;
}
try {
await apiRequest("/api/signature", {
method: "PUT",
body: JSON.stringify({ dataUrl: signatureDataUrl })
});
signaturePresetSelect.value = selectedId;
deleteSignaturePresetButton.disabled = !signaturePresetSelect.value;
setSignatureStatus("Podpis byl uložen.");
} catch (error) {
setSignatureStatus(error.message, true);
}
};
const loadSignaturePresets = async () => {
const response = await apiRequest("/api/signature-presets");
signaturePresets = response.presets;
renderSignaturePresetOptions();
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) => {
@@ -185,49 +191,11 @@ signatureInput.addEventListener("change", () => {
handleSignatureFile(signatureInput.files[0]);
signatureInput.value = "";
});
signaturePresetSelect.addEventListener("change", () => {
const preset = signaturePresets.find(({ id }) => String(id) === signaturePresetSelect.value);
deleteSignaturePresetButton.disabled = !preset;
if (preset) {
signaturePresetName.value = preset.name;
applySignaturePreview(preset.dataUrl);
setSignatureStatus("Uložený podpis načten.");
}
});
saveSignaturePresetButton.addEventListener("click", async () => {
if (!signatureDataUrl) {
setSignatureStatus("Nejprve nahrajte nebo vyberte podpis.", true);
return;
}
saveSignatureButton.addEventListener("click", saveSignature);
loadSignatureButton.addEventListener("click", async () => {
try {
const response = await apiRequest("/api/signature-presets", {
method: "POST",
body: JSON.stringify({
name: signaturePresetName.value,
dataUrl: signatureDataUrl
})
});
const preset = response.preset;
signaturePresets = [
...signaturePresets.filter((entry) => entry.id !== preset.id && entry.name !== preset.name),
preset
].sort((left, right) => left.name.localeCompare(right.name));
renderSignaturePresetOptions(String(preset.id));
signaturePresetName.value = preset.name;
setSignatureStatus("Podpis byl uložen.");
} catch (error) {
setSignatureStatus(error.message, true);
}
});
deleteSignaturePresetButton.addEventListener("click", async () => {
if (!signaturePresetSelect.value) {
return;
}
try {
await apiRequest(`/api/signature-presets/${signaturePresetSelect.value}`, { method: "DELETE" });
signaturePresets = signaturePresets.filter(({ id }) => String(id) !== signaturePresetSelect.value);
signaturePresetName.value = "";
renderSignaturePresetOptions();
await loadSignature();
setSignatureStatus("Podpis načten.");
} catch (error) {
setSignatureStatus(error.message, true);
}
@@ -277,7 +245,11 @@ const getClientLabel = (client) => [
const formatCzechDate = (dateValue) => new Date(`${dateValue}T00:00:00`).toLocaleDateString("cs-CZ");
const toDateInputValue = (date) => date.toISOString().slice(0, 10);
const toDateInputValue = (date) => [
date.getFullYear(),
String(date.getMonth() + 1).padStart(2, "0"),
String(date.getDate()).padStart(2, "0")
].join("-");
const getIssueDate = () => issueDateInput.value || toDateInputValue(new Date());
@@ -435,7 +407,7 @@ const clearInvoiceEditor = () => {
currentInvoiceId = null;
invoiceNumberDisplay.textContent = "Nová faktura";
invoiceSaveStatus.textContent = "";
issueDateInput.value = "";
issueDateInput.value = toDateInputValue(new Date());
dueDateInput.value = "";
writeProfileFields("odberatel", {});
clientSelect.value = "";
@@ -444,9 +416,6 @@ const clearInvoiceEditor = () => {
paymentPresetSelect.value = "";
paymentPresetName.value = "";
deletePaymentPresetButton.disabled = true;
signaturePresetSelect.value = "";
signaturePresetName.value = "";
deleteSignaturePresetButton.disabled = true;
["cisloUctu", "iban", "swift"].forEach((id) => {
document.getElementById(id).value = "";
});
@@ -476,9 +445,6 @@ const loadInvoiceIntoEditor = (invoice) => {
document.getElementById(id).value = invoice.data.payment?.[id] || "";
});
applySignaturePreview(invoice.data.signature || null);
signaturePresetSelect.value = "";
signaturePresetName.value = "";
deleteSignaturePresetButton.disabled = true;
setSignatureStatus("");
itemsContainer.replaceChildren();
itemIndex = 0;
@@ -517,8 +483,9 @@ const loadInvoices = async () => {
renderInvoiceList();
};
const openNewInvoice = () => {
const openNewInvoice = async () => {
clearInvoiceEditor();
await loadSignature();
showInvoiceScreen("editor");
};
@@ -705,7 +672,7 @@ deleteClientButton.addEventListener("click", async () => {
const showInvoiceApp = async () => {
authScreen.hidden = true;
document.getElementById("main-container").hidden = false;
await Promise.all([loadSupplier(), loadClients(), loadInvoices(), loadPaymentPresets(), loadSignaturePresets(), loadNumberingSettings()]);
await Promise.all([loadSupplier(), loadClients(), loadInvoices(), loadPaymentPresets(), loadSignature(), loadNumberingSettings()]);
showInvoiceScreen("list");
};
+32 -59
View File
@@ -33,14 +33,19 @@ body {
}
.auth-card {
width: min(100%, 460px);
padding: clamp(1.75rem, 5vw, 3.5rem);
display: flex;
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);
border: 1px solid rgba(24, 59, 86, 0.12);
border-radius: 1rem;
box-shadow: var(--shadow);
}
.eyebrow {
align-self: flex-start;
margin: 0 0 0.75rem;
color: var(--blue);
font-size: 0.75rem;
@@ -52,12 +57,24 @@ body {
margin: 0;
color: var(--navy);
font-family: Georgia, serif;
font-size: clamp(2rem, 6vw, 3.4rem);
font-size: clamp(2rem, 5vw, 3.2rem);
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 {
max-width: 480px;
margin: 1.25rem 0 2rem;
color: var(--muted-ink);
line-height: 1.6;
@@ -66,6 +83,7 @@ body {
#auth-form {
display: grid;
gap: 1rem;
width: min(100%, 420px);
}
#auth-form .form-group {
@@ -74,18 +92,21 @@ body {
#authSubmitButton {
margin-top: 0.5rem;
min-height: 3rem;
padding: 0.8rem 1rem;
color: #fff;
background: var(--navy);
border: 0;
border-radius: 0.25rem;
border-radius: 0.5rem;
cursor: pointer;
font: inherit;
font-weight: 700;
transition: background-color 160ms ease, transform 160ms ease;
}
#authSubmitButton:hover:not(:disabled) {
background: var(--blue);
transform: translateY(-1px);
}
.text-button {
@@ -99,6 +120,12 @@ body {
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 {
color: var(--navy);
text-decoration: underline;
@@ -682,56 +709,6 @@ body {
margin-top: 0.5rem;
}
.signature-save-row {
display: grid;
grid-template-columns: minmax(0, 1fr) auto auto;
gap: 0.5rem;
margin-top: 0.75rem;
}
.signature-save-row input {
min-width: 0;
padding: 0.55rem 0.7rem;
color: var(--ink);
background: #fff;
border: 1px solid #c8d0d5;
border-radius: 0.25rem;
font: inherit;
}
.signature-save-row button {
padding: 0.55rem 0.7rem;
color: var(--navy);
background: transparent;
border: 1px solid var(--blue);
border-radius: 0.25rem;
cursor: pointer;
font: inherit;
font-size: 0.85rem;
font-weight: 700;
white-space: nowrap;
}
.signature-save-row button:hover:not(:disabled) {
color: #fff;
background: var(--blue);
}
.signature-save-row .secondary-button {
color: #8c3c36;
border-color: #c47a72;
}
.signature-save-row .secondary-button:hover:not(:disabled) {
color: #fff;
background: #a53c36;
}
.signature-save-row button:disabled {
cursor: not-allowed;
opacity: 0.45;
}
.signature-status {
margin: 0.4rem 0 0;
min-height: 1.1rem;
@@ -1013,10 +990,6 @@ select:focus {
grid-template-columns: 1fr;
}
.signature-save-row {
grid-template-columns: 1fr;
}
.delete-item-button {
justify-self: end;
}