Serve the TUI's screens as a web app
money serve puts the seven screens in a browser: accounts, transactions, the report with its period axis and sort, the rule builder with its live preview and edit-in-place, the rules list, the transfer builder with its tolerance preview, and the transfers list. Import and retag are buttons in the header. It is one binary: the page is plain HTML, CSS and JavaScript embedded with go:embed, with no framework and no build step. It is a second frontend, not a second implementation. Amounts are formatted on the server, the rule preview is matched by glob.Match there, and the transfer preview runs transfers.Analyze, so the browser only lays out answers and cannot drift from the TUI on what a rule catches or what a pair costs. The server outlives hand edits to rules.toml in a way the TUI does not, so retag and import re-read it first, as a fresh `money retag` or `money import` would, and the overview says when the file on disk no longer matches what the index was derived from. Edits and deletes still go by position, but carry the rule they showed and are refused unless rules.toml still holds exactly that there; a position from a stale tab could otherwise name a different rule. An edit takes the type pattern from the rule on disk, never from the request. There is no authentication, by request. It listens on loopback unless --addr says otherwise, and writes must be sent as JSON so a cross-site form cannot post to it. Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com>
This commit is contained in:
@@ -0,0 +1,189 @@
|
||||
:root {
|
||||
--bg: #f7f7f5;
|
||||
--surface: #ffffff;
|
||||
--text: #1d1d1f;
|
||||
--muted: #6b6b70;
|
||||
--border: #e2e2de;
|
||||
--accent: #4b46c8;
|
||||
--accent-text: #ffffff;
|
||||
--accent-soft: #ecebfb;
|
||||
--negative: #b3261e;
|
||||
--positive: #1d7a3a;
|
||||
--warn: #9a5b00;
|
||||
--warn-soft: #fff4e0;
|
||||
--error-soft: #fdecea;
|
||||
--row-hover: #f2f2ef;
|
||||
--mono: ui-monospace, "SF Mono", "Cascadia Mono", Menlo, Consolas, monospace;
|
||||
color-scheme: light;
|
||||
}
|
||||
@media (prefers-color-scheme: dark) {
|
||||
:root {
|
||||
--bg: #161618;
|
||||
--surface: #1e1e21;
|
||||
--text: #e8e8ea;
|
||||
--muted: #9a9aa2;
|
||||
--border: #333338;
|
||||
--accent: #8f8cf0;
|
||||
--accent-text: #111114;
|
||||
--accent-soft: #2a2950;
|
||||
--negative: #f2867d;
|
||||
--positive: #6fcf8e;
|
||||
--warn: #f0b45a;
|
||||
--warn-soft: #3a2c14;
|
||||
--error-soft: #3d1c1a;
|
||||
--row-hover: #26262a;
|
||||
color-scheme: dark;
|
||||
}
|
||||
}
|
||||
|
||||
* { box-sizing: border-box; }
|
||||
body {
|
||||
margin: 0;
|
||||
background: var(--bg);
|
||||
color: var(--text);
|
||||
font: 14px/1.45 system-ui, -apple-system, "Segoe UI", sans-serif;
|
||||
}
|
||||
button, input, select { font: inherit; color: inherit; }
|
||||
|
||||
.top {
|
||||
display: flex;
|
||||
align-items: center;
|
||||
gap: 16px;
|
||||
padding: 10px 16px;
|
||||
background: var(--surface);
|
||||
border-bottom: 1px solid var(--border);
|
||||
flex-wrap: wrap;
|
||||
position: sticky;
|
||||
top: 0;
|
||||
z-index: 2;
|
||||
}
|
||||
.brand { font-weight: 700; font-size: 16px; color: var(--text); text-decoration: none; }
|
||||
#nav { display: flex; gap: 2px; flex-wrap: wrap; flex: 1; }
|
||||
#nav a {
|
||||
padding: 5px 10px;
|
||||
border-radius: 6px;
|
||||
color: var(--muted);
|
||||
text-decoration: none;
|
||||
white-space: nowrap;
|
||||
}
|
||||
#nav a:hover { color: var(--text); background: var(--row-hover); }
|
||||
#nav a.active { color: var(--accent); background: var(--accent-soft); font-weight: 600; }
|
||||
#nav kbd { font-family: var(--mono); font-size: 11px; opacity: .6; margin-right: 4px; }
|
||||
.actions { display: flex; gap: 8px; }
|
||||
|
||||
button {
|
||||
padding: 5px 12px;
|
||||
border-radius: 6px;
|
||||
border: 1px solid var(--border);
|
||||
background: var(--surface);
|
||||
cursor: pointer;
|
||||
}
|
||||
button:hover:not(:disabled) { border-color: var(--muted); }
|
||||
button:disabled { opacity: .5; cursor: default; }
|
||||
button.primary { background: var(--accent); border-color: var(--accent); color: var(--accent-text); }
|
||||
button.danger { color: var(--negative); }
|
||||
button.link { border: none; background: none; padding: 0 4px; color: var(--accent); }
|
||||
|
||||
#banners { padding: 0 16px; }
|
||||
.banner {
|
||||
margin-top: 10px;
|
||||
padding: 8px 12px;
|
||||
border-radius: 6px;
|
||||
display: flex;
|
||||
gap: 12px;
|
||||
align-items: center;
|
||||
justify-content: space-between;
|
||||
white-space: pre-wrap;
|
||||
}
|
||||
.banner.info { background: var(--accent-soft); }
|
||||
.banner.warn { background: var(--warn-soft); color: var(--warn); }
|
||||
.banner.error { background: var(--error-soft); color: var(--negative); }
|
||||
.banner button { flex: none; }
|
||||
|
||||
main { padding: 16px; max-width: 1400px; }
|
||||
h2 { font-size: 16px; margin: 0 0 12px; font-weight: 600; }
|
||||
h2 .sub { color: var(--muted); font-weight: 400; }
|
||||
|
||||
.toolbar { display: flex; gap: 10px; align-items: center; flex-wrap: wrap; margin-bottom: 12px; }
|
||||
.toolbar input[type=search] { min-width: 220px; }
|
||||
input[type=text], input[type=search], select {
|
||||
padding: 5px 8px;
|
||||
border: 1px solid var(--border);
|
||||
border-radius: 6px;
|
||||
background: var(--surface);
|
||||
}
|
||||
input:focus, select:focus { outline: 2px solid var(--accent); outline-offset: -1px; border-color: transparent; }
|
||||
label.check { display: inline-flex; gap: 6px; align-items: center; cursor: pointer; }
|
||||
|
||||
.panel {
|
||||
background: var(--surface);
|
||||
border: 1px solid var(--border);
|
||||
border-radius: 8px;
|
||||
overflow: auto;
|
||||
}
|
||||
table { border-collapse: collapse; width: 100%; }
|
||||
th, td { padding: 5px 10px; text-align: left; white-space: nowrap; }
|
||||
th {
|
||||
position: sticky;
|
||||
top: 0;
|
||||
background: var(--surface);
|
||||
border-bottom: 1px solid var(--border);
|
||||
font-weight: 600;
|
||||
z-index: 1;
|
||||
}
|
||||
tbody tr:hover { background: var(--row-hover); }
|
||||
tbody tr.clickable { cursor: pointer; }
|
||||
td.num, th.num { text-align: right; font-family: var(--mono); font-variant-numeric: tabular-nums; }
|
||||
td.desc { white-space: normal; min-width: 240px; }
|
||||
td.mark { width: 1em; text-align: center; padding-right: 0; }
|
||||
.neg { color: var(--negative); }
|
||||
.muted { color: var(--muted); }
|
||||
.warn { color: var(--warn); }
|
||||
th.sortable { cursor: pointer; user-select: none; }
|
||||
th.sortable:hover { color: var(--accent); }
|
||||
tr.total td { font-weight: 600; border-top: 1px solid var(--border); }
|
||||
tr.excluded td { color: var(--muted); }
|
||||
tr.excluded.first td { border-top: 1px dashed var(--border); }
|
||||
td.actions { text-align: right; }
|
||||
td.actions button { padding: 1px 8px; font-size: 12px; }
|
||||
|
||||
.empty { padding: 32px 16px; color: var(--muted); white-space: pre-line; text-align: center; }
|
||||
.more { padding: 10px; text-align: center; }
|
||||
|
||||
.report { display: grid; grid-template-columns: 220px 1fr; gap: 12px; align-items: start; }
|
||||
.periods { padding: 6px; }
|
||||
.periods button {
|
||||
display: block;
|
||||
width: 100%;
|
||||
text-align: left;
|
||||
border: none;
|
||||
background: none;
|
||||
padding: 4px 8px;
|
||||
border-radius: 5px;
|
||||
color: var(--muted);
|
||||
}
|
||||
.periods button:hover { background: var(--row-hover); color: var(--text); }
|
||||
.periods button.active { background: var(--accent-soft); color: var(--accent); font-weight: 600; }
|
||||
.periods .head { font-weight: 600; padding: 4px 8px; }
|
||||
|
||||
.builder { display: grid; grid-template-columns: 320px 1fr; gap: 16px; align-items: start; }
|
||||
.form { padding: 14px; display: flex; flex-direction: column; gap: 12px; }
|
||||
.field label { display: block; font-weight: 600; margin-bottom: 3px; }
|
||||
.field input { width: 100%; font-family: var(--mono); }
|
||||
.field .hint { color: var(--muted); font-size: 12px; margin-top: 2px; }
|
||||
.form .summary { font-weight: 600; white-space: pre-line; }
|
||||
.form .buttons { display: flex; gap: 8px; }
|
||||
.kept { font-size: 12px; color: var(--muted); font-family: var(--mono); }
|
||||
.preview { max-height: calc(100vh - 170px); }
|
||||
|
||||
.import-files { margin-top: 6px; font-size: 13px; }
|
||||
.import-files li { font-family: var(--mono); }
|
||||
|
||||
@media (max-width: 760px) {
|
||||
.report, .builder { grid-template-columns: 1fr; }
|
||||
.periods { display: flex; flex-wrap: wrap; gap: 2px; }
|
||||
.periods button { width: auto; }
|
||||
.periods .head { width: 100%; }
|
||||
.toolbar input[type=search] { min-width: 0; flex: 1; }
|
||||
.preview { max-height: none; }
|
||||
}
|
||||
Reference in New Issue
Block a user