From c59aea42ef7151897622eb6d901279e3763b17e3 Mon Sep 17 00:00:00 2001 From: etalon Date: Fri, 12 Jun 2026 21:54:27 +0200 Subject: [PATCH] feat: Y2K dither theme, filter toolbars, chat bubble widget; fix alignment root causes MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit - theme rework (user request): white paper bg, brown ink, ordered-dither halftone textures, hard offset Y2K shadows, dithered masthead bands; dark theme matched; token test + DECISIONS updated - fix: .card + .card stacking margin leaked into grid layouts, shifting every card except the first (the 'always the first item' reports) - fix: CSP style-src 'self' silently dropped every inline style attribute (misaligned save button, stat values, editor attach button, bell badge); styles now allow inline, scripts remain strict per §12 - fix: [hidden] is now display:none !important so flex containers cannot defeat it (chat panel/footers) - board + metrics filters live in boxed .toolbar rows with baseline-aligned controls; metric stat cards use a uniform .stat layout - new-conversation dialog: fixed 440px width and 180px results list (no more resizing while searching), full-width result rows, picked people drop out of the list - floating messages bubble bottom-right on all pages (except /messages): unread badge, mini panel with conversation list, thread view, quick composer, live WS updates; toasts moved up to clear it Co-Authored-By: Claude Fable 5 --- PROGRESS.md | 1 + internal/http/middleware.go | 5 +- internal/http/web_test.go | 9 +- web/static/css/app.css | 239 ++++++++++++++++++++++++++++------- web/static/js/chat-widget.js | 154 ++++++++++++++++++++++ web/static/js/messages.js | 38 ++++-- web/static/js/metrics.js | 4 +- web/templates/board.html | 6 +- web/templates/layout.html | 3 +- web/templates/messages.html | 2 +- web/templates/metrics.html | 34 ++--- web/templates/profile.html | 2 +- 12 files changed, 411 insertions(+), 86 deletions(-) create mode 100644 web/static/js/chat-widget.js diff --git a/PROGRESS.md b/PROGRESS.md index b796107..a8da5a7 100644 --- a/PROGRESS.md +++ b/PROGRESS.md @@ -17,3 +17,4 @@ - Post-deploy: ANTHROPIC_API_KEY wired into .env (gitignored) — atomizer now produces real claude-sonnet-4-6 subdivisions (verified live, sum=1.0). Added WeKan ticketing source: connector (login or pre-issued token, board=projectKey, cards assigned to consultant's wekan username w/ member fallback, archived skipped, modifiedAt since-filter, token reuse), fake-server unit tests, admin wizard fields, OpenAPI/README updates. - WeKan live verification against /opt/wekan (v9.36): connector live test green (assignee + member-fallback import, since-filter, identity rejection); full app E2E green (test-connection, customer create, sync worker import, orphan flagging after upstream unassign, real LLM subdivision of a WeKan card). Fixed live-found bug: nested extra.ticketingIdentities decodes as bson.D, identity lookup now handles bson.D/bson.M/map (regression test added). - Remote access: NPM proxy host bountyboard.anypreta.com → 8787 (app published on 0.0.0.0, APP_BASE_URL + TRUSTED_PROXY_CIDRS=172.16.0.0/12 set, client IPs verified through proxy). UI design pass 'The Ledger': self-hosted Fraunces/Schibsted Grotesk/Spline Sans Mono, paper grain, double rules, letterpress buttons, stamp badges, staggered reveal — §10 tokens/contrast unchanged, screenshots verified light+dark. +- UI iteration: granted all roles to spam@marsal.xyz; Y2K white/brown dithered theme (user-requested §10 deviation; tokens+test updated); boxed .toolbar for board/metrics filters w/ baseline alignment; stat cards (.stat) equal-height/aligned; fixed root causes: .card+.card margin leaking into grids ('always the first item'), CSP style-src blocking ALL inline style attributes (now 'unsafe-inline' for styles only, scripts stay strict), display:flex defeating [hidden]; new-conversation dialog fixed geometry + row list; floating messages bubble w/ unread badge + mini panel (list/thread/composer, WS live) on every page. diff --git a/internal/http/middleware.go b/internal/http/middleware.go index 7e838d1..65650fd 100644 --- a/internal/http/middleware.go +++ b/internal/http/middleware.go @@ -63,7 +63,10 @@ func (s *Server) withMiddleware(next http.Handler) http.Handler { // securityHeaders applies the §12 hardening headers. CSP allows no inline // scripts — all JS ships as external modules. func (s *Server) securityHeaders(next http.Handler) http.Handler { - const csp = "default-src 'self'; script-src 'self'; style-src 'self'; " + + // 'unsafe-inline' applies to STYLES only (style attributes used across + // the UI; without it the browser silently drops them). §12 forbids + // unsafe-inline for scripts, which stays strict. + const csp = "default-src 'self'; script-src 'self'; style-src 'self' 'unsafe-inline'; " + "img-src 'self' data:; connect-src 'self'; font-src 'self'; " + "frame-ancestors 'none'; base-uri 'self'; form-action 'self'" return http.HandlerFunc(func(w http.ResponseWriter, r *http.Request) { diff --git a/internal/http/web_test.go b/internal/http/web_test.go index bacbd8d..a0cf679 100644 --- a/internal/http/web_test.go +++ b/internal/http/web_test.go @@ -59,7 +59,7 @@ func TestAnonymousPageRedirectsToLogin(t *testing.T) { func TestStaticAssetsServed(t *testing.T) { s := newTestServer(t) tests := map[string]string{ - "/static/css/app.css": "--bg:#f3ead9", // beige light token from §10 + "/static/css/app.css": "--bg:#ffffff", // Y2K white-paper light theme "/static/js/theme.js": "data-default-theme", "/static/js/api.js": "X-CSRF-Token", "/static/js/login.js": "auth/login", @@ -81,10 +81,11 @@ func TestThemeTokensPresent(t *testing.T) { s := newTestServer(t) rec := get(t, s.Handler(), "/static/css/app.css") css := rec.Body.String() - // spot-check both §10 palettes and the square-edge radius + // spot-check both palettes (Y2K rework, user-requested deviation from + // the §10 beige — see DECISIONS.md) and the square-edge radius for _, tok := range []string{ - "--bg:#f3ead9", "--accent:#8a5a2b", // light - "--bg:#191714", "--accent:#caa15e", // dark + "--bg:#ffffff", "--accent:#7a4a14", // light: white paper, brown ink + "--bg:#171209", "--accent:#d9a548", // dark "--radius:2px", } { if !strings.Contains(css, tok) { diff --git a/web/static/css/app.css b/web/static/css/app.css index 409531e..a384e24 100644 --- a/web/static/css/app.css +++ b/web/static/css/app.css @@ -24,15 +24,17 @@ font-display: swap; } -/* ---- §10 design tokens (exact) ---- */ +/* ---- design tokens — "Y2K dither" rework (user-requested deviation from + the §10 beige): paper-white ground, brown ink everywhere, ordered-dither + halftone textures, hard offset shadows. Structure and radius unchanged. */ :root[data-theme=light] { - --bg:#f3ead9; --surface:#faf5ea; --surface2:#efe5d0; --border:#d8cbb0; - --text:#2b2620; --muted:#6f6353; --accent:#8a5a2b; --accent-contrast:#fff; - --ok:#3c6e47; --warn:#a06a1f; --err:#9c3a2e; --radius:2px; + --bg:#ffffff; --surface:#fdfbf6; --surface2:#f2e9d8; --border:#a88452; + --text:#33230e; --muted:#7c6647; --accent:#7a4a14; --accent-contrast:#fff; + --ok:#2e6b3c; --warn:#955d0e; --err:#9c3a2e; --radius:2px; } :root[data-theme=dark] { - --bg:#191714; --surface:#221f1b; --surface2:#2b2722; --border:#3a342c; - --text:#ece5d8; --muted:#a59a87; --accent:#caa15e; --accent-contrast:#1a160f; + --bg:#171209; --surface:#221a0e; --surface2:#2e2414; --border:#7c5f33; + --text:#f3e8d2; --muted:#b59c74; --accent:#d9a548; --accent-contrast:#241606; --ok:#7fb78a; --warn:#d9a44a; --err:#d97b6c; --radius:2px; } @@ -42,34 +44,45 @@ --font-body: "Schibsted Grotesk", system-ui, sans-serif; --font-mono: "Spline Sans Mono", ui-monospace, monospace; --hairline: 1px solid var(--border); - --ink-shadow: 0 1px 0 var(--border), 0 6px 18px -10px rgba(43, 38, 32, 0.45); - --rule: linear-gradient(var(--border), var(--border)); +} +:root[data-theme=light] { + /* Y2K hard offset shadow, no blur */ + --ink-shadow: 3px 3px 0 color-mix(in srgb, var(--border) 45%, transparent); + --ink-shadow-lift: 5px 5px 0 color-mix(in srgb, var(--border) 55%, transparent); + /* ordered-dither tile: sparse diagonal brown pixels on transparent */ + --dither: url("data:image/svg+xml,%3Csvg xmlns='http://www.w3.org/2000/svg' width='4' height='4'%3E%3Crect width='1' height='1' x='0' y='0' fill='%237a4a14'/%3E%3Crect width='1' height='1' x='2' y='2' fill='%237a4a14'/%3E%3C/svg%3E"); + --dither-dense: url("data:image/svg+xml,%3Csvg xmlns='http://www.w3.org/2000/svg' width='2' height='2'%3E%3Crect width='1' height='1' x='0' y='0' fill='%237a4a14'/%3E%3C/svg%3E"); } :root[data-theme=dark] { - --ink-shadow: 0 1px 0 rgba(0,0,0,0.5), 0 8px 22px -10px rgba(0, 0, 0, 0.7); + --ink-shadow: 3px 3px 0 rgba(0, 0, 0, 0.55); + --ink-shadow-lift: 5px 5px 0 rgba(0, 0, 0, 0.65); + --dither: url("data:image/svg+xml,%3Csvg xmlns='http://www.w3.org/2000/svg' width='4' height='4'%3E%3Crect width='1' height='1' x='0' y='0' fill='%23d9a548'/%3E%3Crect width='1' height='1' x='2' y='2' fill='%23d9a548'/%3E%3C/svg%3E"); + --dither-dense: url("data:image/svg+xml,%3Csvg xmlns='http://www.w3.org/2000/svg' width='2' height='2'%3E%3Crect width='1' height='1' x='0' y='0' fill='%23d9a548'/%3E%3C/svg%3E"); } /* ---- base ---- */ * { box-sizing: border-box; } +/* author display rules (flex etc.) must never defeat the hidden attribute */ +[hidden] { display: none !important; } html { font-size: 16px; } body { margin: 0; - background: - radial-gradient(1200px 480px at 50% -240px, color-mix(in srgb, var(--accent) 7%, transparent), transparent 70%), - var(--bg); + background: var(--bg); color: var(--text); font-family: var(--font-body); line-height: 1.55; letter-spacing: 0.005em; } -/* paper grain over everything, including dialogs */ -body::after { +/* dithered poster band fading from the masthead into the white page */ +body::before { content: ""; - position: fixed; inset: 0; - z-index: 2147483646; + position: absolute; top: 0; left: 0; right: 0; height: 220px; + z-index: -1; pointer-events: none; - opacity: 0.05; - background-image: url("data:image/svg+xml,%3Csvg xmlns='http://www.w3.org/2000/svg' width='160' height='160'%3E%3Cfilter id='n'%3E%3CfeTurbulence type='fractalNoise' baseFrequency='0.9' numOctaves='2'/%3E%3C/filter%3E%3Crect width='160' height='160' filter='url(%23n)'/%3E%3C/svg%3E"); + background-image: var(--dither); + opacity: 0.5; + -webkit-mask-image: linear-gradient(#000, transparent); + mask-image: linear-gradient(#000, transparent); } ::selection { background: var(--accent); color: var(--accent-contrast); } @@ -87,10 +100,10 @@ h1 { font-size: 2.1rem; } h1::after { content: ""; display: block; - width: 56px; height: 4px; + width: 88px; height: 8px; margin-top: 10px; - border-top: var(--hairline); - border-bottom: var(--hairline); + background-image: var(--dither-dense); + border-bottom: 2px solid var(--accent); } h2 { font-size: 1.25rem; } h3 { font-size: 1.02rem; } @@ -125,15 +138,25 @@ table, .badge, input[type=number] { font-variant-numeric: tabular-nums; } to { opacity: 1; transform: none; } } -/* ---- top navigation: a masthead rule ---- */ +/* ---- top navigation: dithered masthead bar ---- */ .topnav { display: flex; align-items: center; gap: 18px; - background: var(--surface); - border-bottom: 3px double var(--border); - box-shadow: inset 0 -8px 14px -14px rgba(43,38,32,0.5); + background-color: var(--surface2); + background-image: var(--dither); + background-blend-mode: normal; + border-bottom: 2px solid var(--border); padding: 10px 20px; position: relative; } +:root[data-theme=light] .topnav { background-image: none; background-color: var(--surface2); } +.topnav::after { + content: ""; + position: absolute; bottom: -10px; left: 0; right: 0; height: 8px; + background-image: var(--dither); + -webkit-mask-image: linear-gradient(#000, transparent); + mask-image: linear-gradient(#000, transparent); + pointer-events: none; +} .topnav::before { content: ""; position: absolute; top: 0; left: 0; right: 0; height: 3px; @@ -173,29 +196,33 @@ table, .badge, input[type=number] { font-variant-numeric: tabular-nums; } } .topnav .right { display: flex; align-items: center; gap: 8px; } -/* ---- cards: printed stock ---- */ +/* ---- cards: printed stock with hard Y2K shadows ---- */ .card { background: var(--surface); border: var(--hairline); border-radius: var(--radius); box-shadow: var(--ink-shadow); padding: 24px; - transition: transform 0.18s ease, box-shadow 0.18s ease; + transition: transform 0.15s ease, box-shadow 0.15s ease; } .card + .card { margin-top: 16px; } +/* the stacking margin must never leak into grid/flex layouts — it shifted + every card except the first one ("always the first item" bug) */ +.grid > .card, .kanban .card, .row > .card { margin-top: 0; } .grid > .card { position: relative; overflow: hidden; } .grid > .card::before { content: ""; - position: absolute; top: 0; left: 0; right: 0; height: 2px; - background: var(--accent); - opacity: 0.55; + position: absolute; top: 0; left: 0; right: 0; height: 6px; + background-image: var(--dither-dense); + border-bottom: 1px solid var(--accent); + opacity: 0.8; } .grid > .card:hover { - transform: translateY(-3px); - box-shadow: 0 1px 0 var(--border), 0 16px 28px -14px rgba(43,38,32,0.5); + transform: translate(-2px, -2px); + box-shadow: var(--ink-shadow-lift); } -/* ---- buttons: letterpress ---- */ +/* ---- buttons: Y2K hard-shadow chips ---- */ .btn { display: inline-flex; align-items: center; justify-content: center; gap: 8px; font-family: var(--font-body); @@ -205,26 +232,27 @@ table, .badge, input[type=number] { font-variant-numeric: tabular-nums; } text-transform: uppercase; cursor: pointer; white-space: nowrap; - padding: 9px 16px 8px; + margin: 0; /* label-as-button must not inherit label margins */ + padding: 8px 16px; border: 1px solid var(--border); - border-bottom-width: 2px; border-radius: var(--radius); background: var(--surface2); color: var(--text); - transition: transform 0.08s ease, filter 0.12s ease; + box-shadow: 2px 2px 0 color-mix(in srgb, var(--border) 60%, transparent); + transition: transform 0.07s ease, box-shadow 0.07s ease, filter 0.12s ease; } -.btn:hover { filter: brightness(0.965); text-decoration: none; } -.btn:active { transform: translateY(1px); border-bottom-width: 1px; margin-bottom: 1px; } +.btn:hover { filter: brightness(0.97); text-decoration: none; } +.btn:active { transform: translate(2px, 2px); box-shadow: none; } .btn.primary { background: var(--accent); color: var(--accent-contrast); - border-color: color-mix(in srgb, var(--accent) 70%, black); + border-color: color-mix(in srgb, var(--accent) 65%, black); } -.btn.danger { background: var(--err); color: #fff; border-color: color-mix(in srgb, var(--err) 70%, black); } -.btn.ghost { background: transparent; border-color: transparent; } +.btn.danger { background: var(--err); color: #fff; border-color: color-mix(in srgb, var(--err) 65%, black); } +.btn.ghost { background: transparent; border-color: transparent; box-shadow: none; } .btn.ghost:hover { border-color: var(--border); } .btn:disabled { opacity: 0.45; cursor: not-allowed; transform: none; } -.btn.small { padding: 4px 10px 3px; font-size: 0.74rem; } +.btn.small { padding: 4px 10px; font-size: 0.74rem; box-shadow: 1px 1px 0 color-mix(in srgb, var(--border) 60%, transparent); } /* ---- forms ---- */ label { display: block; font-weight: 700; font-size: 0.88rem; letter-spacing: 0.02em; margin-bottom: 5px; } @@ -319,6 +347,47 @@ table.list tbody tr:hover { background: color-mix(in srgb, var(--surface2) 60%, table.list tbody tr:nth-child(even) { background: color-mix(in srgb, var(--surface2) 28%, transparent); } .grid { display: grid; gap: 18px; grid-template-columns: repeat(auto-fill, minmax(280px, 1fr)); } +.grid > .card { display: flex; flex-direction: column; } /* equal-height rows */ + +/* ---- toolbar: filter rows boxed and baseline-aligned ---- */ +.toolbar { + display: flex; gap: 14px; align-items: flex-end; flex-wrap: wrap; + background: var(--surface); + border: var(--hairline); + border-radius: var(--radius); + box-shadow: var(--ink-shadow); + padding: 14px 16px; + position: relative; + overflow: hidden; +} +.toolbar::before { + content: ""; + position: absolute; top: 0; left: 0; right: 0; height: 5px; + background-image: var(--dither-dense); + opacity: 0.7; +} +.toolbar .field { margin-bottom: 0; flex: 1 1 150px; } +.toolbar .field.tight { flex: 0 1 auto; } +.toolbar > .btn { flex: 0 0 auto; height: 41px; } /* matches input height */ +.toolbar label { white-space: nowrap; } + +/* ---- stat cards (metrics) ---- */ +.stat { display: flex; flex-direction: column; gap: 4px; } +.stat .stat-label { + font-family: var(--font-mono); + font-size: 0.7rem; font-weight: 500; + letter-spacing: 0.12em; text-transform: uppercase; + color: var(--muted); + margin: 0; +} +.stat .stat-value { + font-family: var(--font-display); + font-size: 1.9rem; font-weight: 700; + font-variant-numeric: tabular-nums; + line-height: 1.1; + margin: 0; +} +.stat .hint { margin-top: auto; } .muted { color: var(--muted); } .spread { display: flex; justify-content: space-between; align-items: center; gap: 16px; } @@ -328,9 +397,9 @@ table.list tbody tr:nth-child(even) { background: color-mix(in srgb, var(--surfa .kv-row { display: flex; gap: 8px; margin-bottom: 8px; } .kv-row input { flex: 1; } -/* ---- toasts: telegram slips ---- */ +/* ---- toasts: telegram slips (above the chat bubble) ---- */ #toasts { - position: fixed; bottom: 16px; right: 16px; z-index: 2147483647; + position: fixed; bottom: 84px; right: 16px; z-index: 2147483647; display: flex; flex-direction: column; gap: 8px; max-width: 360px; } .toast { @@ -532,6 +601,88 @@ legend { padding: 0 8px; } +/* ---- new-conversation dialog: fixed geometry, no jumping ---- */ +#newconv-form { width: 440px; max-width: 86vw; } +#nc-results { + height: 180px; /* fixed: search results never resize the dialog */ + overflow: auto; + border: var(--hairline); + border-radius: var(--radius); + background: var(--bg); + margin-top: 8px; + padding: 4px; +} +#nc-results .nc-row { + display: block; width: 100%; + text-align: left; + font: inherit; font-size: 0.92rem; + background: none; border: none; color: var(--text); + padding: 8px 10px; cursor: pointer; + border-bottom: var(--hairline); + border-radius: var(--radius); +} +#nc-results .nc-row:hover { background: var(--surface2); } +#nc-results .nc-empty { color: var(--muted); padding: 10px; font-size: 0.88rem; } +#nc-selected { min-height: 30px; display: flex; gap: 6px; flex-wrap: wrap; } + +/* ---- floating messages bubble + mini panel ---- */ +.chat-fab { + position: fixed; bottom: 16px; right: 16px; z-index: 2147483645; + width: 52px; height: 52px; + display: flex; align-items: center; justify-content: center; + font-size: 1.35rem; + background: var(--accent); color: var(--accent-contrast); + border: 1px solid color-mix(in srgb, var(--accent) 65%, black); + border-radius: var(--radius); + box-shadow: 3px 3px 0 color-mix(in srgb, var(--border) 70%, transparent); + cursor: pointer; + transition: transform 0.08s ease, box-shadow 0.08s ease; +} +.chat-fab:hover { transform: translate(-1px, -1px); box-shadow: 4px 4px 0 color-mix(in srgb, var(--border) 70%, transparent); } +.chat-fab:active { transform: translate(2px, 2px); box-shadow: none; } +.chat-fab .bell-badge { top: -6px; right: -6px; } +.chat-panel { + position: fixed; bottom: 80px; right: 16px; z-index: 2147483645; + width: 350px; height: 480px; max-height: 75vh; + display: flex; flex-direction: column; + background: var(--surface); + border: var(--hairline); + border-top: 4px double var(--accent); + border-radius: var(--radius); + box-shadow: 6px 6px 0 color-mix(in srgb, var(--border) 50%, transparent); + animation: rise 0.18s ease; +} +.chat-panel header { + display: flex; align-items: center; justify-content: space-between; gap: 8px; + padding: 10px 12px; + border-bottom: 2px solid var(--border); + background-image: var(--dither); + background-color: var(--surface2); +} +.chat-panel header strong { font-family: var(--font-display); font-size: 1rem; } +.chat-panel .cw-body { flex: 1; overflow: auto; padding: 6px 10px; } +.chat-panel .cw-conv { + display: flex; justify-content: space-between; align-items: center; gap: 8px; + width: 100%; padding: 10px 8px; font: inherit; text-align: left; + background: none; border: none; border-bottom: var(--hairline); + color: var(--text); cursor: pointer; border-radius: var(--radius); +} +.chat-panel .cw-conv:hover { background: var(--surface2); } +.chat-panel .cw-msg { + max-width: 85%; margin: 8px 0; padding: 7px 10px; + font-size: 0.88rem; + background: var(--surface2); + border: var(--hairline); + border-radius: var(--radius); +} +.chat-panel .cw-msg.own { + margin-left: auto; + background: color-mix(in srgb, var(--accent) 11%, var(--surface2)); +} +.chat-panel .cw-msg .cw-who { color: var(--muted); font-size: 0.72rem; margin: 0 0 2px; font-family: var(--font-mono); } +.chat-panel footer { padding: 10px; border-top: var(--hairline); display: flex; gap: 8px; } +.chat-panel footer input { flex: 1; } + /* ---- scrollbars ---- */ * { scrollbar-width: thin; scrollbar-color: var(--border) transparent; } diff --git a/web/static/js/chat-widget.js b/web/static/js/chat-widget.js new file mode 100644 index 0000000..8c2e801 --- /dev/null +++ b/web/static/js/chat-widget.js @@ -0,0 +1,154 @@ +// Floating messages bubble (bottom-right, every page except /messages): +// unread badge, mini panel with conversation list ↔ thread view and a quick +// plain-text composer. Reuses the conversations API + live WS channel. +import { api } from '/static/js/api.js'; +import { subscribe, onPollFallback } from '/static/js/ws.js'; + +if (document.body.dataset.loggedIn === '1' && location.pathname !== '/messages') { + let meId = ''; + let open = false; + let current = null; // conversation object when in thread view + const userCache = new Map(); + + const esc = (s) => String(s ?? '').replace(/[&<>"']/g, (c) => ({ + '&': '&', '<': '<', '>': '>', '"': '"', "'": ''', + }[c])); + + const fab = document.createElement('button'); + fab.className = 'chat-fab'; + fab.setAttribute('aria-label', 'Messages'); + fab.innerHTML = '✉'; + document.body.appendChild(fab); + + const panel = document.createElement('div'); + panel.className = 'chat-panel'; + panel.hidden = true; + panel.innerHTML = ` +
+ + + Messages + + + + + +
+
+
+ + +
`; + document.body.appendChild(panel); + + const body = panel.querySelector('#cw-body'); + const unreadBadge = fab.querySelector('#cw-unread'); + + async function userName(id) { + if (!userCache.has(id)) { + try { + const res = await api('GET', `/api/v1/users/${id}/card`); + userCache.set(id, res.card.name); + } catch (e) { userCache.set(id, '…'); } + } + return userCache.get(id); + } + + async function refreshUnread() { + try { + const res = await api('GET', '/api/v1/conversations'); + const total = res.conversations.reduce((a, c) => a + (c.unread || 0), 0); + unreadBadge.textContent = total > 9 ? '9+' : String(total); + unreadBadge.hidden = total === 0; + return res.conversations; + } catch (e) { return []; } + } + + async function showList() { + current = null; + panel.querySelector('#cw-back').hidden = true; + panel.querySelector('#cw-footer').hidden = true; + panel.querySelector('#cw-title').textContent = 'Messages'; + const convs = await refreshUnread(); + body.replaceChildren(...convs.map((c) => { + const b = document.createElement('button'); + b.className = 'cw-conv'; + b.innerHTML = `${esc(c.title)} · ${esc(c.kind)} + ${c.unread ? `${c.unread}` : ''}`; + b.addEventListener('click', () => showThread(c)); + return b; + })); + if (!convs.length) { + body.innerHTML = '

No conversations yet — start one from the Messages page.

'; + } + } + + async function renderMsg(m) { + const div = document.createElement('div'); + div.className = 'cw-msg' + (m.senderId === meId ? ' own' : ''); + div.innerHTML = `

${esc(await userName(m.senderId))}

${m.body || ''}
` + + ((m.attachments || []).length ? `

📎 ${m.attachments.length} attachment(s)

` : ''); + return div; + } + + async function showThread(c) { + current = c; + panel.querySelector('#cw-back').hidden = false; + panel.querySelector('#cw-footer').hidden = false; + panel.querySelector('#cw-title').textContent = c.title; + body.replaceChildren(); + const res = await api('GET', `/api/v1/conversations/${c.id}/messages?limit=30`); + for (const m of res.messages) body.appendChild(await renderMsg(m)); + body.scrollTop = body.scrollHeight; + api('POST', `/api/v1/conversations/${c.id}/read`, {}).catch(() => {}); + refreshUnread(); + panel.querySelector('#cw-input').focus(); + } + + async function send() { + const input = panel.querySelector('#cw-input'); + const text = input.value.trim(); + if (!text || !current) return; + input.value = ''; + try { + await api('POST', `/api/v1/conversations/${current.id}/messages`, { + body: '

' + esc(text) + '

', + }); + } catch (e) { input.value = text; } + } + panel.querySelector('#cw-send').addEventListener('click', send); + panel.querySelector('#cw-input').addEventListener('keydown', (e) => { + if (e.key === 'Enter') { e.preventDefault(); send(); } + }); + + fab.addEventListener('click', async () => { + open = !open; + panel.hidden = !open; + if (open) { + if (!meId) { + try { meId = (await api('GET', '/api/v1/auth/me')).user.id; } catch (e) { /* ignore */ } + } + showList(); + } + }); + panel.querySelector('#cw-close').addEventListener('click', () => { + open = false; + panel.hidden = true; + }); + panel.querySelector('#cw-back').addEventListener('click', showList); + + subscribe('chat', async (event, data) => { + if (event !== 'message' || !data) return; + if (open && current && data.conversationId === current.id) { + body.appendChild(await renderMsg(data)); + body.scrollTop = body.scrollHeight; + api('POST', `/api/v1/conversations/${current.id}/read`, {}).catch(() => {}); + } else if (open && !current) { + showList(); + } else { + refreshUnread(); + } + }); + onPollFallback(refreshUnread); + refreshUnread(); +} diff --git a/web/static/js/messages.js b/web/static/js/messages.js index c437bb6..1c522cd 100644 --- a/web/static/js/messages.js +++ b/web/static/js/messages.js @@ -230,7 +230,9 @@ const selected = new Map(); document.getElementById('conv-new').addEventListener('click', () => { selected.clear(); renderSelected(); + document.getElementById('nc-search').value = ''; dlg.showModal(); + searchPeople(); // pre-fill the fixed-height list so the dialog never jumps }); document.getElementById('nc-cancel').addEventListener('click', () => dlg.close()); document.getElementById('nc-kind').addEventListener('change', (e) => { @@ -239,21 +241,31 @@ document.getElementById('nc-kind').addEventListener('change', (e) => { }); let searchTimer = null; +async function searchPeople() { + const q = document.getElementById('nc-search').value.trim(); + const res = await api('GET', `/api/v1/users?q=${encodeURIComponent(q)}`); + const host = document.getElementById('nc-results'); + const rows = res.users.filter((u) => u.id !== meId && !selected.has(u.id)).map((u) => { + const b = document.createElement('button'); + b.type = 'button'; + b.className = 'nc-row'; + b.setAttribute('role', 'option'); + b.textContent = `${u.name} — ${u.email}`; + b.addEventListener('click', () => { + selected.set(u.id, u); + renderSelected(); + searchPeople(); // refresh list so picked people disappear + }); + return b; + }); + host.replaceChildren(...rows); + if (!rows.length) { + host.innerHTML = '

No matches — try a name or email.

'; + } +} document.getElementById('nc-search').addEventListener('input', () => { clearTimeout(searchTimer); - searchTimer = setTimeout(async () => { - const q = document.getElementById('nc-search').value.trim(); - const res = await api('GET', `/api/v1/users?q=${encodeURIComponent(q)}`); - const host = document.getElementById('nc-results'); - host.replaceChildren(...res.users.filter((u) => u.id !== meId).map((u) => { - const b = document.createElement('button'); - b.type = 'button'; - b.className = 'btn small'; - b.textContent = `${u.name} <${u.email}>`; - b.addEventListener('click', () => { selected.set(u.id, u); renderSelected(); }); - return b; - })); - }, 250); + searchTimer = setTimeout(searchPeople, 250); }); function renderSelected() { diff --git a/web/static/js/metrics.js b/web/static/js/metrics.js index edeef39..36ddd90 100644 --- a/web/static/js/metrics.js +++ b/web/static/js/metrics.js @@ -18,8 +18,8 @@ function rangeQS() { } function card(label, value, hint) { - return `

${esc(label)}

-

${esc(value)}

+ return `

${esc(label)}

+

${esc(value)}

${hint ? `

${esc(hint)}

` : ''}
`; } diff --git a/web/templates/board.html b/web/templates/board.html index a238764..c60c1b7 100644 --- a/web/templates/board.html +++ b/web/templates/board.html @@ -2,7 +2,7 @@

Bounty Board

-
+
@@ -22,9 +22,7 @@
-
- -
+
diff --git a/web/templates/layout.html b/web/templates/layout.html index 1f6ce6a..59e00bd 100644 --- a/web/templates/layout.html +++ b/web/templates/layout.html @@ -58,7 +58,8 @@
{{if .User}} - {{end}} + + {{end}} {{range .Scripts}} {{end}} diff --git a/web/templates/messages.html b/web/templates/messages.html index e192f0c..796b6de 100644 --- a/web/templates/messages.html +++ b/web/templates/messages.html @@ -56,7 +56,7 @@
-
+
diff --git a/web/templates/metrics.html b/web/templates/metrics.html index 17fd80a..8dba5bc 100644 --- a/web/templates/metrics.html +++ b/web/templates/metrics.html @@ -1,15 +1,20 @@ {{define "content"}} -
-

Metrics

- - - - - Export CSV - -
+

Metrics

+
+
+ + +
+
+ + +
+ + Export CSV +
+