feat(ui): constrain decimal input and add stacked timed toasts with uniform actions

This commit is contained in:
2026-11-02 19:19:07 +01:00
parent 22e509d982
commit 86a73b862a
24 changed files with 315 additions and 103 deletions
+14 -6
View File
@@ -27,6 +27,16 @@ class Program
var appDataDir = Path.Combine(
Environment.GetFolderPath(Environment.SpecialFolder.ApplicationData),
"Duempelkas");
var isolated = false;
#if DEBUG
var directoryIndex = Array.IndexOf(args, "--data-directory");
if (directoryIndex >= 0)
{
if (directoryIndex + 1 >= args.Length) throw new ArgumentException("Datenordner fehlt.");
appDataDir = Path.GetFullPath(args[directoryIndex + 1]);
isolated = true;
}
#endif
Directory.CreateDirectory(appDataDir);
var dbPath = Path.Combine(appDataDir, "duempelkas.db");
@@ -34,7 +44,7 @@ class Program
// One-time compatibility: keep existing data if older versions stored DB next to the app executable.
var legacyExeDirectory = Path.GetDirectoryName(Environment.ProcessPath ?? AppContext.BaseDirectory) ?? AppContext.BaseDirectory;
var legacyDbPath = Path.Combine(legacyExeDirectory, "duempelkas.db");
if (!File.Exists(dbPath) && File.Exists(legacyDbPath))
if (!isolated && !File.Exists(dbPath) && File.Exists(legacyDbPath))
{
try
{
@@ -50,7 +60,7 @@ class Program
}
}
appBuilder.Services.AddInfrastructure($"Data Source={dbPath}");
appBuilder.Services.AddInfrastructure(new Microsoft.Data.Sqlite.SqliteConnectionStringBuilder { DataSource = dbPath }.ToString(), appDataDir);
appBuilder.RootComponents.Add<Duempelkas.App.Components.App>("app");
@@ -72,9 +82,7 @@ class Program
}
// Load saved window size or use defaults
var settingsPath = Path.Combine(
Environment.GetFolderPath(Environment.SpecialFolder.ApplicationData),
"Duempelkas", "window.json");
var settingsPath = Path.Combine(appDataDir, "window.json");
int savedWidth = 1280, savedHeight = 800;
if (File.Exists(settingsPath))
@@ -90,7 +98,7 @@ class Program
}
app.MainWindow
.SetTitle("Dümpelkas – Kassenbuch")
.SetTitle(isolated ? "Dümpelkas – Testdaten" : "Dümpelkas – Kassenbuch")
.SetSize(savedWidth, savedHeight)
.SetUseOsDefaultSize(false);
@@ -612,3 +612,21 @@ fieldset { min-width: 0; }
@media (prefers-reduced-motion: reduce) {
.entry-recent > td { animation: none; border-bottom: 2px solid var(--color-accent); }
}
/* One icon row plus two reserved text rows on every page. */
.action-toolbar { display: flex; flex-wrap: wrap; gap: .5rem; padding: 1rem 0; align-items: flex-start; }
.action-toolbar .btn-nav { box-sizing: border-box; width: 10.5rem; min-width: 10.5rem; max-width: 10.5rem; height: 6.25rem; min-height: 6.25rem; padding: .45rem .6rem; font-size: .9rem !important; line-height: 1.15rem; gap: .2rem; border-radius: .5rem; border: 1px solid transparent; overflow-wrap: normal; }
.action-toolbar .btn-nav i { display: block; font-size: 1.65rem; line-height: 1.85rem; }
.action-toolbar .btn-nav span { display: block; min-height: 2.3rem; }
.action-toolbar .more-actions { align-self: flex-start; position: relative; }
.action-toolbar .more-actions[open] { padding: 0; border: 0; }
.more-actions > button { position: absolute; z-index: 20; right: 0; min-width: 15rem; box-shadow: 0 4px 16px #0004; }
.toast-stack { position: fixed; z-index: 3000; inset: .75rem .75rem auto auto; width: min(28rem, calc(100vw - 1.5rem)); max-height: calc(100vh - 1.5rem); overflow-y: auto; display: flex; flex-direction: column; gap: .65rem; pointer-events: none; }
.app-toast { pointer-events: auto; padding: 1rem 2.5rem 1rem 1rem; color: var(--color-text); background: var(--color-surface); border: 1px solid var(--color-border); border-left: .35rem solid var(--color-accent); border-radius: .65rem; box-shadow: 0 5px 22px #0004; position: relative; animation: toast-enter .2s ease-out; overflow-wrap: anywhere; }
.toast-success { border-left-color: var(--color-income); }
.toast-error { border-left-color: var(--color-expense); }
.app-toast p { margin: 0 0 .5rem; }
.toast-close { position: absolute; right: .3rem; top: .3rem; background: transparent; border: 0; color: inherit; width: 2rem; height: 2rem; font-size: 1.5rem; }
@keyframes toast-enter { from { transform: translateY(-2rem); opacity: 0; } to { transform: translateY(0); opacity: 1; } }
@media (prefers-reduced-motion: reduce) { .app-toast { animation: none; } }
.input-rejection:empty { display: none; }
@@ -126,6 +126,7 @@
<a class="dismiss">×</a>
</div>
<script src="js/app.js"></script>
<script src="_framework/blazor.webview.js"></script>
</body>
</html>
+19
View File
@@ -0,0 +1,19 @@
// Keep decimal editing predictable: reject the complete invalid edit/paste, never round it.
(() => {
function allowed(input, value) {
return (input.dataset.allowNegative === 'true' ? /^-?\d*([,.]\d{0,2})?$/ : /^\d*([,.]\d{0,2})?$/).test(value);
}
function accept(input, text) {
const next = input.value.slice(0, input.selectionStart) + text + input.value.slice(input.selectionEnd);
const ok = allowed(input, next);
const hint = document.getElementById(input.id + '-rejected');
if (hint) hint.textContent = ok ? '' : 'Diese Eingabe wurde nicht übernommen. Bitte einen Betrag wie 12,50 eingeben.';
return ok;
}
document.addEventListener('beforeinput', e => {
if (e.target.matches('[data-amount-guard]') && e.data != null && !accept(e.target, e.data)) e.preventDefault();
});
document.addEventListener('paste', e => {
if (e.target.matches('[data-amount-guard]') && !accept(e.target, e.clipboardData.getData('text').trim())) e.preventDefault();
});
})();