0){E().setTimeout(e,x.swapDelay)}else{e()}}if(f){fe(o,"htmx:responseError",ce({error:"Response Status Error Code "+s.status+" from "+i.pathInfo.requestPath},i))}}const Mn={};function Xn(){return{init:function(e){return null},getSelectors:function(){return null},onEvent:function(e,t){return true},transformResponse:function(e,t,n){return e},isInlineSwap:function(e){return false},handleSwap:function(e,t,n,r){return false},encodeParameters:function(e,t,n){return null}}}function Fn(e,t){if(t.init){t.init(n)}Mn[e]=ce(Xn(),t)}function Bn(e){delete Mn[e]}function Un(e,n,r){if(n==undefined){n=[]}if(e==undefined){return n}if(r==undefined){r=[]}const t=te(e,"hx-ext");if(t){se(t.split(","),function(e){e=e.replace(/ /g,"");if(e.slice(0,7)=="ignore:"){r.push(e.slice(7));return}if(r.indexOf(e)<0){const t=Mn[e];if(t&&n.indexOf(t)<0){n.push(t)}}})}return Un(ue(c(e)),n,r)}var jn=false;ne().addEventListener("DOMContentLoaded",function(){jn=true});function Vn(e){if(jn||ne().readyState==="complete"){e()}else{ne().addEventListener("DOMContentLoaded",e)}}function _n(){if(Q.config.includeIndicatorStyles!==false){const e=Q.config.inlineStyleNonce?` nonce="${Q.config.inlineStyleNonce}"`:"";ne().head.insertAdjacentHTML("beforeend","")}}function zn(){const e=ne().querySelector('meta[name="htmx-config"]');if(e){return S(e.content)}else{return null}}function $n(){const e=zn();if(e){Q.config=ce(Q.config,e)}}Vn(function(){$n();_n();let e=ne().body;kt(e);const t=ne().querySelectorAll("[hx-trigger='restored'],[data-hx-trigger='restored']");e.addEventListener("htmx:abort",function(e){const t=e.target;const n=ie(t);if(n&&n.xhr){n.xhr.abort()}});const n=window.onpopstate?window.onpopstate.bind(window):null;window.onpopstate=function(e){if(e.state&&e.state.htmx){Wt();se(t,function(e){he(e,"htmx:restored",{document:ne(),triggerEvent:he})})}else{if(n){n(e)}}};E().setTimeout(function(){he(e,"htmx:load",{});e=null},0)});return Q}();
\ No newline at end of file
diff --git a/internal/web/static/keys.js b/internal/web/static/keys.js
new file mode 100644
index 00000000..784d7ae0
--- /dev/null
+++ b/internal/web/static/keys.js
@@ -0,0 +1,431 @@
+// Keyboard shortcuts for msgvault web UI
+// Mirrors TUI bindings: j/k navigation, / search, ? help
+(function () {
+ 'use strict';
+
+ var activeRow = -1;
+
+ function getRows() {
+ var table = document.querySelector('.data-table tbody');
+ return table ? table.querySelectorAll('tr') : [];
+ }
+
+ function clearActive() {
+ var rows = getRows();
+ for (var i = 0; i < rows.length; i++) {
+ rows[i].classList.remove('kb-active');
+ }
+ }
+
+ function setActive(index) {
+ var rows = getRows();
+ if (rows.length === 0) return;
+ if (index < 0) index = 0;
+ if (index >= rows.length) index = rows.length - 1;
+ clearActive();
+ activeRow = index;
+ rows[activeRow].classList.add('kb-active');
+ rows[activeRow].scrollIntoView({ block: 'nearest' });
+ }
+
+ function openActiveRow(linkIndex) {
+ var rows = getRows();
+ if (activeRow < 0 || activeRow >= rows.length) return;
+ var links = rows[activeRow].querySelectorAll('a');
+ if (links.length === 0) return;
+ var idx = (linkIndex !== undefined && linkIndex < links.length) ? linkIndex : 0;
+ links[idx].click();
+ }
+
+ function isInputFocused() {
+ var el = document.activeElement;
+ if (!el) return false;
+ var tag = el.tagName.toLowerCase();
+ return tag === 'input' || tag === 'textarea' || tag === 'select' || el.isContentEditable;
+ }
+
+ // Help overlay
+ function toggleHelp() {
+ var overlay = document.getElementById('help-overlay');
+ if (overlay) overlay.classList.toggle('visible');
+ }
+
+ function hideHelp() {
+ var overlay = document.getElementById('help-overlay');
+ if (overlay) overlay.classList.remove('visible');
+ }
+
+ // Search form loading state
+ function setupSearchLoading() {
+ var form = document.querySelector('.search-form');
+ if (!form) return;
+ form.addEventListener('submit', function () {
+ var btn = form.querySelector('.search-btn');
+ if (!btn) return;
+ btn.disabled = true;
+ btn.innerHTML = ' Searching\u2026';
+ });
+ }
+
+ // Store message IDs for prev/next navigation
+ function storeMessageList() {
+ var rows = getRows();
+ var ids = [];
+ for (var i = 0; i < rows.length; i++) {
+ var link = rows[i].querySelector('a[href^="/messages/"]');
+ if (link) {
+ var match = link.getAttribute('href').match(/\/messages\/(\d+)/);
+ if (match) ids.push(match[1]);
+ }
+ }
+ if (ids.length > 0) {
+ sessionStorage.setItem('msgvault-msg-list', JSON.stringify(ids));
+ }
+ }
+
+ // On message detail page, add prev/next navigation links
+ function setupMessageNav() {
+ var path = window.location.pathname;
+ var match = path.match(/^\/messages\/(\d+)$/);
+ if (!match) return;
+
+ var currentId = match[1];
+ var ids = JSON.parse(sessionStorage.getItem('msgvault-msg-list') || '[]');
+ var idx = ids.indexOf(currentId);
+ if (idx < 0) return;
+
+ var nav = document.querySelector('.breadcrumb');
+ if (!nav) return;
+
+ var navSpan = document.createElement('span');
+ navSpan.className = 'msg-nav';
+ if (idx > 0) {
+ var prev = document.createElement('a');
+ prev.href = '/messages/' + ids[idx - 1];
+ prev.innerHTML = '← Prev';
+ prev.className = 'msg-nav-link';
+ prev.id = 'msg-prev';
+ navSpan.appendChild(prev);
+ }
+ if (idx < ids.length - 1) {
+ var next = document.createElement('a');
+ next.href = '/messages/' + ids[idx + 1];
+ next.innerHTML = 'Next →';
+ next.className = 'msg-nav-link';
+ next.id = 'msg-next';
+ navSpan.appendChild(next);
+ }
+ if (navSpan.children.length > 0) {
+ // Add position indicator
+ var pos = document.createElement('span');
+ pos.className = 'msg-nav-pos';
+ pos.textContent = (idx + 1) + ' / ' + ids.length;
+ navSpan.appendChild(pos);
+ nav.appendChild(navSpan);
+ }
+ }
+
+ document.addEventListener('keydown', function (e) {
+ // Always allow Escape to close help / exit delete mode
+ if (e.key === 'Escape') {
+ var overlay = document.getElementById('help-overlay');
+ if (overlay && overlay.classList.contains('visible')) {
+ hideHelp();
+ e.preventDefault();
+ return;
+ }
+ if (isDeleteMode()) {
+ exitDeleteMode();
+ e.preventDefault();
+ return;
+ }
+ // Escape also blurs search input
+ if (isInputFocused()) {
+ document.activeElement.blur();
+ e.preventDefault();
+ return;
+ }
+ }
+
+ // Don't capture shortcuts when typing in inputs
+ if (isInputFocused()) return;
+
+ switch (e.key) {
+ case '/':
+ e.preventDefault();
+ // If on search page, focus the input
+ var searchInput = document.querySelector('.search-input');
+ if (searchInput) {
+ searchInput.focus();
+ searchInput.select();
+ } else {
+ // Navigate to search page
+ window.location.href = '/search';
+ }
+ break;
+
+ case '?':
+ e.preventDefault();
+ toggleHelp();
+ break;
+
+ case 'j':
+ case 'ArrowDown':
+ e.preventDefault();
+ setActive(activeRow + 1);
+ break;
+
+ case 'k':
+ case 'ArrowUp':
+ e.preventDefault();
+ setActive(activeRow - 1);
+ break;
+
+ case 'Enter':
+ if (activeRow >= 0) {
+ e.preventDefault();
+ openActiveRow(0);
+ }
+ break;
+
+ case 'o':
+ // Open messages for active row (second link, or first if only one)
+ if (activeRow >= 0) {
+ e.preventDefault();
+ openActiveRow(1);
+ }
+ break;
+
+ case 'g':
+ // Go to first row
+ e.preventDefault();
+ setActive(0);
+ break;
+
+ case 'G':
+ e.preventDefault();
+ var rows = getRows();
+ setActive(rows.length - 1);
+ break;
+
+ case 'H':
+ // Go home (dashboard)
+ window.location.href = '/';
+ break;
+
+ case 'B':
+ // Go to browse
+ window.location.href = '/browse';
+ break;
+
+ case 'Backspace':
+ // Navigate back via breadcrumb link
+ e.preventDefault();
+ var backLink = document.querySelector('.breadcrumb a');
+ if (backLink) {
+ backLink.click();
+ }
+ break;
+
+ case 'n':
+ // Next page
+ var nextLink = document.querySelector('.pagination a:last-of-type');
+ if (nextLink && nextLink.textContent.trim() === 'Next') {
+ nextLink.click();
+ }
+ break;
+
+ case 'p':
+ // Previous page
+ var prevLink = document.querySelector('.pagination a:first-of-type');
+ if (prevLink && prevLink.textContent.trim() === 'Prev') {
+ prevLink.click();
+ }
+ break;
+
+ case 'ArrowLeft':
+ // Previous message (detail view)
+ var prevMsg = document.getElementById('msg-prev');
+ if (prevMsg) { prevMsg.click(); e.preventDefault(); }
+ break;
+
+ case 'ArrowRight':
+ // Next message (detail view)
+ var nextMsg = document.getElementById('msg-next');
+ if (nextMsg) { nextMsg.click(); e.preventDefault(); }
+ break;
+
+ case 'd':
+ // Enter delete mode
+ if (!isDeleteMode()) {
+ e.preventDefault();
+ enterDeleteMode();
+ }
+ break;
+
+ case ' ':
+ // Toggle selection on active row (delete mode only)
+ if (isDeleteMode() && activeRow >= 0) {
+ e.preventDefault();
+ toggleActiveRowCheckbox();
+ }
+ break;
+
+ case 'x':
+ // Clear selection (delete mode)
+ if (isDeleteMode()) {
+ var boxes = document.querySelectorAll('.msg-checkbox');
+ for (var i = 0; i < boxes.length; i++) boxes[i].checked = false;
+ var selectAll = document.getElementById('select-all');
+ if (selectAll) { selectAll.checked = false; selectAll.indeterminate = false; }
+ updateSelectionInfo();
+ }
+ break;
+
+ case 'A':
+ // Select all (delete mode)
+ if (isDeleteMode()) {
+ e.preventDefault();
+ selectAllMessages();
+ }
+ break;
+ }
+ });
+
+ // Click on help overlay backdrop to close
+ document.addEventListener('click', function (e) {
+ var overlay = document.getElementById('help-overlay');
+ if (overlay && e.target === overlay) {
+ hideHelp();
+ }
+ });
+
+ // Theme toggle: cycles auto → dark → light → auto
+ function setupThemeToggle() {
+ var btn = document.getElementById('theme-toggle');
+ if (!btn) return;
+
+ var saved = localStorage.getItem('msgvault-theme') || 'auto';
+ applyTheme(saved);
+
+ btn.addEventListener('click', function () {
+ var current = localStorage.getItem('msgvault-theme') || 'auto';
+ var next = current === 'auto' ? 'dark' : current === 'dark' ? 'light' : 'auto';
+ localStorage.setItem('msgvault-theme', next);
+ applyTheme(next);
+ });
+ }
+
+ function applyTheme(theme) {
+ var root = document.documentElement;
+ var btn = document.getElementById('theme-toggle');
+ if (theme === 'dark') {
+ root.setAttribute('data-theme', 'dark');
+ if (btn) btn.textContent = '\u263E'; // moon
+ } else if (theme === 'light') {
+ root.setAttribute('data-theme', 'light');
+ if (btn) btn.textContent = '\u2600'; // sun
+ } else {
+ root.removeAttribute('data-theme');
+ if (btn) btn.textContent = '\u25D1'; // half circle (auto)
+ }
+ }
+
+ // Delete mode — toggled by 'd' key
+ var deleteMode = false;
+
+ function isDeleteMode() {
+ return deleteMode;
+ }
+
+ function enterDeleteMode() {
+ if (!document.querySelector('.msg-checkbox')) return; // no checkboxes on page
+ deleteMode = true;
+ document.body.classList.add('delete-mode');
+ updateSelectionInfo();
+ }
+
+ window.exitDeleteMode = function () {
+ deleteMode = false;
+ document.body.classList.remove('delete-mode');
+ // Uncheck everything
+ var boxes = document.querySelectorAll('.msg-checkbox');
+ for (var i = 0; i < boxes.length; i++) boxes[i].checked = false;
+ var selectAll = document.getElementById('select-all');
+ if (selectAll) { selectAll.checked = false; selectAll.indeterminate = false; }
+ updateSelectionInfo();
+ };
+
+ window.selectAllMessages = function () {
+ var boxes = document.querySelectorAll('.msg-checkbox');
+ for (var i = 0; i < boxes.length; i++) boxes[i].checked = true;
+ var selectAll = document.getElementById('select-all');
+ if (selectAll) { selectAll.checked = true; selectAll.indeterminate = false; }
+ updateSelectionInfo();
+ };
+
+ function updateSelectionInfo() {
+ var info = document.getElementById('sel-info');
+ var submit = document.getElementById('sel-submit');
+ if (!info) return;
+ var checked = document.querySelectorAll('.msg-checkbox:checked');
+ var total = document.querySelectorAll('.msg-checkbox');
+ if (checked.length === 0) {
+ info.textContent = 'Select messages to stage for deletion';
+ if (submit) { submit.disabled = true; submit.textContent = 'Stage for Deletion'; }
+ } else {
+ info.textContent = checked.length + ' of ' + total.length + ' selected';
+ if (submit) { submit.disabled = false; submit.textContent = 'Stage ' + checked.length + ' for Deletion'; }
+ }
+ // Update select-all checkbox state
+ var selectAll = document.getElementById('select-all');
+ if (selectAll) {
+ selectAll.checked = total.length > 0 && checked.length === total.length;
+ selectAll.indeterminate = checked.length > 0 && checked.length < total.length;
+ }
+ }
+
+ function setupSelection() {
+ document.addEventListener('change', function (e) {
+ if (e.target.classList.contains('msg-checkbox') || e.target.id === 'select-all') {
+ if (e.target.id === 'select-all') {
+ var boxes = document.querySelectorAll('.msg-checkbox');
+ for (var i = 0; i < boxes.length; i++) boxes[i].checked = e.target.checked;
+ }
+ updateSelectionInfo();
+ }
+ });
+ }
+
+ function toggleActiveRowCheckbox() {
+ if (!isDeleteMode()) return;
+ var rows = getRows();
+ if (activeRow < 0 || activeRow >= rows.length) return;
+ var cb = rows[activeRow].querySelector('.msg-checkbox');
+ if (cb) {
+ cb.checked = !cb.checked;
+ updateSelectionInfo();
+ }
+ }
+
+ // Auto-dismiss flash notices
+ function setupFlashDismiss() {
+ var flash = document.querySelector('.flash-notice');
+ if (flash) {
+ setTimeout(function () {
+ flash.style.transition = 'opacity 0.3s';
+ flash.style.opacity = '0';
+ setTimeout(function () { flash.remove(); }, 300);
+ }, 4000);
+ }
+ }
+
+ // Reset active row on page load
+ activeRow = -1;
+ setupSearchLoading();
+ setupThemeToggle();
+ setupSelection();
+ storeMessageList();
+ setupMessageNav();
+ setupFlashDismiss();
+})();
diff --git a/internal/web/static/style.css b/internal/web/static/style.css
new file mode 100644
index 00000000..2d049ee9
--- /dev/null
+++ b/internal/web/static/style.css
@@ -0,0 +1,673 @@
+/* Solarized light */
+:root {
+ --bg: #f5f3ef;
+ --bg-alt: #eae8e3;
+ --bg-hover: #dfddd7;
+ --fg: #657b83;
+ --fg-muted: #93a1a1;
+ --border: #ccc9c0;
+ --accent: #268bd2;
+ --accent-hover: #2aa198;
+ --danger: #dc322f;
+ --font: ui-monospace, "Cascadia Code", "Source Code Pro", Menlo, Consolas, monospace;
+ --font-sans: -apple-system, BlinkMacSystemFont, "Segoe UI", Roboto, sans-serif;
+ --radius: 4px;
+}
+
+/* Solarized dark — auto (OS preference) */
+@media (prefers-color-scheme: dark) {
+ :root:not([data-theme="light"]) {
+ --bg: #002b36;
+ --bg-alt: #073642;
+ --bg-hover: #0a4050;
+ --fg: #93a1a1;
+ --fg-muted: #657b83;
+ --border: #2a4a53;
+ --accent: #268bd2;
+ --accent-hover: #2aa198;
+ --danger: #dc322f;
+ }
+}
+
+/* Solarized dark — forced via toggle */
+:root[data-theme="dark"] {
+ --bg: #002b36;
+ --bg-alt: #073642;
+ --bg-hover: #0a4050;
+ --fg: #93a1a1;
+ --fg-muted: #657b83;
+ --border: #2a4a53;
+ --accent: #268bd2;
+ --accent-hover: #2aa198;
+ --danger: #dc322f;
+}
+
+*, *::before, *::after { box-sizing: border-box; margin: 0; padding: 0; }
+
+body {
+ font-family: var(--font-sans);
+ font-size: 14px;
+ line-height: 1.5;
+ color: var(--fg);
+ background: var(--bg);
+}
+
+a { color: var(--accent); text-decoration: none; }
+a:hover { color: var(--accent-hover); text-decoration: underline; }
+
+/* Layout */
+.app { max-width: 1200px; margin: 0 auto; padding: 0 16px; }
+
+.header {
+ display: flex;
+ align-items: center;
+ justify-content: space-between;
+ padding: 12px 0;
+ border-bottom: 1px solid var(--border);
+ margin-bottom: 16px;
+}
+
+.header-title {
+ font-family: var(--font);
+ font-size: 16px;
+ font-weight: 700;
+ color: var(--fg);
+}
+
+.header-right {
+ display: flex;
+ gap: 16px;
+ align-items: center;
+}
+
+.header-nav {
+ display: flex;
+ gap: 16px;
+ align-items: center;
+}
+
+/* Theme toggle */
+.theme-toggle {
+ background: none;
+ border: 1px solid var(--border);
+ border-radius: var(--radius);
+ padding: 3px 8px;
+ cursor: pointer;
+ font-size: 14px;
+ line-height: 1;
+ color: var(--fg-muted);
+}
+.theme-toggle:hover {
+ background: var(--bg-hover);
+ color: var(--fg);
+}
+
+.header-nav a {
+ color: var(--fg-muted);
+ font-size: 13px;
+ font-weight: 500;
+ padding: 4px 8px;
+ border-radius: var(--radius);
+}
+
+.header-nav a:hover {
+ color: var(--fg);
+ background: var(--bg-hover);
+ text-decoration: none;
+}
+
+.header-nav a.active {
+ color: var(--accent);
+ background: var(--bg-alt);
+}
+
+/* Stats bar */
+.stats-bar {
+ display: flex;
+ gap: 24px;
+ padding: 8px 0;
+ font-size: 12px;
+ color: var(--fg-muted);
+ border-bottom: 1px solid var(--border);
+ margin-bottom: 16px;
+ font-family: var(--font);
+}
+
+.stat-item { display: flex; gap: 4px; align-items: center; }
+.stat-value { color: var(--fg); font-weight: 600; }
+
+/* Cards */
+.card {
+ background: var(--bg-alt);
+ border: 1px solid var(--border);
+ border-radius: var(--radius);
+ padding: 16px;
+ margin-bottom: 12px;
+}
+
+.card-title {
+ font-size: 13px;
+ font-weight: 600;
+ color: var(--fg-muted);
+ text-transform: uppercase;
+ letter-spacing: 0.5px;
+ margin-bottom: 12px;
+}
+
+/* Dashboard grid */
+.dashboard-stats {
+ display: grid;
+ grid-template-columns: repeat(auto-fit, minmax(160px, 1fr));
+ gap: 12px;
+ margin-bottom: 24px;
+}
+
+.stat-card {
+ background: var(--bg-alt);
+ border: 1px solid var(--border);
+ border-radius: var(--radius);
+ padding: 16px;
+ text-align: center;
+}
+
+.stat-card-value {
+ font-family: var(--font);
+ font-size: 28px;
+ font-weight: 700;
+ color: var(--fg);
+ line-height: 1.2;
+}
+
+.stat-card-label {
+ font-size: 12px;
+ color: var(--fg-muted);
+ text-transform: uppercase;
+ letter-spacing: 0.5px;
+ margin-top: 4px;
+}
+
+/* Account list */
+.account-list { list-style: none; }
+
+.account-item {
+ display: flex;
+ align-items: center;
+ justify-content: space-between;
+ padding: 10px 0;
+ border-bottom: 1px solid var(--border);
+}
+
+.account-item:last-child { border-bottom: none; }
+
+.account-email {
+ font-family: var(--font);
+ font-size: 13px;
+ font-weight: 500;
+}
+
+.account-meta {
+ font-size: 12px;
+ color: var(--fg-muted);
+}
+
+/* Tables */
+.data-table {
+ width: 100%;
+ border-collapse: collapse;
+ font-size: 13px;
+ font-family: var(--font);
+}
+
+.data-table th {
+ text-align: left;
+ padding: 8px 12px;
+ font-size: 11px;
+ font-weight: 600;
+ color: var(--fg-muted);
+ text-transform: uppercase;
+ letter-spacing: 0.5px;
+ border-bottom: 2px solid var(--border);
+ white-space: nowrap;
+ user-select: none;
+}
+
+.data-table th a {
+ color: var(--fg-muted);
+ display: flex;
+ align-items: center;
+ gap: 4px;
+}
+
+.data-table th a:hover { color: var(--fg); text-decoration: none; }
+
+.data-table td {
+ padding: 6px 12px;
+ border-bottom: 1px solid var(--border);
+ white-space: nowrap;
+ overflow: hidden;
+ text-overflow: ellipsis;
+ max-width: 400px;
+}
+
+.data-table tbody tr:hover { background: var(--bg-hover); }
+.data-table tbody tr.kb-active { background: var(--bg-hover); outline: 2px solid var(--accent); outline-offset: -2px; }
+
+.data-table .num { text-align: right; font-variant-numeric: tabular-nums; }
+
+/* Empty state */
+.empty-state {
+ text-align: center;
+ padding: 48px 16px;
+ color: var(--fg-muted);
+}
+
+.empty-state-title {
+ font-size: 16px;
+ font-weight: 600;
+ margin-bottom: 8px;
+ color: var(--fg);
+}
+
+/* Pill buttons (toggle tabs, filter controls) */
+.pill-group { display: flex; gap: 4px; }
+.pill {
+ padding: 4px 10px;
+ border-radius: var(--radius);
+ font-size: 12px;
+ text-decoration: none;
+ border: 1px solid var(--border);
+ color: var(--fg-muted);
+}
+.pill:hover { color: var(--fg); text-decoration: none; background: var(--bg-hover); }
+.pill.active {
+ font-weight: 600;
+ background: var(--accent);
+ color: #fff;
+ border-color: var(--accent);
+}
+.pill.active:hover { background: var(--accent-hover); border-color: var(--accent-hover); color: #fff; }
+
+.pill-sm {
+ padding: 2px 8px;
+ border-radius: 3px;
+ font-size: 11px;
+ text-decoration: none;
+ border: 1px solid var(--border);
+ color: var(--fg-muted);
+}
+.pill-sm:hover { color: var(--fg); text-decoration: none; background: var(--bg-hover); }
+.pill-sm.active {
+ font-weight: 600;
+ background: var(--accent);
+ color: #fff;
+ border-color: var(--accent);
+}
+.pill-sm.active:hover { background: var(--accent-hover); border-color: var(--accent-hover); color: #fff; }
+.pill-sm.active-muted {
+ font-weight: 600;
+ background: var(--fg-muted);
+ color: var(--bg);
+ border-color: var(--fg-muted);
+}
+
+/* Breadcrumb navigation */
+.breadcrumb {
+ margin-bottom: 12px;
+ font-size: 13px;
+ color: var(--fg-muted);
+}
+
+/* Result count */
+.result-count {
+ margin-bottom: 8px;
+ font-size: 12px;
+ color: var(--fg-muted);
+}
+
+/* Pagination */
+.pagination {
+ margin-top: 12px;
+ display: flex;
+ justify-content: center;
+ gap: 4px;
+ align-items: center;
+ font-size: 13px;
+}
+.pagination a {
+ padding: 4px 10px;
+ border-radius: var(--radius);
+ border: 1px solid var(--border);
+ color: var(--fg-muted);
+ text-decoration: none;
+}
+.pagination a:hover { background: var(--bg-hover); color: var(--fg); text-decoration: none; }
+.pagination .page-info { padding: 4px 8px; color: var(--fg-muted); }
+
+/* Search form */
+.search-form { margin-bottom: 16px; }
+.search-row { display: flex; gap: 8px; align-items: stretch; }
+.search-input {
+ flex: 1;
+ padding: 8px 12px;
+ font-size: 14px;
+ font-family: var(--font);
+ border: 1px solid var(--border);
+ border-radius: var(--radius);
+ background: var(--bg);
+ color: var(--fg);
+ outline: none;
+}
+.search-input:focus { border-color: var(--accent); }
+.search-btn {
+ padding: 8px 16px;
+ font-size: 14px;
+ font-weight: 600;
+ border: 1px solid var(--accent);
+ border-radius: var(--radius);
+ background: var(--accent);
+ color: #fff;
+ cursor: pointer;
+ display: flex;
+ align-items: center;
+ gap: 6px;
+}
+.search-btn:hover { background: var(--accent-hover); border-color: var(--accent-hover); }
+.search-btn:disabled { opacity: 0.7; cursor: not-allowed; }
+.search-controls {
+ margin-top: 8px;
+ display: flex;
+ gap: 8px;
+ align-items: center;
+ flex-wrap: wrap;
+ font-size: 12px;
+}
+.search-hint { color: var(--fg-muted); margin-right: 8px; }
+.search-divider { border-left: 1px solid var(--border); padding-left: 8px; }
+
+/* Toolbar (view selector + filters row) */
+.toolbar {
+ margin-bottom: 16px;
+ display: flex;
+ align-items: center;
+ justify-content: space-between;
+ flex-wrap: wrap;
+ gap: 8px;
+}
+.toolbar-left { display: flex; gap: 8px; align-items: center; flex-wrap: wrap; }
+.toolbar-right { display: flex; gap: 8px; align-items: center; font-size: 12px; flex-wrap: wrap; }
+.account-select {
+ padding: 3px 8px;
+ font-size: 12px;
+ font-family: var(--font);
+ border: 1px solid var(--border);
+ border-radius: var(--radius);
+ background: var(--bg);
+ color: var(--fg);
+ cursor: pointer;
+ outline: none;
+}
+.account-select:hover { border-color: var(--fg-muted); }
+.account-select:focus { border-color: var(--accent); }
+
+/* Message detail */
+.msg-subject {
+ font-size: 18px;
+ font-weight: 600;
+ margin-bottom: 12px;
+ line-height: 1.3;
+}
+.msg-headers {
+ font-size: 13px;
+ border-collapse: collapse;
+}
+.msg-header-label {
+ padding: 2px 12px 2px 0;
+ color: var(--fg-muted);
+ white-space: nowrap;
+ vertical-align: top;
+}
+.msg-header-value { padding: 2px 0; }
+.msg-label {
+ display: inline-block;
+ padding: 1px 6px;
+ border-radius: 3px;
+ font-size: 11px;
+ background: var(--bg-alt);
+ border: 1px solid var(--border);
+ margin-right: 4px;
+}
+.msg-section {
+ margin-top: 12px;
+ padding-top: 12px;
+ border-top: 1px solid var(--border);
+}
+.msg-section-title {
+ font-size: 12px;
+ color: var(--fg-muted);
+ font-weight: 600;
+ margin-bottom: 6px;
+}
+.msg-attachment {
+ font-size: 13px;
+ padding: 2px 0;
+ color: var(--fg-muted);
+}
+.msg-attachment-size {
+ font-size: 11px;
+ color: var(--fg-muted);
+ margin-left: 4px;
+}
+.msg-body {
+ white-space: pre-wrap;
+ word-wrap: break-word;
+ font-family: var(--font);
+ font-size: 13px;
+ line-height: 1.6;
+ margin: 0;
+}
+
+/* Attachment badge in message lists */
+.attachment-badge {
+ margin-left: 4px;
+ font-size: 11px;
+ color: var(--fg-muted);
+}
+
+/* Date cells in message tables */
+.date-cell {
+ white-space: nowrap;
+ font-size: 12px;
+ color: var(--fg-muted);
+}
+
+/* Loading spinner */
+.spinner {
+ display: inline-block;
+ width: 14px;
+ height: 14px;
+ border: 2px solid rgba(255, 255, 255, 0.3);
+ border-top-color: #fff;
+ border-radius: 50%;
+ animation: spin 0.6s linear infinite;
+}
+
+@keyframes spin { to { transform: rotate(360deg); } }
+
+/* Help overlay */
+.help-overlay {
+ display: none;
+ position: fixed;
+ inset: 0;
+ background: rgba(0, 0, 0, 0.5);
+ z-index: 100;
+ align-items: center;
+ justify-content: center;
+}
+.help-overlay.visible { display: flex; }
+.help-dialog {
+ background: var(--bg);
+ border: 1px solid var(--border);
+ border-radius: 8px;
+ padding: 24px;
+ max-width: 420px;
+ width: 90%;
+ box-shadow: 0 8px 32px rgba(0, 0, 0, 0.3);
+}
+.help-dialog h3 {
+ font-size: 15px;
+ font-weight: 600;
+ margin-bottom: 16px;
+ color: var(--fg);
+}
+.help-dialog table {
+ width: 100%;
+ font-size: 13px;
+ border-collapse: collapse;
+}
+.help-dialog td {
+ padding: 4px 0;
+ color: var(--fg);
+}
+.help-dialog td:first-child {
+ width: 80px;
+ color: var(--fg-muted);
+}
+.help-dialog kbd {
+ display: inline-block;
+ padding: 1px 6px;
+ font-family: var(--font);
+ font-size: 12px;
+ background: var(--bg-alt);
+ border: 1px solid var(--border);
+ border-radius: 3px;
+}
+.help-dialog .help-close {
+ margin-top: 16px;
+ text-align: center;
+ font-size: 12px;
+ color: var(--fg-muted);
+}
+
+/* Danger button (small) */
+.btn-danger-sm {
+ padding: 4px 10px;
+ font-size: 12px;
+ font-weight: 600;
+ border: 1px solid var(--danger);
+ border-radius: var(--radius);
+ background: none;
+ color: var(--danger);
+ cursor: pointer;
+ text-decoration: none;
+}
+.btn-danger-sm:hover {
+ background: var(--danger);
+ color: #fff;
+ text-decoration: none;
+}
+
+/* Command reference table */
+.cmd-table {
+ width: 100%;
+ font-size: 13px;
+ border-collapse: collapse;
+}
+.cmd-table td {
+ padding: 4px 0;
+}
+.cmd-table td:first-child {
+ padding-right: 16px;
+ white-space: nowrap;
+}
+.cmd-table td:last-child {
+ color: var(--fg-muted);
+}
+.cmd-table code {
+ font-family: var(--font);
+ font-size: 12px;
+ padding: 2px 6px;
+ background: var(--bg-alt);
+ border-radius: var(--radius);
+}
+
+/* Selection / delete mode */
+.sel-cell {
+ width: 0;
+ padding: 0 !important;
+ overflow: hidden;
+ transition: width 0.15s, padding 0.15s;
+}
+.sel-cell input[type="checkbox"] {
+ display: none;
+}
+.delete-mode .sel-cell {
+ width: 28px;
+ padding: 4px 6px !important;
+}
+.delete-mode .sel-cell input[type="checkbox"] {
+ display: inline;
+ cursor: pointer;
+ margin: 0;
+}
+.selection-bar {
+ display: none;
+ position: fixed;
+ bottom: 0;
+ left: 0;
+ right: 0;
+ padding: 10px 24px;
+ background: var(--bg-alt);
+ border-top: 2px solid var(--danger);
+ font-size: 13px;
+ font-weight: 600;
+ align-items: center;
+ gap: 12px;
+ z-index: 100;
+}
+.delete-mode .selection-bar {
+ display: flex;
+}
+
+/* Flash notices */
+.flash-notice {
+ padding: 8px 16px;
+ margin-bottom: 16px;
+ background: rgba(42, 161, 152, 0.12);
+ border: 1px solid #2aa198;
+ border-radius: var(--radius);
+ color: var(--fg);
+ font-size: 13px;
+}
+
+/* Message navigation (prev/next) */
+.msg-nav {
+ float: right;
+ display: flex;
+ gap: 12px;
+ align-items: center;
+}
+.msg-nav-link {
+ font-size: 13px;
+}
+.msg-nav-pos {
+ font-size: 12px;
+ color: var(--fg-muted);
+}
+
+/* Attachment download links */
+.msg-attachment-link {
+ color: var(--accent);
+}
+.msg-attachment-link:hover {
+ color: var(--accent-hover);
+}
+
+/* Footer */
+.footer {
+ padding: 16px 0;
+ margin-top: 24px;
+ border-top: 1px solid var(--border);
+ font-size: 12px;
+ color: var(--fg-muted);
+ text-align: center;
+}
diff --git a/internal/web/templates/aggregates.templ b/internal/web/templates/aggregates.templ
new file mode 100644
index 00000000..536ba8e3
--- /dev/null
+++ b/internal/web/templates/aggregates.templ
@@ -0,0 +1,397 @@
+package templates
+
+import (
+ "fmt"
+ "net/url"
+ "sort"
+ "github.com/wesm/msgvault/internal/query"
+)
+
+type BrowseData struct {
+ Stats *query.TotalStats
+ Rows []query.AggregateRow
+ ViewType string
+ ViewLabel string
+ SortField string
+ SortDir string
+ Granularity string
+ AccountID string
+ Attachments bool
+ HideDeleted bool
+ Accounts []query.AccountInfo
+ // Drill-down context
+ DrillFilters map[string]string
+ Breadcrumbs []Breadcrumb
+}
+
+type Breadcrumb struct {
+ Label string
+ URL string
+}
+
+// buildURL constructs a URL with properly encoded query parameters.
+func buildURL(path string, params ...string) string {
+ u := url.URL{Path: path}
+ q := u.Query()
+ for i := 0; i+1 < len(params); i += 2 {
+ if params[i+1] != "" {
+ q.Set(params[i], params[i+1])
+ }
+ }
+ u.RawQuery = q.Encode()
+ return u.String()
+}
+
+// addFilterParams appends the common filter params to a url.Values.
+func (d BrowseData) addFilterParams(q url.Values) {
+ if d.AccountID != "" {
+ q.Set("account", d.AccountID)
+ }
+ if d.Attachments {
+ q.Set("attachments", "1")
+ }
+ if d.HideDeleted {
+ q.Set("hide_deleted", "1")
+ }
+}
+
+// addDrillParams appends drill filter params to a url.Values.
+func (d BrowseData) addDrillParams(q url.Values) {
+ for k, v := range d.DrillFilters {
+ q.Set(k, v)
+ }
+}
+
+// currentBase returns the current page URL with all state preserved.
+func (d BrowseData) currentBase() string {
+ var path string
+ if len(d.DrillFilters) > 0 {
+ path = "/browse/drill"
+ } else {
+ path = "/browse"
+ }
+ q := url.Values{}
+ q.Set("view", d.ViewType)
+ q.Set("sort", d.SortField)
+ q.Set("dir", d.SortDir)
+ if d.Granularity != "" {
+ q.Set("granularity", d.Granularity)
+ }
+ d.addDrillParams(q)
+ d.addFilterParams(q)
+ return path + "?" + q.Encode()
+}
+
+// sortURL returns the URL for clicking a column header to sort.
+func (d BrowseData) sortURL(field string) string {
+ dir := "desc"
+ if d.SortField == field && d.SortDir == "desc" {
+ dir = "asc"
+ }
+ var path string
+ if len(d.DrillFilters) > 0 {
+ path = "/browse/drill"
+ } else {
+ path = "/browse"
+ }
+ q := url.Values{}
+ q.Set("view", d.ViewType)
+ q.Set("sort", field)
+ q.Set("dir", dir)
+ if d.Granularity != "" {
+ q.Set("granularity", d.Granularity)
+ }
+ d.addDrillParams(q)
+ d.addFilterParams(q)
+ return path + "?" + q.Encode()
+}
+
+// sortIndicator returns the sort arrow for a column header.
+func (d BrowseData) sortIndicator(field string) string {
+ if d.SortField != field {
+ return ""
+ }
+ if d.SortDir == "asc" {
+ return " ↑"
+ }
+ return " ↓"
+}
+
+// drillURL returns the URL for drilling into an aggregate row.
+func (d BrowseData) drillURL(key string) string {
+ filterKey := viewTypeToFilterParam(d.ViewType)
+ q := url.Values{}
+ q.Set("view", d.ViewType)
+ if key == "" {
+ q.Set(filterKey, "")
+ } else {
+ q.Set(filterKey, key)
+ }
+ q.Set("sort", d.SortField)
+ q.Set("dir", d.SortDir)
+ d.addDrillParams(q)
+ if d.Granularity != "" {
+ q.Set("granularity", d.Granularity)
+ }
+ d.addFilterParams(q)
+ return "/browse/drill?" + q.Encode()
+}
+
+// messagesURL returns the URL for viewing messages matching a filter.
+func (d BrowseData) messagesURL(key string) string {
+ filterKey := viewTypeToFilterParam(d.ViewType)
+ q := url.Values{}
+ if key == "" {
+ q.Set(filterKey, "")
+ } else {
+ q.Set(filterKey, key)
+ }
+ d.addDrillParams(q)
+ // Include granularity for time views so the messages query uses the
+ // correct time precision (year/month/day) instead of defaulting to month.
+ if d.ViewType == "time" && d.Granularity != "" {
+ q.Set("granularity", d.Granularity)
+ }
+ d.addFilterParams(q)
+ return "/messages?" + q.Encode()
+}
+
+// ViewTabURL returns the URL for switching to a different view type.
+func (d BrowseData) ViewTabURL(viewType string) string {
+ q := url.Values{}
+ q.Set("view", viewType)
+ q.Set("sort", d.SortField)
+ q.Set("dir", d.SortDir)
+ d.addFilterParams(q)
+ return "/browse?" + q.Encode()
+}
+
+// granularityTabURL returns the URL for switching time granularity.
+func (d BrowseData) granularityTabURL(granularity string) string {
+ q := url.Values{}
+ q.Set("view", "time")
+ q.Set("granularity", granularity)
+ q.Set("sort", d.SortField)
+ q.Set("dir", d.SortDir)
+ d.addFilterParams(q)
+ return "/browse?" + q.Encode()
+}
+
+// sortedDrillKeys returns drill filter keys in deterministic order.
+func (d BrowseData) sortedDrillKeys() []string {
+ keys := make([]string, 0, len(d.DrillFilters))
+ for k := range d.DrillFilters {
+ keys = append(keys, k)
+ }
+ sort.Strings(keys)
+ return keys
+}
+
+// filterToggleURL returns the URL to toggle a boolean filter on or off.
+func (d BrowseData) filterToggleURL(key string, currentlyOn bool) string {
+ base := d.currentBase()
+ if currentlyOn {
+ return deleteParam(base, key)
+ }
+ return addParam(base, key, "1")
+}
+
+// accountFilterURL returns the URL for filtering by a specific account (or all).
+func (d BrowseData) accountFilterURL(accountID string) string {
+ base := d.currentBase()
+ if accountID == "" {
+ return deleteParam(base, "account")
+ }
+ // Replace any existing account param
+ base = deleteParam(base, "account")
+ return addParam(base, "account", accountID)
+}
+
+func viewTypeToFilterParam(viewType string) string {
+ switch viewType {
+ case "senders":
+ return "sender"
+ case "sender_names":
+ return "sender_name"
+ case "recipients":
+ return "recipient"
+ case "recipient_names":
+ return "recipient_name"
+ case "domains":
+ return "domain"
+ case "labels":
+ return "label"
+ case "time":
+ return "time_period"
+ default:
+ return "sender"
+ }
+}
+
+templ Aggregates(data BrowseData) {
+ @Layout(data.ViewLabel, "browse") {
+ if data.Stats != nil {
+ @StatsBar(data.Stats)
+ }
+
+ if len(data.Breadcrumbs) > 0 {
+
+ }
+ if len(data.Rows) == 0 {
+
+
No data
+
No messages match the current filters.
+
+ } else {
+ @AggregateTable(data)
+ }
+ }
+}
+
+templ AggregateTable(data BrowseData) {
+
+ if len(data.Rows) > 0 {
+
+ Showing { formatCount(int64(len(data.Rows))) } of { formatCount(data.Rows[0].TotalUnique) } unique entries
+
+ }
+}
+
+templ viewSelector(data BrowseData) {
+
+ @viewTab("Senders", "senders", data)
+ @viewTab("Names", "sender_names", data)
+ @viewTab("Recipients", "recipients", data)
+ @viewTab("Rcpt Names", "recipient_names", data)
+ @viewTab("Domains", "domains", data)
+ @viewTab("Labels", "labels", data)
+ @viewTab("Time", "time", data)
+
+}
+
+templ viewTab(label string, viewType string, data BrowseData) {
+ if data.ViewType == viewType {
+ { label }
+ } else {
+ { label }
+ }
+}
+
+templ granularitySelector(data BrowseData) {
+
+ @granularityTab("Year", "year", data)
+ @granularityTab("Month", "month", data)
+ @granularityTab("Day", "day", data)
+
+}
+
+templ granularityTab(label string, granularity string, data BrowseData) {
+ if data.Granularity == granularity {
+ { label }
+ } else {
+ { label }
+ }
+}
+
+templ filterControls(data BrowseData) {
+
+}
+
+templ accountSelector(data BrowseData) {
+
+}
diff --git a/internal/web/templates/aggregates_templ.go b/internal/web/templates/aggregates_templ.go
new file mode 100644
index 00000000..3b166c66
--- /dev/null
+++ b/internal/web/templates/aggregates_templ.go
@@ -0,0 +1,1192 @@
+// Code generated by templ - DO NOT EDIT.
+
+// templ: version: v0.3.977
+package templates
+
+//lint:file-ignore SA4006 This context is only used if a nested component is present.
+
+import "github.com/a-h/templ"
+import templruntime "github.com/a-h/templ/runtime"
+
+import (
+ "fmt"
+ "github.com/wesm/msgvault/internal/query"
+ "net/url"
+ "sort"
+)
+
+type BrowseData struct {
+ Stats *query.TotalStats
+ Rows []query.AggregateRow
+ ViewType string
+ ViewLabel string
+ SortField string
+ SortDir string
+ Granularity string
+ AccountID string
+ Attachments bool
+ HideDeleted bool
+ Accounts []query.AccountInfo
+ // Drill-down context
+ DrillFilters map[string]string
+ Breadcrumbs []Breadcrumb
+}
+
+type Breadcrumb struct {
+ Label string
+ URL string
+}
+
+// buildURL constructs a URL with properly encoded query parameters.
+func buildURL(path string, params ...string) string {
+ u := url.URL{Path: path}
+ q := u.Query()
+ for i := 0; i+1 < len(params); i += 2 {
+ if params[i+1] != "" {
+ q.Set(params[i], params[i+1])
+ }
+ }
+ u.RawQuery = q.Encode()
+ return u.String()
+}
+
+// addFilterParams appends the common filter params to a url.Values.
+func (d BrowseData) addFilterParams(q url.Values) {
+ if d.AccountID != "" {
+ q.Set("account", d.AccountID)
+ }
+ if d.Attachments {
+ q.Set("attachments", "1")
+ }
+ if d.HideDeleted {
+ q.Set("hide_deleted", "1")
+ }
+}
+
+// addDrillParams appends drill filter params to a url.Values.
+func (d BrowseData) addDrillParams(q url.Values) {
+ for k, v := range d.DrillFilters {
+ q.Set(k, v)
+ }
+}
+
+// currentBase returns the current page URL with all state preserved.
+func (d BrowseData) currentBase() string {
+ var path string
+ if len(d.DrillFilters) > 0 {
+ path = "/browse/drill"
+ } else {
+ path = "/browse"
+ }
+ q := url.Values{}
+ q.Set("view", d.ViewType)
+ q.Set("sort", d.SortField)
+ q.Set("dir", d.SortDir)
+ if d.Granularity != "" {
+ q.Set("granularity", d.Granularity)
+ }
+ d.addDrillParams(q)
+ d.addFilterParams(q)
+ return path + "?" + q.Encode()
+}
+
+// sortURL returns the URL for clicking a column header to sort.
+func (d BrowseData) sortURL(field string) string {
+ dir := "desc"
+ if d.SortField == field && d.SortDir == "desc" {
+ dir = "asc"
+ }
+ var path string
+ if len(d.DrillFilters) > 0 {
+ path = "/browse/drill"
+ } else {
+ path = "/browse"
+ }
+ q := url.Values{}
+ q.Set("view", d.ViewType)
+ q.Set("sort", field)
+ q.Set("dir", dir)
+ if d.Granularity != "" {
+ q.Set("granularity", d.Granularity)
+ }
+ d.addDrillParams(q)
+ d.addFilterParams(q)
+ return path + "?" + q.Encode()
+}
+
+// sortIndicator returns the sort arrow for a column header.
+func (d BrowseData) sortIndicator(field string) string {
+ if d.SortField != field {
+ return ""
+ }
+ if d.SortDir == "asc" {
+ return " ↑"
+ }
+ return " ↓"
+}
+
+// drillURL returns the URL for drilling into an aggregate row.
+func (d BrowseData) drillURL(key string) string {
+ filterKey := viewTypeToFilterParam(d.ViewType)
+ q := url.Values{}
+ q.Set("view", d.ViewType)
+ if key == "" {
+ q.Set(filterKey, "")
+ } else {
+ q.Set(filterKey, key)
+ }
+ q.Set("sort", d.SortField)
+ q.Set("dir", d.SortDir)
+ d.addDrillParams(q)
+ if d.Granularity != "" {
+ q.Set("granularity", d.Granularity)
+ }
+ d.addFilterParams(q)
+ return "/browse/drill?" + q.Encode()
+}
+
+// messagesURL returns the URL for viewing messages matching a filter.
+func (d BrowseData) messagesURL(key string) string {
+ filterKey := viewTypeToFilterParam(d.ViewType)
+ q := url.Values{}
+ if key == "" {
+ q.Set(filterKey, "")
+ } else {
+ q.Set(filterKey, key)
+ }
+ d.addDrillParams(q)
+ // Include granularity for time views so the messages query uses the
+ // correct time precision (year/month/day) instead of defaulting to month.
+ if d.ViewType == "time" && d.Granularity != "" {
+ q.Set("granularity", d.Granularity)
+ }
+ d.addFilterParams(q)
+ return "/messages?" + q.Encode()
+}
+
+// ViewTabURL returns the URL for switching to a different view type.
+func (d BrowseData) ViewTabURL(viewType string) string {
+ q := url.Values{}
+ q.Set("view", viewType)
+ q.Set("sort", d.SortField)
+ q.Set("dir", d.SortDir)
+ d.addFilterParams(q)
+ return "/browse?" + q.Encode()
+}
+
+// granularityTabURL returns the URL for switching time granularity.
+func (d BrowseData) granularityTabURL(granularity string) string {
+ q := url.Values{}
+ q.Set("view", "time")
+ q.Set("granularity", granularity)
+ q.Set("sort", d.SortField)
+ q.Set("dir", d.SortDir)
+ d.addFilterParams(q)
+ return "/browse?" + q.Encode()
+}
+
+// sortedDrillKeys returns drill filter keys in deterministic order.
+func (d BrowseData) sortedDrillKeys() []string {
+ keys := make([]string, 0, len(d.DrillFilters))
+ for k := range d.DrillFilters {
+ keys = append(keys, k)
+ }
+ sort.Strings(keys)
+ return keys
+}
+
+// filterToggleURL returns the URL to toggle a boolean filter on or off.
+func (d BrowseData) filterToggleURL(key string, currentlyOn bool) string {
+ base := d.currentBase()
+ if currentlyOn {
+ return deleteParam(base, key)
+ }
+ return addParam(base, key, "1")
+}
+
+// accountFilterURL returns the URL for filtering by a specific account (or all).
+func (d BrowseData) accountFilterURL(accountID string) string {
+ base := d.currentBase()
+ if accountID == "" {
+ return deleteParam(base, "account")
+ }
+ // Replace any existing account param
+ base = deleteParam(base, "account")
+ return addParam(base, "account", accountID)
+}
+
+func viewTypeToFilterParam(viewType string) string {
+ switch viewType {
+ case "senders":
+ return "sender"
+ case "sender_names":
+ return "sender_name"
+ case "recipients":
+ return "recipient"
+ case "recipient_names":
+ return "recipient_name"
+ case "domains":
+ return "domain"
+ case "labels":
+ return "label"
+ case "time":
+ return "time_period"
+ default:
+ return "sender"
+ }
+}
+
+func Aggregates(data BrowseData) templ.Component {
+ return templruntime.GeneratedTemplate(func(templ_7745c5c3_Input templruntime.GeneratedComponentInput) (templ_7745c5c3_Err error) {
+ templ_7745c5c3_W, ctx := templ_7745c5c3_Input.Writer, templ_7745c5c3_Input.Context
+ if templ_7745c5c3_CtxErr := ctx.Err(); templ_7745c5c3_CtxErr != nil {
+ return templ_7745c5c3_CtxErr
+ }
+ templ_7745c5c3_Buffer, templ_7745c5c3_IsBuffer := templruntime.GetBuffer(templ_7745c5c3_W)
+ if !templ_7745c5c3_IsBuffer {
+ defer func() {
+ templ_7745c5c3_BufErr := templruntime.ReleaseBuffer(templ_7745c5c3_Buffer)
+ if templ_7745c5c3_Err == nil {
+ templ_7745c5c3_Err = templ_7745c5c3_BufErr
+ }
+ }()
+ }
+ ctx = templ.InitializeContext(ctx)
+ templ_7745c5c3_Var1 := templ.GetChildren(ctx)
+ if templ_7745c5c3_Var1 == nil {
+ templ_7745c5c3_Var1 = templ.NopComponent
+ }
+ ctx = templ.ClearChildren(ctx)
+ templ_7745c5c3_Var2 := templruntime.GeneratedTemplate(func(templ_7745c5c3_Input templruntime.GeneratedComponentInput) (templ_7745c5c3_Err error) {
+ templ_7745c5c3_W, ctx := templ_7745c5c3_Input.Writer, templ_7745c5c3_Input.Context
+ templ_7745c5c3_Buffer, templ_7745c5c3_IsBuffer := templruntime.GetBuffer(templ_7745c5c3_W)
+ if !templ_7745c5c3_IsBuffer {
+ defer func() {
+ templ_7745c5c3_BufErr := templruntime.ReleaseBuffer(templ_7745c5c3_Buffer)
+ if templ_7745c5c3_Err == nil {
+ templ_7745c5c3_Err = templ_7745c5c3_BufErr
+ }
+ }()
+ }
+ ctx = templ.InitializeContext(ctx)
+ if data.Stats != nil {
+ templ_7745c5c3_Err = StatsBar(data.Stats).Render(ctx, templ_7745c5c3_Buffer)
+ if templ_7745c5c3_Err != nil {
+ return templ_7745c5c3_Err
+ }
+ }
+ templ_7745c5c3_Err = templruntime.WriteString(templ_7745c5c3_Buffer, 1, " ")
+ if templ_7745c5c3_Err != nil {
+ return templ_7745c5c3_Err
+ }
+ if len(data.Breadcrumbs) > 0 {
+ templ_7745c5c3_Err = templruntime.WriteString(templ_7745c5c3_Buffer, 4, "")
+ if templ_7745c5c3_Err != nil {
+ return templ_7745c5c3_Err
+ }
+ }
+ templ_7745c5c3_Err = templruntime.WriteString(templ_7745c5c3_Buffer, 13, " ")
+ if templ_7745c5c3_Err != nil {
+ return templ_7745c5c3_Err
+ }
+ if len(data.Rows) == 0 {
+ templ_7745c5c3_Err = templruntime.WriteString(templ_7745c5c3_Buffer, 14, "No data
No messages match the current filters.
")
+ if templ_7745c5c3_Err != nil {
+ return templ_7745c5c3_Err
+ }
+ } else {
+ templ_7745c5c3_Err = AggregateTable(data).Render(ctx, templ_7745c5c3_Buffer)
+ if templ_7745c5c3_Err != nil {
+ return templ_7745c5c3_Err
+ }
+ }
+ return nil
+ })
+ templ_7745c5c3_Err = Layout(data.ViewLabel, "browse").Render(templ.WithChildren(ctx, templ_7745c5c3_Var2), templ_7745c5c3_Buffer)
+ if templ_7745c5c3_Err != nil {
+ return templ_7745c5c3_Err
+ }
+ return nil
+ })
+}
+
+func AggregateTable(data BrowseData) templ.Component {
+ return templruntime.GeneratedTemplate(func(templ_7745c5c3_Input templruntime.GeneratedComponentInput) (templ_7745c5c3_Err error) {
+ templ_7745c5c3_W, ctx := templ_7745c5c3_Input.Writer, templ_7745c5c3_Input.Context
+ if templ_7745c5c3_CtxErr := ctx.Err(); templ_7745c5c3_CtxErr != nil {
+ return templ_7745c5c3_CtxErr
+ }
+ templ_7745c5c3_Buffer, templ_7745c5c3_IsBuffer := templruntime.GetBuffer(templ_7745c5c3_W)
+ if !templ_7745c5c3_IsBuffer {
+ defer func() {
+ templ_7745c5c3_BufErr := templruntime.ReleaseBuffer(templ_7745c5c3_Buffer)
+ if templ_7745c5c3_Err == nil {
+ templ_7745c5c3_Err = templ_7745c5c3_BufErr
+ }
+ }()
+ }
+ ctx = templ.InitializeContext(ctx)
+ templ_7745c5c3_Var6 := templ.GetChildren(ctx)
+ if templ_7745c5c3_Var6 == nil {
+ templ_7745c5c3_Var6 = templ.NopComponent
+ }
+ ctx = templ.ClearChildren(ctx)
+ templ_7745c5c3_Err = templruntime.WriteString(templ_7745c5c3_Buffer, 15, "| ")
+ if templ_7745c5c3_Err != nil {
+ return templ_7745c5c3_Err
+ }
+ var templ_7745c5c3_Var8 string
+ templ_7745c5c3_Var8, templ_7745c5c3_Err = templ.JoinStringErrs(data.ViewLabel)
+ if templ_7745c5c3_Err != nil {
+ return templ.Error{Err: templ_7745c5c3_Err, FileName: `internal/web/templates/aggregates.templ`, Line: 276, Col: 22}
+ }
+ _, templ_7745c5c3_Err = templ_7745c5c3_Buffer.WriteString(templ.EscapeString(templ_7745c5c3_Var8))
+ if templ_7745c5c3_Err != nil {
+ return templ_7745c5c3_Err
+ }
+ var templ_7745c5c3_Var9 string
+ templ_7745c5c3_Var9, templ_7745c5c3_Err = templ.JoinStringErrs(data.sortIndicator("name"))
+ if templ_7745c5c3_Err != nil {
+ return templ.Error{Err: templ_7745c5c3_Err, FileName: `internal/web/templates/aggregates.templ`, Line: 276, Col: 52}
+ }
+ _, templ_7745c5c3_Err = templ_7745c5c3_Buffer.WriteString(templ.EscapeString(templ_7745c5c3_Var9))
+ if templ_7745c5c3_Err != nil {
+ return templ_7745c5c3_Err
+ }
+ templ_7745c5c3_Err = templruntime.WriteString(templ_7745c5c3_Buffer, 17, " | Count")
+ if templ_7745c5c3_Err != nil {
+ return templ_7745c5c3_Err
+ }
+ var templ_7745c5c3_Var11 string
+ templ_7745c5c3_Var11, templ_7745c5c3_Err = templ.JoinStringErrs(data.sortIndicator("count"))
+ if templ_7745c5c3_Err != nil {
+ return templ.Error{Err: templ_7745c5c3_Err, FileName: `internal/web/templates/aggregates.templ`, Line: 281, Col: 40}
+ }
+ _, templ_7745c5c3_Err = templ_7745c5c3_Buffer.WriteString(templ.EscapeString(templ_7745c5c3_Var11))
+ if templ_7745c5c3_Err != nil {
+ return templ_7745c5c3_Err
+ }
+ templ_7745c5c3_Err = templruntime.WriteString(templ_7745c5c3_Buffer, 19, " | Size")
+ if templ_7745c5c3_Err != nil {
+ return templ_7745c5c3_Err
+ }
+ var templ_7745c5c3_Var13 string
+ templ_7745c5c3_Var13, templ_7745c5c3_Err = templ.JoinStringErrs(data.sortIndicator("size"))
+ if templ_7745c5c3_Err != nil {
+ return templ.Error{Err: templ_7745c5c3_Err, FileName: `internal/web/templates/aggregates.templ`, Line: 286, Col: 38}
+ }
+ _, templ_7745c5c3_Err = templ_7745c5c3_Buffer.WriteString(templ.EscapeString(templ_7745c5c3_Var13))
+ if templ_7745c5c3_Err != nil {
+ return templ_7745c5c3_Err
+ }
+ templ_7745c5c3_Err = templruntime.WriteString(templ_7745c5c3_Buffer, 21, " | Attachments")
+ if templ_7745c5c3_Err != nil {
+ return templ_7745c5c3_Err
+ }
+ var templ_7745c5c3_Var15 string
+ templ_7745c5c3_Var15, templ_7745c5c3_Err = templ.JoinStringErrs(data.sortIndicator("attachments"))
+ if templ_7745c5c3_Err != nil {
+ return templ.Error{Err: templ_7745c5c3_Err, FileName: `internal/web/templates/aggregates.templ`, Line: 291, Col: 52}
+ }
+ _, templ_7745c5c3_Err = templ_7745c5c3_Buffer.WriteString(templ.EscapeString(templ_7745c5c3_Var15))
+ if templ_7745c5c3_Err != nil {
+ return templ_7745c5c3_Err
+ }
+ templ_7745c5c3_Err = templruntime.WriteString(templ_7745c5c3_Buffer, 23, " |
")
+ if templ_7745c5c3_Err != nil {
+ return templ_7745c5c3_Err
+ }
+ for _, row := range data.Rows {
+ templ_7745c5c3_Err = templruntime.WriteString(templ_7745c5c3_Buffer, 24, "| ")
+ if templ_7745c5c3_Err != nil {
+ return templ_7745c5c3_Err
+ }
+ if row.Key == "" {
+ templ_7745c5c3_Err = templruntime.WriteString(templ_7745c5c3_Buffer, 26, "(empty)")
+ if templ_7745c5c3_Err != nil {
+ return templ_7745c5c3_Err
+ }
+ } else {
+ var templ_7745c5c3_Var17 string
+ templ_7745c5c3_Var17, templ_7745c5c3_Err = templ.JoinStringErrs(row.Key)
+ if templ_7745c5c3_Err != nil {
+ return templ.Error{Err: templ_7745c5c3_Err, FileName: `internal/web/templates/aggregates.templ`, Line: 304, Col: 17}
+ }
+ _, templ_7745c5c3_Err = templ_7745c5c3_Buffer.WriteString(templ.EscapeString(templ_7745c5c3_Var17))
+ if templ_7745c5c3_Err != nil {
+ return templ_7745c5c3_Err
+ }
+ }
+ templ_7745c5c3_Err = templruntime.WriteString(templ_7745c5c3_Buffer, 27, " | ")
+ if templ_7745c5c3_Err != nil {
+ return templ_7745c5c3_Err
+ }
+ var templ_7745c5c3_Var19 string
+ templ_7745c5c3_Var19, templ_7745c5c3_Err = templ.JoinStringErrs(formatCount(row.Count))
+ if templ_7745c5c3_Err != nil {
+ return templ.Error{Err: templ_7745c5c3_Err, FileName: `internal/web/templates/aggregates.templ`, Line: 310, Col: 31}
+ }
+ _, templ_7745c5c3_Err = templ_7745c5c3_Buffer.WriteString(templ.EscapeString(templ_7745c5c3_Var19))
+ if templ_7745c5c3_Err != nil {
+ return templ_7745c5c3_Err
+ }
+ templ_7745c5c3_Err = templruntime.WriteString(templ_7745c5c3_Buffer, 29, " | ")
+ if templ_7745c5c3_Err != nil {
+ return templ_7745c5c3_Err
+ }
+ var templ_7745c5c3_Var20 string
+ templ_7745c5c3_Var20, templ_7745c5c3_Err = templ.JoinStringErrs(formatBytes(row.TotalSize))
+ if templ_7745c5c3_Err != nil {
+ return templ.Error{Err: templ_7745c5c3_Err, FileName: `internal/web/templates/aggregates.templ`, Line: 313, Col: 49}
+ }
+ _, templ_7745c5c3_Err = templ_7745c5c3_Buffer.WriteString(templ.EscapeString(templ_7745c5c3_Var20))
+ if templ_7745c5c3_Err != nil {
+ return templ_7745c5c3_Err
+ }
+ templ_7745c5c3_Err = templruntime.WriteString(templ_7745c5c3_Buffer, 30, " | ")
+ if templ_7745c5c3_Err != nil {
+ return templ_7745c5c3_Err
+ }
+ if row.AttachmentCount > 0 {
+ var templ_7745c5c3_Var21 string
+ templ_7745c5c3_Var21, templ_7745c5c3_Err = templ.JoinStringErrs(formatCount(row.AttachmentCount))
+ if templ_7745c5c3_Err != nil {
+ return templ.Error{Err: templ_7745c5c3_Err, FileName: `internal/web/templates/aggregates.templ`, Line: 316, Col: 41}
+ }
+ _, templ_7745c5c3_Err = templ_7745c5c3_Buffer.WriteString(templ.EscapeString(templ_7745c5c3_Var21))
+ if templ_7745c5c3_Err != nil {
+ return templ_7745c5c3_Err
+ }
+ templ_7745c5c3_Err = templruntime.WriteString(templ_7745c5c3_Buffer, 31, " (")
+ if templ_7745c5c3_Err != nil {
+ return templ_7745c5c3_Err
+ }
+ var templ_7745c5c3_Var22 string
+ templ_7745c5c3_Var22, templ_7745c5c3_Err = templ.JoinStringErrs(formatBytes(row.AttachmentSize))
+ if templ_7745c5c3_Err != nil {
+ return templ.Error{Err: templ_7745c5c3_Err, FileName: `internal/web/templates/aggregates.templ`, Line: 316, Col: 78}
+ }
+ _, templ_7745c5c3_Err = templ_7745c5c3_Buffer.WriteString(templ.EscapeString(templ_7745c5c3_Var22))
+ if templ_7745c5c3_Err != nil {
+ return templ_7745c5c3_Err
+ }
+ templ_7745c5c3_Err = templruntime.WriteString(templ_7745c5c3_Buffer, 32, ")")
+ if templ_7745c5c3_Err != nil {
+ return templ_7745c5c3_Err
+ }
+ } else {
+ var templ_7745c5c3_Var23 string
+ templ_7745c5c3_Var23, templ_7745c5c3_Err = templ.JoinStringErrs("-")
+ if templ_7745c5c3_Err != nil {
+ return templ.Error{Err: templ_7745c5c3_Err, FileName: `internal/web/templates/aggregates.templ`, Line: 318, Col: 12}
+ }
+ _, templ_7745c5c3_Err = templ_7745c5c3_Buffer.WriteString(templ.EscapeString(templ_7745c5c3_Var23))
+ if templ_7745c5c3_Err != nil {
+ return templ_7745c5c3_Err
+ }
+ }
+ templ_7745c5c3_Err = templruntime.WriteString(templ_7745c5c3_Buffer, 33, " |
")
+ if templ_7745c5c3_Err != nil {
+ return templ_7745c5c3_Err
+ }
+ }
+ templ_7745c5c3_Err = templruntime.WriteString(templ_7745c5c3_Buffer, 34, "
")
+ if templ_7745c5c3_Err != nil {
+ return templ_7745c5c3_Err
+ }
+ if len(data.Rows) > 0 {
+ templ_7745c5c3_Err = templruntime.WriteString(templ_7745c5c3_Buffer, 35, "Showing ")
+ if templ_7745c5c3_Err != nil {
+ return templ_7745c5c3_Err
+ }
+ var templ_7745c5c3_Var24 string
+ templ_7745c5c3_Var24, templ_7745c5c3_Err = templ.JoinStringErrs(formatCount(int64(len(data.Rows))))
+ if templ_7745c5c3_Err != nil {
+ return templ.Error{Err: templ_7745c5c3_Err, FileName: `internal/web/templates/aggregates.templ`, Line: 327, Col: 47}
+ }
+ _, templ_7745c5c3_Err = templ_7745c5c3_Buffer.WriteString(templ.EscapeString(templ_7745c5c3_Var24))
+ if templ_7745c5c3_Err != nil {
+ return templ_7745c5c3_Err
+ }
+ templ_7745c5c3_Err = templruntime.WriteString(templ_7745c5c3_Buffer, 36, " of ")
+ if templ_7745c5c3_Err != nil {
+ return templ_7745c5c3_Err
+ }
+ var templ_7745c5c3_Var25 string
+ templ_7745c5c3_Var25, templ_7745c5c3_Err = templ.JoinStringErrs(formatCount(data.Rows[0].TotalUnique))
+ if templ_7745c5c3_Err != nil {
+ return templ.Error{Err: templ_7745c5c3_Err, FileName: `internal/web/templates/aggregates.templ`, Line: 327, Col: 92}
+ }
+ _, templ_7745c5c3_Err = templ_7745c5c3_Buffer.WriteString(templ.EscapeString(templ_7745c5c3_Var25))
+ if templ_7745c5c3_Err != nil {
+ return templ_7745c5c3_Err
+ }
+ templ_7745c5c3_Err = templruntime.WriteString(templ_7745c5c3_Buffer, 37, " unique entries
")
+ if templ_7745c5c3_Err != nil {
+ return templ_7745c5c3_Err
+ }
+ }
+ return nil
+ })
+}
+
+func viewSelector(data BrowseData) templ.Component {
+ return templruntime.GeneratedTemplate(func(templ_7745c5c3_Input templruntime.GeneratedComponentInput) (templ_7745c5c3_Err error) {
+ templ_7745c5c3_W, ctx := templ_7745c5c3_Input.Writer, templ_7745c5c3_Input.Context
+ if templ_7745c5c3_CtxErr := ctx.Err(); templ_7745c5c3_CtxErr != nil {
+ return templ_7745c5c3_CtxErr
+ }
+ templ_7745c5c3_Buffer, templ_7745c5c3_IsBuffer := templruntime.GetBuffer(templ_7745c5c3_W)
+ if !templ_7745c5c3_IsBuffer {
+ defer func() {
+ templ_7745c5c3_BufErr := templruntime.ReleaseBuffer(templ_7745c5c3_Buffer)
+ if templ_7745c5c3_Err == nil {
+ templ_7745c5c3_Err = templ_7745c5c3_BufErr
+ }
+ }()
+ }
+ ctx = templ.InitializeContext(ctx)
+ templ_7745c5c3_Var26 := templ.GetChildren(ctx)
+ if templ_7745c5c3_Var26 == nil {
+ templ_7745c5c3_Var26 = templ.NopComponent
+ }
+ ctx = templ.ClearChildren(ctx)
+ templ_7745c5c3_Err = templruntime.WriteString(templ_7745c5c3_Buffer, 38, "")
+ if templ_7745c5c3_Err != nil {
+ return templ_7745c5c3_Err
+ }
+ templ_7745c5c3_Err = viewTab("Senders", "senders", data).Render(ctx, templ_7745c5c3_Buffer)
+ if templ_7745c5c3_Err != nil {
+ return templ_7745c5c3_Err
+ }
+ templ_7745c5c3_Err = viewTab("Names", "sender_names", data).Render(ctx, templ_7745c5c3_Buffer)
+ if templ_7745c5c3_Err != nil {
+ return templ_7745c5c3_Err
+ }
+ templ_7745c5c3_Err = viewTab("Recipients", "recipients", data).Render(ctx, templ_7745c5c3_Buffer)
+ if templ_7745c5c3_Err != nil {
+ return templ_7745c5c3_Err
+ }
+ templ_7745c5c3_Err = viewTab("Rcpt Names", "recipient_names", data).Render(ctx, templ_7745c5c3_Buffer)
+ if templ_7745c5c3_Err != nil {
+ return templ_7745c5c3_Err
+ }
+ templ_7745c5c3_Err = viewTab("Domains", "domains", data).Render(ctx, templ_7745c5c3_Buffer)
+ if templ_7745c5c3_Err != nil {
+ return templ_7745c5c3_Err
+ }
+ templ_7745c5c3_Err = viewTab("Labels", "labels", data).Render(ctx, templ_7745c5c3_Buffer)
+ if templ_7745c5c3_Err != nil {
+ return templ_7745c5c3_Err
+ }
+ templ_7745c5c3_Err = viewTab("Time", "time", data).Render(ctx, templ_7745c5c3_Buffer)
+ if templ_7745c5c3_Err != nil {
+ return templ_7745c5c3_Err
+ }
+ templ_7745c5c3_Err = templruntime.WriteString(templ_7745c5c3_Buffer, 39, "
")
+ if templ_7745c5c3_Err != nil {
+ return templ_7745c5c3_Err
+ }
+ return nil
+ })
+}
+
+func viewTab(label string, viewType string, data BrowseData) templ.Component {
+ return templruntime.GeneratedTemplate(func(templ_7745c5c3_Input templruntime.GeneratedComponentInput) (templ_7745c5c3_Err error) {
+ templ_7745c5c3_W, ctx := templ_7745c5c3_Input.Writer, templ_7745c5c3_Input.Context
+ if templ_7745c5c3_CtxErr := ctx.Err(); templ_7745c5c3_CtxErr != nil {
+ return templ_7745c5c3_CtxErr
+ }
+ templ_7745c5c3_Buffer, templ_7745c5c3_IsBuffer := templruntime.GetBuffer(templ_7745c5c3_W)
+ if !templ_7745c5c3_IsBuffer {
+ defer func() {
+ templ_7745c5c3_BufErr := templruntime.ReleaseBuffer(templ_7745c5c3_Buffer)
+ if templ_7745c5c3_Err == nil {
+ templ_7745c5c3_Err = templ_7745c5c3_BufErr
+ }
+ }()
+ }
+ ctx = templ.InitializeContext(ctx)
+ templ_7745c5c3_Var27 := templ.GetChildren(ctx)
+ if templ_7745c5c3_Var27 == nil {
+ templ_7745c5c3_Var27 = templ.NopComponent
+ }
+ ctx = templ.ClearChildren(ctx)
+ if data.ViewType == viewType {
+ templ_7745c5c3_Err = templruntime.WriteString(templ_7745c5c3_Buffer, 40, "")
+ if templ_7745c5c3_Err != nil {
+ return templ_7745c5c3_Err
+ }
+ var templ_7745c5c3_Var29 string
+ templ_7745c5c3_Var29, templ_7745c5c3_Err = templ.JoinStringErrs(label)
+ if templ_7745c5c3_Err != nil {
+ return templ.Error{Err: templ_7745c5c3_Err, FileName: `internal/web/templates/aggregates.templ`, Line: 346, Col: 82}
+ }
+ _, templ_7745c5c3_Err = templ_7745c5c3_Buffer.WriteString(templ.EscapeString(templ_7745c5c3_Var29))
+ if templ_7745c5c3_Err != nil {
+ return templ_7745c5c3_Err
+ }
+ templ_7745c5c3_Err = templruntime.WriteString(templ_7745c5c3_Buffer, 42, "")
+ if templ_7745c5c3_Err != nil {
+ return templ_7745c5c3_Err
+ }
+ } else {
+ templ_7745c5c3_Err = templruntime.WriteString(templ_7745c5c3_Buffer, 43, "")
+ if templ_7745c5c3_Err != nil {
+ return templ_7745c5c3_Err
+ }
+ var templ_7745c5c3_Var31 string
+ templ_7745c5c3_Var31, templ_7745c5c3_Err = templ.JoinStringErrs(label)
+ if templ_7745c5c3_Err != nil {
+ return templ.Error{Err: templ_7745c5c3_Err, FileName: `internal/web/templates/aggregates.templ`, Line: 348, Col: 75}
+ }
+ _, templ_7745c5c3_Err = templ_7745c5c3_Buffer.WriteString(templ.EscapeString(templ_7745c5c3_Var31))
+ if templ_7745c5c3_Err != nil {
+ return templ_7745c5c3_Err
+ }
+ templ_7745c5c3_Err = templruntime.WriteString(templ_7745c5c3_Buffer, 45, "")
+ if templ_7745c5c3_Err != nil {
+ return templ_7745c5c3_Err
+ }
+ }
+ return nil
+ })
+}
+
+func granularitySelector(data BrowseData) templ.Component {
+ return templruntime.GeneratedTemplate(func(templ_7745c5c3_Input templruntime.GeneratedComponentInput) (templ_7745c5c3_Err error) {
+ templ_7745c5c3_W, ctx := templ_7745c5c3_Input.Writer, templ_7745c5c3_Input.Context
+ if templ_7745c5c3_CtxErr := ctx.Err(); templ_7745c5c3_CtxErr != nil {
+ return templ_7745c5c3_CtxErr
+ }
+ templ_7745c5c3_Buffer, templ_7745c5c3_IsBuffer := templruntime.GetBuffer(templ_7745c5c3_W)
+ if !templ_7745c5c3_IsBuffer {
+ defer func() {
+ templ_7745c5c3_BufErr := templruntime.ReleaseBuffer(templ_7745c5c3_Buffer)
+ if templ_7745c5c3_Err == nil {
+ templ_7745c5c3_Err = templ_7745c5c3_BufErr
+ }
+ }()
+ }
+ ctx = templ.InitializeContext(ctx)
+ templ_7745c5c3_Var32 := templ.GetChildren(ctx)
+ if templ_7745c5c3_Var32 == nil {
+ templ_7745c5c3_Var32 = templ.NopComponent
+ }
+ ctx = templ.ClearChildren(ctx)
+ templ_7745c5c3_Err = templruntime.WriteString(templ_7745c5c3_Buffer, 46, "")
+ if templ_7745c5c3_Err != nil {
+ return templ_7745c5c3_Err
+ }
+ templ_7745c5c3_Err = granularityTab("Year", "year", data).Render(ctx, templ_7745c5c3_Buffer)
+ if templ_7745c5c3_Err != nil {
+ return templ_7745c5c3_Err
+ }
+ templ_7745c5c3_Err = granularityTab("Month", "month", data).Render(ctx, templ_7745c5c3_Buffer)
+ if templ_7745c5c3_Err != nil {
+ return templ_7745c5c3_Err
+ }
+ templ_7745c5c3_Err = granularityTab("Day", "day", data).Render(ctx, templ_7745c5c3_Buffer)
+ if templ_7745c5c3_Err != nil {
+ return templ_7745c5c3_Err
+ }
+ templ_7745c5c3_Err = templruntime.WriteString(templ_7745c5c3_Buffer, 47, "
")
+ if templ_7745c5c3_Err != nil {
+ return templ_7745c5c3_Err
+ }
+ return nil
+ })
+}
+
+func granularityTab(label string, granularity string, data BrowseData) templ.Component {
+ return templruntime.GeneratedTemplate(func(templ_7745c5c3_Input templruntime.GeneratedComponentInput) (templ_7745c5c3_Err error) {
+ templ_7745c5c3_W, ctx := templ_7745c5c3_Input.Writer, templ_7745c5c3_Input.Context
+ if templ_7745c5c3_CtxErr := ctx.Err(); templ_7745c5c3_CtxErr != nil {
+ return templ_7745c5c3_CtxErr
+ }
+ templ_7745c5c3_Buffer, templ_7745c5c3_IsBuffer := templruntime.GetBuffer(templ_7745c5c3_W)
+ if !templ_7745c5c3_IsBuffer {
+ defer func() {
+ templ_7745c5c3_BufErr := templruntime.ReleaseBuffer(templ_7745c5c3_Buffer)
+ if templ_7745c5c3_Err == nil {
+ templ_7745c5c3_Err = templ_7745c5c3_BufErr
+ }
+ }()
+ }
+ ctx = templ.InitializeContext(ctx)
+ templ_7745c5c3_Var33 := templ.GetChildren(ctx)
+ if templ_7745c5c3_Var33 == nil {
+ templ_7745c5c3_Var33 = templ.NopComponent
+ }
+ ctx = templ.ClearChildren(ctx)
+ if data.Granularity == granularity {
+ templ_7745c5c3_Err = templruntime.WriteString(templ_7745c5c3_Buffer, 48, "")
+ if templ_7745c5c3_Err != nil {
+ return templ_7745c5c3_Err
+ }
+ var templ_7745c5c3_Var35 string
+ templ_7745c5c3_Var35, templ_7745c5c3_Err = templ.JoinStringErrs(label)
+ if templ_7745c5c3_Err != nil {
+ return templ.Error{Err: templ_7745c5c3_Err, FileName: `internal/web/templates/aggregates.templ`, Line: 362, Col: 101}
+ }
+ _, templ_7745c5c3_Err = templ_7745c5c3_Buffer.WriteString(templ.EscapeString(templ_7745c5c3_Var35))
+ if templ_7745c5c3_Err != nil {
+ return templ_7745c5c3_Err
+ }
+ templ_7745c5c3_Err = templruntime.WriteString(templ_7745c5c3_Buffer, 50, "")
+ if templ_7745c5c3_Err != nil {
+ return templ_7745c5c3_Err
+ }
+ } else {
+ templ_7745c5c3_Err = templruntime.WriteString(templ_7745c5c3_Buffer, 51, "")
+ if templ_7745c5c3_Err != nil {
+ return templ_7745c5c3_Err
+ }
+ var templ_7745c5c3_Var37 string
+ templ_7745c5c3_Var37, templ_7745c5c3_Err = templ.JoinStringErrs(label)
+ if templ_7745c5c3_Err != nil {
+ return templ.Error{Err: templ_7745c5c3_Err, FileName: `internal/web/templates/aggregates.templ`, Line: 364, Col: 88}
+ }
+ _, templ_7745c5c3_Err = templ_7745c5c3_Buffer.WriteString(templ.EscapeString(templ_7745c5c3_Var37))
+ if templ_7745c5c3_Err != nil {
+ return templ_7745c5c3_Err
+ }
+ templ_7745c5c3_Err = templruntime.WriteString(templ_7745c5c3_Buffer, 53, "")
+ if templ_7745c5c3_Err != nil {
+ return templ_7745c5c3_Err
+ }
+ }
+ return nil
+ })
+}
+
+func filterControls(data BrowseData) templ.Component {
+ return templruntime.GeneratedTemplate(func(templ_7745c5c3_Input templruntime.GeneratedComponentInput) (templ_7745c5c3_Err error) {
+ templ_7745c5c3_W, ctx := templ_7745c5c3_Input.Writer, templ_7745c5c3_Input.Context
+ if templ_7745c5c3_CtxErr := ctx.Err(); templ_7745c5c3_CtxErr != nil {
+ return templ_7745c5c3_CtxErr
+ }
+ templ_7745c5c3_Buffer, templ_7745c5c3_IsBuffer := templruntime.GetBuffer(templ_7745c5c3_W)
+ if !templ_7745c5c3_IsBuffer {
+ defer func() {
+ templ_7745c5c3_BufErr := templruntime.ReleaseBuffer(templ_7745c5c3_Buffer)
+ if templ_7745c5c3_Err == nil {
+ templ_7745c5c3_Err = templ_7745c5c3_BufErr
+ }
+ }()
+ }
+ ctx = templ.InitializeContext(ctx)
+ templ_7745c5c3_Var38 := templ.GetChildren(ctx)
+ if templ_7745c5c3_Var38 == nil {
+ templ_7745c5c3_Var38 = templ.NopComponent
+ }
+ ctx = templ.ClearChildren(ctx)
+ templ_7745c5c3_Err = templruntime.WriteString(templ_7745c5c3_Buffer, 54, "")
+ if templ_7745c5c3_Err != nil {
+ return templ_7745c5c3_Err
+ }
+ return nil
+ })
+}
+
+func accountSelector(data BrowseData) templ.Component {
+ return templruntime.GeneratedTemplate(func(templ_7745c5c3_Input templruntime.GeneratedComponentInput) (templ_7745c5c3_Err error) {
+ templ_7745c5c3_W, ctx := templ_7745c5c3_Input.Writer, templ_7745c5c3_Input.Context
+ if templ_7745c5c3_CtxErr := ctx.Err(); templ_7745c5c3_CtxErr != nil {
+ return templ_7745c5c3_CtxErr
+ }
+ templ_7745c5c3_Buffer, templ_7745c5c3_IsBuffer := templruntime.GetBuffer(templ_7745c5c3_W)
+ if !templ_7745c5c3_IsBuffer {
+ defer func() {
+ templ_7745c5c3_BufErr := templruntime.ReleaseBuffer(templ_7745c5c3_Buffer)
+ if templ_7745c5c3_Err == nil {
+ templ_7745c5c3_Err = templ_7745c5c3_BufErr
+ }
+ }()
+ }
+ ctx = templ.InitializeContext(ctx)
+ templ_7745c5c3_Var43 := templ.GetChildren(ctx)
+ if templ_7745c5c3_Var43 == nil {
+ templ_7745c5c3_Var43 = templ.NopComponent
+ }
+ ctx = templ.ClearChildren(ctx)
+ templ_7745c5c3_Err = templruntime.WriteString(templ_7745c5c3_Buffer, 64, "")
+ if templ_7745c5c3_Err != nil {
+ return templ_7745c5c3_Err
+ }
+ return nil
+ })
+}
+
+var _ = templruntime.GeneratedTemplate
diff --git a/internal/web/templates/dashboard.templ b/internal/web/templates/dashboard.templ
new file mode 100644
index 00000000..d99b6012
--- /dev/null
+++ b/internal/web/templates/dashboard.templ
@@ -0,0 +1,80 @@
+package templates
+
+import "github.com/wesm/msgvault/internal/query"
+
+type DashboardData struct {
+ Stats *query.TotalStats
+ Accounts []query.AccountInfo
+}
+
+templ Dashboard(data DashboardData) {
+ @Layout("Dashboard", "dashboard") {
+ if data.Stats != nil {
+ @StatsBar(data.Stats)
+
+ @statCard(formatCount(data.Stats.MessageCount), "Messages")
+ @statCard(formatBytes(data.Stats.TotalSize), "Total Size")
+ @statCard(formatCount(data.Stats.AttachmentCount), "Attachments")
+ @statCard(formatBytes(data.Stats.AttachmentSize), "Attachment Size")
+ @statCard(formatCount(data.Stats.LabelCount), "Labels")
+ @statCard(formatCount(data.Stats.AccountCount), "Accounts")
+
+ }
+
+
Accounts
+ if len(data.Accounts) == 0 {
+
+
No accounts configured
+
Run msgvault add-account you@gmail.com to get started.
+
+ } else {
+
+ for _, acct := range data.Accounts {
+ -
+ { acct.Identifier }
+ { acct.SourceType }
+
+ }
+
+ }
+
+
+ }
+}
+
+templ statCard(value string, label string) {
+
+
{ value }
+
{ label }
+
+}
+
+templ StatsBar(stats *query.TotalStats) {
+
+
+ { formatCount(stats.MessageCount) } msgs
+
+
+ { formatBytes(stats.TotalSize) } total
+
+
+ { formatCount(stats.AttachmentCount) } attachments
+
+
+}
diff --git a/internal/web/templates/dashboard_templ.go b/internal/web/templates/dashboard_templ.go
new file mode 100644
index 00000000..9cb28bd0
--- /dev/null
+++ b/internal/web/templates/dashboard_templ.go
@@ -0,0 +1,277 @@
+// Code generated by templ - DO NOT EDIT.
+
+// templ: version: v0.3.977
+package templates
+
+//lint:file-ignore SA4006 This context is only used if a nested component is present.
+
+import "github.com/a-h/templ"
+import templruntime "github.com/a-h/templ/runtime"
+
+import "github.com/wesm/msgvault/internal/query"
+
+type DashboardData struct {
+ Stats *query.TotalStats
+ Accounts []query.AccountInfo
+}
+
+func Dashboard(data DashboardData) templ.Component {
+ return templruntime.GeneratedTemplate(func(templ_7745c5c3_Input templruntime.GeneratedComponentInput) (templ_7745c5c3_Err error) {
+ templ_7745c5c3_W, ctx := templ_7745c5c3_Input.Writer, templ_7745c5c3_Input.Context
+ if templ_7745c5c3_CtxErr := ctx.Err(); templ_7745c5c3_CtxErr != nil {
+ return templ_7745c5c3_CtxErr
+ }
+ templ_7745c5c3_Buffer, templ_7745c5c3_IsBuffer := templruntime.GetBuffer(templ_7745c5c3_W)
+ if !templ_7745c5c3_IsBuffer {
+ defer func() {
+ templ_7745c5c3_BufErr := templruntime.ReleaseBuffer(templ_7745c5c3_Buffer)
+ if templ_7745c5c3_Err == nil {
+ templ_7745c5c3_Err = templ_7745c5c3_BufErr
+ }
+ }()
+ }
+ ctx = templ.InitializeContext(ctx)
+ templ_7745c5c3_Var1 := templ.GetChildren(ctx)
+ if templ_7745c5c3_Var1 == nil {
+ templ_7745c5c3_Var1 = templ.NopComponent
+ }
+ ctx = templ.ClearChildren(ctx)
+ templ_7745c5c3_Var2 := templruntime.GeneratedTemplate(func(templ_7745c5c3_Input templruntime.GeneratedComponentInput) (templ_7745c5c3_Err error) {
+ templ_7745c5c3_W, ctx := templ_7745c5c3_Input.Writer, templ_7745c5c3_Input.Context
+ templ_7745c5c3_Buffer, templ_7745c5c3_IsBuffer := templruntime.GetBuffer(templ_7745c5c3_W)
+ if !templ_7745c5c3_IsBuffer {
+ defer func() {
+ templ_7745c5c3_BufErr := templruntime.ReleaseBuffer(templ_7745c5c3_Buffer)
+ if templ_7745c5c3_Err == nil {
+ templ_7745c5c3_Err = templ_7745c5c3_BufErr
+ }
+ }()
+ }
+ ctx = templ.InitializeContext(ctx)
+ if data.Stats != nil {
+ templ_7745c5c3_Err = StatsBar(data.Stats).Render(ctx, templ_7745c5c3_Buffer)
+ if templ_7745c5c3_Err != nil {
+ return templ_7745c5c3_Err
+ }
+ templ_7745c5c3_Err = templruntime.WriteString(templ_7745c5c3_Buffer, 1, " ")
+ if templ_7745c5c3_Err != nil {
+ return templ_7745c5c3_Err
+ }
+ templ_7745c5c3_Err = statCard(formatCount(data.Stats.MessageCount), "Messages").Render(ctx, templ_7745c5c3_Buffer)
+ if templ_7745c5c3_Err != nil {
+ return templ_7745c5c3_Err
+ }
+ templ_7745c5c3_Err = statCard(formatBytes(data.Stats.TotalSize), "Total Size").Render(ctx, templ_7745c5c3_Buffer)
+ if templ_7745c5c3_Err != nil {
+ return templ_7745c5c3_Err
+ }
+ templ_7745c5c3_Err = statCard(formatCount(data.Stats.AttachmentCount), "Attachments").Render(ctx, templ_7745c5c3_Buffer)
+ if templ_7745c5c3_Err != nil {
+ return templ_7745c5c3_Err
+ }
+ templ_7745c5c3_Err = statCard(formatBytes(data.Stats.AttachmentSize), "Attachment Size").Render(ctx, templ_7745c5c3_Buffer)
+ if templ_7745c5c3_Err != nil {
+ return templ_7745c5c3_Err
+ }
+ templ_7745c5c3_Err = statCard(formatCount(data.Stats.LabelCount), "Labels").Render(ctx, templ_7745c5c3_Buffer)
+ if templ_7745c5c3_Err != nil {
+ return templ_7745c5c3_Err
+ }
+ templ_7745c5c3_Err = statCard(formatCount(data.Stats.AccountCount), "Accounts").Render(ctx, templ_7745c5c3_Buffer)
+ if templ_7745c5c3_Err != nil {
+ return templ_7745c5c3_Err
+ }
+ templ_7745c5c3_Err = templruntime.WriteString(templ_7745c5c3_Buffer, 2, "
")
+ if templ_7745c5c3_Err != nil {
+ return templ_7745c5c3_Err
+ }
+ }
+ templ_7745c5c3_Err = templruntime.WriteString(templ_7745c5c3_Buffer, 3, " Accounts
")
+ if templ_7745c5c3_Err != nil {
+ return templ_7745c5c3_Err
+ }
+ if len(data.Accounts) == 0 {
+ templ_7745c5c3_Err = templruntime.WriteString(templ_7745c5c3_Buffer, 4, "
No accounts configured
Run msgvault add-account you@gmail.com to get started.
")
+ if templ_7745c5c3_Err != nil {
+ return templ_7745c5c3_Err
+ }
+ } else {
+ templ_7745c5c3_Err = templruntime.WriteString(templ_7745c5c3_Buffer, 5, "
")
+ if templ_7745c5c3_Err != nil {
+ return templ_7745c5c3_Err
+ }
+ for _, acct := range data.Accounts {
+ templ_7745c5c3_Err = templruntime.WriteString(templ_7745c5c3_Buffer, 6, "- ")
+ if templ_7745c5c3_Err != nil {
+ return templ_7745c5c3_Err
+ }
+ var templ_7745c5c3_Var3 string
+ templ_7745c5c3_Var3, templ_7745c5c3_Err = templ.JoinStringErrs(acct.Identifier)
+ if templ_7745c5c3_Err != nil {
+ return templ.Error{Err: templ_7745c5c3_Err, FileName: `internal/web/templates/dashboard.templ`, Line: 34, Col: 52}
+ }
+ _, templ_7745c5c3_Err = templ_7745c5c3_Buffer.WriteString(templ.EscapeString(templ_7745c5c3_Var3))
+ if templ_7745c5c3_Err != nil {
+ return templ_7745c5c3_Err
+ }
+ templ_7745c5c3_Err = templruntime.WriteString(templ_7745c5c3_Buffer, 7, " ")
+ if templ_7745c5c3_Err != nil {
+ return templ_7745c5c3_Err
+ }
+ var templ_7745c5c3_Var4 string
+ templ_7745c5c3_Var4, templ_7745c5c3_Err = templ.JoinStringErrs(acct.SourceType)
+ if templ_7745c5c3_Err != nil {
+ return templ.Error{Err: templ_7745c5c3_Err, FileName: `internal/web/templates/dashboard.templ`, Line: 35, Col: 51}
+ }
+ _, templ_7745c5c3_Err = templ_7745c5c3_Buffer.WriteString(templ.EscapeString(templ_7745c5c3_Var4))
+ if templ_7745c5c3_Err != nil {
+ return templ_7745c5c3_Err
+ }
+ templ_7745c5c3_Err = templruntime.WriteString(templ_7745c5c3_Buffer, 8, "
")
+ if templ_7745c5c3_Err != nil {
+ return templ_7745c5c3_Err
+ }
+ }
+ templ_7745c5c3_Err = templruntime.WriteString(templ_7745c5c3_Buffer, 9, "
")
+ if templ_7745c5c3_Err != nil {
+ return templ_7745c5c3_Err
+ }
+ }
+ templ_7745c5c3_Err = templruntime.WriteString(templ_7745c5c3_Buffer, 10, "
")
+ if templ_7745c5c3_Err != nil {
+ return templ_7745c5c3_Err
+ }
+ return nil
+ })
+ templ_7745c5c3_Err = Layout("Dashboard", "dashboard").Render(templ.WithChildren(ctx, templ_7745c5c3_Var2), templ_7745c5c3_Buffer)
+ if templ_7745c5c3_Err != nil {
+ return templ_7745c5c3_Err
+ }
+ return nil
+ })
+}
+
+func statCard(value string, label string) templ.Component {
+ return templruntime.GeneratedTemplate(func(templ_7745c5c3_Input templruntime.GeneratedComponentInput) (templ_7745c5c3_Err error) {
+ templ_7745c5c3_W, ctx := templ_7745c5c3_Input.Writer, templ_7745c5c3_Input.Context
+ if templ_7745c5c3_CtxErr := ctx.Err(); templ_7745c5c3_CtxErr != nil {
+ return templ_7745c5c3_CtxErr
+ }
+ templ_7745c5c3_Buffer, templ_7745c5c3_IsBuffer := templruntime.GetBuffer(templ_7745c5c3_W)
+ if !templ_7745c5c3_IsBuffer {
+ defer func() {
+ templ_7745c5c3_BufErr := templruntime.ReleaseBuffer(templ_7745c5c3_Buffer)
+ if templ_7745c5c3_Err == nil {
+ templ_7745c5c3_Err = templ_7745c5c3_BufErr
+ }
+ }()
+ }
+ ctx = templ.InitializeContext(ctx)
+ templ_7745c5c3_Var5 := templ.GetChildren(ctx)
+ if templ_7745c5c3_Var5 == nil {
+ templ_7745c5c3_Var5 = templ.NopComponent
+ }
+ ctx = templ.ClearChildren(ctx)
+ templ_7745c5c3_Err = templruntime.WriteString(templ_7745c5c3_Buffer, 11, "")
+ if templ_7745c5c3_Err != nil {
+ return templ_7745c5c3_Err
+ }
+ var templ_7745c5c3_Var6 string
+ templ_7745c5c3_Var6, templ_7745c5c3_Err = templ.JoinStringErrs(value)
+ if templ_7745c5c3_Err != nil {
+ return templ.Error{Err: templ_7745c5c3_Err, FileName: `internal/web/templates/dashboard.templ`, Line: 63, Col: 38}
+ }
+ _, templ_7745c5c3_Err = templ_7745c5c3_Buffer.WriteString(templ.EscapeString(templ_7745c5c3_Var6))
+ if templ_7745c5c3_Err != nil {
+ return templ_7745c5c3_Err
+ }
+ templ_7745c5c3_Err = templruntime.WriteString(templ_7745c5c3_Buffer, 12, "
")
+ if templ_7745c5c3_Err != nil {
+ return templ_7745c5c3_Err
+ }
+ var templ_7745c5c3_Var7 string
+ templ_7745c5c3_Var7, templ_7745c5c3_Err = templ.JoinStringErrs(label)
+ if templ_7745c5c3_Err != nil {
+ return templ.Error{Err: templ_7745c5c3_Err, FileName: `internal/web/templates/dashboard.templ`, Line: 64, Col: 38}
+ }
+ _, templ_7745c5c3_Err = templ_7745c5c3_Buffer.WriteString(templ.EscapeString(templ_7745c5c3_Var7))
+ if templ_7745c5c3_Err != nil {
+ return templ_7745c5c3_Err
+ }
+ templ_7745c5c3_Err = templruntime.WriteString(templ_7745c5c3_Buffer, 13, "
")
+ if templ_7745c5c3_Err != nil {
+ return templ_7745c5c3_Err
+ }
+ return nil
+ })
+}
+
+func StatsBar(stats *query.TotalStats) templ.Component {
+ return templruntime.GeneratedTemplate(func(templ_7745c5c3_Input templruntime.GeneratedComponentInput) (templ_7745c5c3_Err error) {
+ templ_7745c5c3_W, ctx := templ_7745c5c3_Input.Writer, templ_7745c5c3_Input.Context
+ if templ_7745c5c3_CtxErr := ctx.Err(); templ_7745c5c3_CtxErr != nil {
+ return templ_7745c5c3_CtxErr
+ }
+ templ_7745c5c3_Buffer, templ_7745c5c3_IsBuffer := templruntime.GetBuffer(templ_7745c5c3_W)
+ if !templ_7745c5c3_IsBuffer {
+ defer func() {
+ templ_7745c5c3_BufErr := templruntime.ReleaseBuffer(templ_7745c5c3_Buffer)
+ if templ_7745c5c3_Err == nil {
+ templ_7745c5c3_Err = templ_7745c5c3_BufErr
+ }
+ }()
+ }
+ ctx = templ.InitializeContext(ctx)
+ templ_7745c5c3_Var8 := templ.GetChildren(ctx)
+ if templ_7745c5c3_Var8 == nil {
+ templ_7745c5c3_Var8 = templ.NopComponent
+ }
+ ctx = templ.ClearChildren(ctx)
+ templ_7745c5c3_Err = templruntime.WriteString(templ_7745c5c3_Buffer, 14, "")
+ if templ_7745c5c3_Err != nil {
+ return templ_7745c5c3_Err
+ }
+ var templ_7745c5c3_Var9 string
+ templ_7745c5c3_Var9, templ_7745c5c3_Err = templ.JoinStringErrs(formatCount(stats.MessageCount))
+ if templ_7745c5c3_Err != nil {
+ return templ.Error{Err: templ_7745c5c3_Err, FileName: `internal/web/templates/dashboard.templ`, Line: 71, Col: 61}
+ }
+ _, templ_7745c5c3_Err = templ_7745c5c3_Buffer.WriteString(templ.EscapeString(templ_7745c5c3_Var9))
+ if templ_7745c5c3_Err != nil {
+ return templ_7745c5c3_Err
+ }
+ templ_7745c5c3_Err = templruntime.WriteString(templ_7745c5c3_Buffer, 15, " msgs
")
+ if templ_7745c5c3_Err != nil {
+ return templ_7745c5c3_Err
+ }
+ var templ_7745c5c3_Var10 string
+ templ_7745c5c3_Var10, templ_7745c5c3_Err = templ.JoinStringErrs(formatBytes(stats.TotalSize))
+ if templ_7745c5c3_Err != nil {
+ return templ.Error{Err: templ_7745c5c3_Err, FileName: `internal/web/templates/dashboard.templ`, Line: 74, Col: 58}
+ }
+ _, templ_7745c5c3_Err = templ_7745c5c3_Buffer.WriteString(templ.EscapeString(templ_7745c5c3_Var10))
+ if templ_7745c5c3_Err != nil {
+ return templ_7745c5c3_Err
+ }
+ templ_7745c5c3_Err = templruntime.WriteString(templ_7745c5c3_Buffer, 16, " total
")
+ if templ_7745c5c3_Err != nil {
+ return templ_7745c5c3_Err
+ }
+ var templ_7745c5c3_Var11 string
+ templ_7745c5c3_Var11, templ_7745c5c3_Err = templ.JoinStringErrs(formatCount(stats.AttachmentCount))
+ if templ_7745c5c3_Err != nil {
+ return templ.Error{Err: templ_7745c5c3_Err, FileName: `internal/web/templates/dashboard.templ`, Line: 77, Col: 64}
+ }
+ _, templ_7745c5c3_Err = templ_7745c5c3_Buffer.WriteString(templ.EscapeString(templ_7745c5c3_Var11))
+ if templ_7745c5c3_Err != nil {
+ return templ_7745c5c3_Err
+ }
+ templ_7745c5c3_Err = templruntime.WriteString(templ_7745c5c3_Buffer, 17, " attachments
")
+ if templ_7745c5c3_Err != nil {
+ return templ_7745c5c3_Err
+ }
+ return nil
+ })
+}
+
+var _ = templruntime.GeneratedTemplate
diff --git a/internal/web/templates/deletions.templ b/internal/web/templates/deletions.templ
new file mode 100644
index 00000000..febf8e02
--- /dev/null
+++ b/internal/web/templates/deletions.templ
@@ -0,0 +1,109 @@
+package templates
+
+import (
+ "fmt"
+ "github.com/wesm/msgvault/internal/deletion"
+)
+
+type DeletionsData struct {
+ Pending []*deletion.Manifest
+ InProgress []*deletion.Manifest
+ Completed []*deletion.Manifest
+ Failed []*deletion.Manifest
+ Flash string
+ FlashCount int
+}
+
+func (d DeletionsData) totalCount() int {
+ return len(d.Pending) + len(d.InProgress) + len(d.Completed) + len(d.Failed)
+}
+
+templ DeletionsPage(data DeletionsData) {
+ @Layout("Deletions", "deletions") {
+ if data.Flash == "staged" {
+
+ Successfully staged { fmt.Sprintf("%d", data.FlashCount) } message(s) for deletion.
+
+ }
+ Deletion Batches
+ if data.totalCount() == 0 {
+
+
No deletion batches
+
Stage individual messages for deletion from the message detail view, then execute with the CLI.
+
+ } else {
+ if len(data.Pending) > 0 {
+ @manifestSection("Pending", "pending", data.Pending, true)
+ }
+ if len(data.InProgress) > 0 {
+ @manifestSection("In Progress", "in_progress", data.InProgress, false)
+ }
+ if len(data.Completed) > 0 {
+ @manifestSection("Completed", "completed", data.Completed, false)
+ }
+ if len(data.Failed) > 0 {
+ @manifestSection("Failed", "failed", data.Failed, false)
+ }
+ }
+
+
How to execute
+
+ Staged batches are executed from the command line:
+
+
+
+ msgvault delete-staged |
+ Execute all pending |
+
+
+ msgvault delete-staged <batch-id> |
+ Execute specific batch |
+
+
+ msgvault delete-staged --dry-run |
+ Preview without deleting |
+
+
+ msgvault delete-staged --trash |
+ Move to trash (recoverable) |
+
+
+
+ }
+}
+
+templ manifestSection(title string, status string, manifests []*deletion.Manifest, showCancel bool) {
+
+
{ title } ({ fmt.Sprintf("%d", len(manifests)) })
+
+
+
+ | Batch ID |
+ Description |
+ Messages |
+ Created |
+ if showCancel {
+ |
+ }
+
+
+
+ for _, m := range manifests {
+
+ | { m.ID } |
+ { m.Description } |
+ { formatCount(int64(len(m.GmailIDs))) } |
+ { m.CreatedAt.Format("Jan 02 15:04") } |
+ if showCancel {
+
+
+ |
+ }
+
+ }
+
+
+
+}
diff --git a/internal/web/templates/deletions_templ.go b/internal/web/templates/deletions_templ.go
new file mode 100644
index 00000000..7e5dc910
--- /dev/null
+++ b/internal/web/templates/deletions_templ.go
@@ -0,0 +1,292 @@
+// Code generated by templ - DO NOT EDIT.
+
+// templ: version: v0.3.977
+package templates
+
+//lint:file-ignore SA4006 This context is only used if a nested component is present.
+
+import "github.com/a-h/templ"
+import templruntime "github.com/a-h/templ/runtime"
+
+import (
+ "fmt"
+ "github.com/wesm/msgvault/internal/deletion"
+)
+
+type DeletionsData struct {
+ Pending []*deletion.Manifest
+ InProgress []*deletion.Manifest
+ Completed []*deletion.Manifest
+ Failed []*deletion.Manifest
+ Flash string
+ FlashCount int
+}
+
+func (d DeletionsData) totalCount() int {
+ return len(d.Pending) + len(d.InProgress) + len(d.Completed) + len(d.Failed)
+}
+
+func DeletionsPage(data DeletionsData) templ.Component {
+ return templruntime.GeneratedTemplate(func(templ_7745c5c3_Input templruntime.GeneratedComponentInput) (templ_7745c5c3_Err error) {
+ templ_7745c5c3_W, ctx := templ_7745c5c3_Input.Writer, templ_7745c5c3_Input.Context
+ if templ_7745c5c3_CtxErr := ctx.Err(); templ_7745c5c3_CtxErr != nil {
+ return templ_7745c5c3_CtxErr
+ }
+ templ_7745c5c3_Buffer, templ_7745c5c3_IsBuffer := templruntime.GetBuffer(templ_7745c5c3_W)
+ if !templ_7745c5c3_IsBuffer {
+ defer func() {
+ templ_7745c5c3_BufErr := templruntime.ReleaseBuffer(templ_7745c5c3_Buffer)
+ if templ_7745c5c3_Err == nil {
+ templ_7745c5c3_Err = templ_7745c5c3_BufErr
+ }
+ }()
+ }
+ ctx = templ.InitializeContext(ctx)
+ templ_7745c5c3_Var1 := templ.GetChildren(ctx)
+ if templ_7745c5c3_Var1 == nil {
+ templ_7745c5c3_Var1 = templ.NopComponent
+ }
+ ctx = templ.ClearChildren(ctx)
+ templ_7745c5c3_Var2 := templruntime.GeneratedTemplate(func(templ_7745c5c3_Input templruntime.GeneratedComponentInput) (templ_7745c5c3_Err error) {
+ templ_7745c5c3_W, ctx := templ_7745c5c3_Input.Writer, templ_7745c5c3_Input.Context
+ templ_7745c5c3_Buffer, templ_7745c5c3_IsBuffer := templruntime.GetBuffer(templ_7745c5c3_W)
+ if !templ_7745c5c3_IsBuffer {
+ defer func() {
+ templ_7745c5c3_BufErr := templruntime.ReleaseBuffer(templ_7745c5c3_Buffer)
+ if templ_7745c5c3_Err == nil {
+ templ_7745c5c3_Err = templ_7745c5c3_BufErr
+ }
+ }()
+ }
+ ctx = templ.InitializeContext(ctx)
+ if data.Flash == "staged" {
+ templ_7745c5c3_Err = templruntime.WriteString(templ_7745c5c3_Buffer, 1, "Successfully staged ")
+ if templ_7745c5c3_Err != nil {
+ return templ_7745c5c3_Err
+ }
+ var templ_7745c5c3_Var3 string
+ templ_7745c5c3_Var3, templ_7745c5c3_Err = templ.JoinStringErrs(fmt.Sprintf("%d", data.FlashCount))
+ if templ_7745c5c3_Err != nil {
+ return templ.Error{Err: templ_7745c5c3_Err, FileName: `internal/web/templates/deletions.templ`, Line: 25, Col: 60}
+ }
+ _, templ_7745c5c3_Err = templ_7745c5c3_Buffer.WriteString(templ.EscapeString(templ_7745c5c3_Var3))
+ if templ_7745c5c3_Err != nil {
+ return templ_7745c5c3_Err
+ }
+ templ_7745c5c3_Err = templruntime.WriteString(templ_7745c5c3_Buffer, 2, " message(s) for deletion.
")
+ if templ_7745c5c3_Err != nil {
+ return templ_7745c5c3_Err
+ }
+ }
+ templ_7745c5c3_Err = templruntime.WriteString(templ_7745c5c3_Buffer, 3, " Deletion Batches
")
+ if templ_7745c5c3_Err != nil {
+ return templ_7745c5c3_Err
+ }
+ if data.totalCount() == 0 {
+ templ_7745c5c3_Err = templruntime.WriteString(templ_7745c5c3_Buffer, 4, "No deletion batches
Stage individual messages for deletion from the message detail view, then execute with the CLI.
")
+ if templ_7745c5c3_Err != nil {
+ return templ_7745c5c3_Err
+ }
+ } else {
+ if len(data.Pending) > 0 {
+ templ_7745c5c3_Err = manifestSection("Pending", "pending", data.Pending, true).Render(ctx, templ_7745c5c3_Buffer)
+ if templ_7745c5c3_Err != nil {
+ return templ_7745c5c3_Err
+ }
+ }
+ templ_7745c5c3_Err = templruntime.WriteString(templ_7745c5c3_Buffer, 5, " ")
+ if templ_7745c5c3_Err != nil {
+ return templ_7745c5c3_Err
+ }
+ if len(data.InProgress) > 0 {
+ templ_7745c5c3_Err = manifestSection("In Progress", "in_progress", data.InProgress, false).Render(ctx, templ_7745c5c3_Buffer)
+ if templ_7745c5c3_Err != nil {
+ return templ_7745c5c3_Err
+ }
+ }
+ templ_7745c5c3_Err = templruntime.WriteString(templ_7745c5c3_Buffer, 6, " ")
+ if templ_7745c5c3_Err != nil {
+ return templ_7745c5c3_Err
+ }
+ if len(data.Completed) > 0 {
+ templ_7745c5c3_Err = manifestSection("Completed", "completed", data.Completed, false).Render(ctx, templ_7745c5c3_Buffer)
+ if templ_7745c5c3_Err != nil {
+ return templ_7745c5c3_Err
+ }
+ }
+ templ_7745c5c3_Err = templruntime.WriteString(templ_7745c5c3_Buffer, 7, " ")
+ if templ_7745c5c3_Err != nil {
+ return templ_7745c5c3_Err
+ }
+ if len(data.Failed) > 0 {
+ templ_7745c5c3_Err = manifestSection("Failed", "failed", data.Failed, false).Render(ctx, templ_7745c5c3_Buffer)
+ if templ_7745c5c3_Err != nil {
+ return templ_7745c5c3_Err
+ }
+ }
+ }
+ templ_7745c5c3_Err = templruntime.WriteString(templ_7745c5c3_Buffer, 8, " How to execute
Staged batches are executed from the command line:
msgvault delete-staged | Execute all pending |
msgvault delete-staged <batch-id> | Execute specific batch |
msgvault delete-staged --dry-run | Preview without deleting |
msgvault delete-staged --trash | Move to trash (recoverable) |
")
+ if templ_7745c5c3_Err != nil {
+ return templ_7745c5c3_Err
+ }
+ return nil
+ })
+ templ_7745c5c3_Err = Layout("Deletions", "deletions").Render(templ.WithChildren(ctx, templ_7745c5c3_Var2), templ_7745c5c3_Buffer)
+ if templ_7745c5c3_Err != nil {
+ return templ_7745c5c3_Err
+ }
+ return nil
+ })
+}
+
+func manifestSection(title string, status string, manifests []*deletion.Manifest, showCancel bool) templ.Component {
+ return templruntime.GeneratedTemplate(func(templ_7745c5c3_Input templruntime.GeneratedComponentInput) (templ_7745c5c3_Err error) {
+ templ_7745c5c3_W, ctx := templ_7745c5c3_Input.Writer, templ_7745c5c3_Input.Context
+ if templ_7745c5c3_CtxErr := ctx.Err(); templ_7745c5c3_CtxErr != nil {
+ return templ_7745c5c3_CtxErr
+ }
+ templ_7745c5c3_Buffer, templ_7745c5c3_IsBuffer := templruntime.GetBuffer(templ_7745c5c3_W)
+ if !templ_7745c5c3_IsBuffer {
+ defer func() {
+ templ_7745c5c3_BufErr := templruntime.ReleaseBuffer(templ_7745c5c3_Buffer)
+ if templ_7745c5c3_Err == nil {
+ templ_7745c5c3_Err = templ_7745c5c3_BufErr
+ }
+ }()
+ }
+ ctx = templ.InitializeContext(ctx)
+ templ_7745c5c3_Var4 := templ.GetChildren(ctx)
+ if templ_7745c5c3_Var4 == nil {
+ templ_7745c5c3_Var4 = templ.NopComponent
+ }
+ ctx = templ.ClearChildren(ctx)
+ templ_7745c5c3_Err = templruntime.WriteString(templ_7745c5c3_Buffer, 9, "")
+ if templ_7745c5c3_Err != nil {
+ return templ_7745c5c3_Err
+ }
+ var templ_7745c5c3_Var5 string
+ templ_7745c5c3_Var5, templ_7745c5c3_Err = templ.JoinStringErrs(title)
+ if templ_7745c5c3_Err != nil {
+ return templ.Error{Err: templ_7745c5c3_Err, FileName: `internal/web/templates/deletions.templ`, Line: 77, Col: 33}
+ }
+ _, templ_7745c5c3_Err = templ_7745c5c3_Buffer.WriteString(templ.EscapeString(templ_7745c5c3_Var5))
+ if templ_7745c5c3_Err != nil {
+ return templ_7745c5c3_Err
+ }
+ templ_7745c5c3_Err = templruntime.WriteString(templ_7745c5c3_Buffer, 10, " (")
+ if templ_7745c5c3_Err != nil {
+ return templ_7745c5c3_Err
+ }
+ var templ_7745c5c3_Var6 string
+ templ_7745c5c3_Var6, templ_7745c5c3_Err = templ.JoinStringErrs(fmt.Sprintf("%d", len(manifests)))
+ if templ_7745c5c3_Err != nil {
+ return templ.Error{Err: templ_7745c5c3_Err, FileName: `internal/web/templates/deletions.templ`, Line: 77, Col: 72}
+ }
+ _, templ_7745c5c3_Err = templ_7745c5c3_Buffer.WriteString(templ.EscapeString(templ_7745c5c3_Var6))
+ if templ_7745c5c3_Err != nil {
+ return templ_7745c5c3_Err
+ }
+ templ_7745c5c3_Err = templruntime.WriteString(templ_7745c5c3_Buffer, 11, ")
| Batch ID | Description | Messages | Created | ")
+ if templ_7745c5c3_Err != nil {
+ return templ_7745c5c3_Err
+ }
+ if showCancel {
+ templ_7745c5c3_Err = templruntime.WriteString(templ_7745c5c3_Buffer, 12, " | ")
+ if templ_7745c5c3_Err != nil {
+ return templ_7745c5c3_Err
+ }
+ }
+ templ_7745c5c3_Err = templruntime.WriteString(templ_7745c5c3_Buffer, 13, "
")
+ if templ_7745c5c3_Err != nil {
+ return templ_7745c5c3_Err
+ }
+ for _, m := range manifests {
+ templ_7745c5c3_Err = templruntime.WriteString(templ_7745c5c3_Buffer, 14, "| ")
+ if templ_7745c5c3_Err != nil {
+ return templ_7745c5c3_Err
+ }
+ var templ_7745c5c3_Var7 string
+ templ_7745c5c3_Var7, templ_7745c5c3_Err = templ.JoinStringErrs(m.ID)
+ if templ_7745c5c3_Err != nil {
+ return templ.Error{Err: templ_7745c5c3_Err, FileName: `internal/web/templates/deletions.templ`, Line: 93, Col: 67}
+ }
+ _, templ_7745c5c3_Err = templ_7745c5c3_Buffer.WriteString(templ.EscapeString(templ_7745c5c3_Var7))
+ if templ_7745c5c3_Err != nil {
+ return templ_7745c5c3_Err
+ }
+ templ_7745c5c3_Err = templruntime.WriteString(templ_7745c5c3_Buffer, 15, " | ")
+ if templ_7745c5c3_Err != nil {
+ return templ_7745c5c3_Err
+ }
+ var templ_7745c5c3_Var8 string
+ templ_7745c5c3_Var8, templ_7745c5c3_Err = templ.JoinStringErrs(m.Description)
+ if templ_7745c5c3_Err != nil {
+ return templ.Error{Err: templ_7745c5c3_Err, FileName: `internal/web/templates/deletions.templ`, Line: 94, Col: 25}
+ }
+ _, templ_7745c5c3_Err = templ_7745c5c3_Buffer.WriteString(templ.EscapeString(templ_7745c5c3_Var8))
+ if templ_7745c5c3_Err != nil {
+ return templ_7745c5c3_Err
+ }
+ templ_7745c5c3_Err = templruntime.WriteString(templ_7745c5c3_Buffer, 16, " | ")
+ if templ_7745c5c3_Err != nil {
+ return templ_7745c5c3_Err
+ }
+ var templ_7745c5c3_Var9 string
+ templ_7745c5c3_Var9, templ_7745c5c3_Err = templ.JoinStringErrs(formatCount(int64(len(m.GmailIDs))))
+ if templ_7745c5c3_Err != nil {
+ return templ.Error{Err: templ_7745c5c3_Err, FileName: `internal/web/templates/deletions.templ`, Line: 95, Col: 59}
+ }
+ _, templ_7745c5c3_Err = templ_7745c5c3_Buffer.WriteString(templ.EscapeString(templ_7745c5c3_Var9))
+ if templ_7745c5c3_Err != nil {
+ return templ_7745c5c3_Err
+ }
+ templ_7745c5c3_Err = templruntime.WriteString(templ_7745c5c3_Buffer, 17, " | ")
+ if templ_7745c5c3_Err != nil {
+ return templ_7745c5c3_Err
+ }
+ var templ_7745c5c3_Var10 string
+ templ_7745c5c3_Var10, templ_7745c5c3_Err = templ.JoinStringErrs(m.CreatedAt.Format("Jan 02 15:04"))
+ if templ_7745c5c3_Err != nil {
+ return templ.Error{Err: templ_7745c5c3_Err, FileName: `internal/web/templates/deletions.templ`, Line: 96, Col: 64}
+ }
+ _, templ_7745c5c3_Err = templ_7745c5c3_Buffer.WriteString(templ.EscapeString(templ_7745c5c3_Var10))
+ if templ_7745c5c3_Err != nil {
+ return templ_7745c5c3_Err
+ }
+ templ_7745c5c3_Err = templruntime.WriteString(templ_7745c5c3_Buffer, 18, " | ")
+ if templ_7745c5c3_Err != nil {
+ return templ_7745c5c3_Err
+ }
+ if showCancel {
+ templ_7745c5c3_Err = templruntime.WriteString(templ_7745c5c3_Buffer, 19, " | ")
+ if templ_7745c5c3_Err != nil {
+ return templ_7745c5c3_Err
+ }
+ }
+ templ_7745c5c3_Err = templruntime.WriteString(templ_7745c5c3_Buffer, 21, "
")
+ if templ_7745c5c3_Err != nil {
+ return templ_7745c5c3_Err
+ }
+ }
+ templ_7745c5c3_Err = templruntime.WriteString(templ_7745c5c3_Buffer, 22, "
")
+ if templ_7745c5c3_Err != nil {
+ return templ_7745c5c3_Err
+ }
+ return nil
+ })
+}
+
+var _ = templruntime.GeneratedTemplate
diff --git a/internal/web/templates/helpers.go b/internal/web/templates/helpers.go
new file mode 100644
index 00000000..c46de1be
--- /dev/null
+++ b/internal/web/templates/helpers.go
@@ -0,0 +1,108 @@
+package templates
+
+import (
+ "fmt"
+ "html"
+ "net/url"
+ "regexp"
+ "time"
+)
+
+// formatBytes formats a byte count into a human-readable string.
+func formatBytes(b int64) string {
+ const (
+ KB = 1024
+ MB = 1024 * KB
+ GB = 1024 * MB
+ )
+ switch {
+ case b >= GB:
+ return fmt.Sprintf("%.1f GB", float64(b)/float64(GB))
+ case b >= MB:
+ return fmt.Sprintf("%.1f MB", float64(b)/float64(MB))
+ case b >= KB:
+ return fmt.Sprintf("%.1f KB", float64(b)/float64(KB))
+ default:
+ return fmt.Sprintf("%d B", b)
+ }
+}
+
+// formatCount formats a large number with comma separators.
+func formatCount(n int64) string {
+ if n < 0 {
+ return "-" + formatCount(-n)
+ }
+ if n < 1000 {
+ return fmt.Sprintf("%d", n)
+ }
+
+ s := fmt.Sprintf("%d", n)
+ result := make([]byte, 0, len(s)+len(s)/3)
+ rem := len(s) % 3
+ if rem == 0 {
+ rem = 3
+ }
+ result = append(result, s[:rem]...)
+ for i := rem; i < len(s); i += 3 {
+ result = append(result, ',')
+ result = append(result, s[i:i+3]...)
+ }
+ return string(result)
+}
+
+// addParam appends a query parameter to a URL string.
+func addParam(base, key, value string) string {
+ if value == "" {
+ return base
+ }
+ u, err := url.Parse(base)
+ if err != nil {
+ return base
+ }
+ q := u.Query()
+ q.Set(key, value)
+ u.RawQuery = q.Encode()
+ return u.String()
+}
+
+// deleteParam removes a query parameter from a URL string.
+func deleteParam(base, key string) string {
+ u, err := url.Parse(base)
+ if err != nil {
+ return base
+ }
+ q := u.Query()
+ q.Del(key)
+ u.RawQuery = q.Encode()
+ return u.String()
+}
+
+// formatMessageDate formats a time for the message list.
+func formatMessageDate(t time.Time) string {
+ now := time.Now()
+ if t.Year() == now.Year() {
+ return t.Format("Jan 02 15:04")
+ }
+ return t.Format("Jan 02, 2006")
+}
+
+// Regexes for HTML-to-text conversion.
+var (
+ // styleRe and scriptRe strip and blocks
+ // (including their content) before tag stripping to avoid rendering CSS/JS as text.
+ // Go's regexp (RE2) doesn't support backreferences, so we use separate patterns.
+ styleRe = regexp.MustCompile(`(?is)`)
+ scriptRe = regexp.MustCompile(`(?is)`)
+ // htmlTagRe matches HTML tags for stripping.
+ htmlTagRe = regexp.MustCompile(`<[^>]*>`)
+)
+
+// htmlToPlainText strips style/script blocks and all HTML tags, returning plain text.
+// Used to extract readable content from HTML email bodies.
+func htmlToPlainText(s string) string {
+ // Remove style/script blocks first (their content is not displayable text)
+ text := styleRe.ReplaceAllString(s, "")
+ text = scriptRe.ReplaceAllString(text, "")
+ text = htmlTagRe.ReplaceAllString(text, "")
+ return html.UnescapeString(text)
+}
diff --git a/internal/web/templates/layout.templ b/internal/web/templates/layout.templ
new file mode 100644
index 00000000..d14b0039
--- /dev/null
+++ b/internal/web/templates/layout.templ
@@ -0,0 +1,111 @@
+package templates
+
+templ Layout(title string, activePage string) {
+
+
+
+
+
+ { title } - msgvault
+
+
+
+
+
+ { children... }
+
+
+ @helpOverlay()
+
+
+
+}
+
+templ helpOverlay() {
+
+
+
Keyboard Shortcuts
+
+
+ | j k |
+ Navigate rows up / down |
+
+
+ | Enter |
+ Drill into selected row |
+
+
+ | o |
+ Open messages for row |
+
+
+ | g G |
+ Jump to first / last row |
+
+
+ | n p |
+ Next / previous page |
+
+
+ | / |
+ Focus search (or go to search) |
+
+
+ | Esc |
+ Blur search / close help |
+
+
+ | d |
+ Enter delete mode |
+
+
+ | Space |
+ Toggle selection (delete mode) |
+
+
+ | A |
+ Select all (delete mode) |
+
+
+ | x |
+ Clear selection (delete mode) |
+
+
+ | ← → |
+ Previous / next message |
+
+
+ | Bksp |
+ Go back (breadcrumb) |
+
+
+ | H |
+ Go to Dashboard |
+
+
+ | B |
+ Go to Browse |
+
+
+ | ? |
+ Toggle this help |
+
+
+
Press ? or Esc to close
+
+
+}
diff --git a/internal/web/templates/layout_templ.go b/internal/web/templates/layout_templ.go
new file mode 100644
index 00000000..7994b781
--- /dev/null
+++ b/internal/web/templates/layout_templ.go
@@ -0,0 +1,186 @@
+// Code generated by templ - DO NOT EDIT.
+
+// templ: version: v0.3.977
+package templates
+
+//lint:file-ignore SA4006 This context is only used if a nested component is present.
+
+import "github.com/a-h/templ"
+import templruntime "github.com/a-h/templ/runtime"
+
+func Layout(title string, activePage string) templ.Component {
+ return templruntime.GeneratedTemplate(func(templ_7745c5c3_Input templruntime.GeneratedComponentInput) (templ_7745c5c3_Err error) {
+ templ_7745c5c3_W, ctx := templ_7745c5c3_Input.Writer, templ_7745c5c3_Input.Context
+ if templ_7745c5c3_CtxErr := ctx.Err(); templ_7745c5c3_CtxErr != nil {
+ return templ_7745c5c3_CtxErr
+ }
+ templ_7745c5c3_Buffer, templ_7745c5c3_IsBuffer := templruntime.GetBuffer(templ_7745c5c3_W)
+ if !templ_7745c5c3_IsBuffer {
+ defer func() {
+ templ_7745c5c3_BufErr := templruntime.ReleaseBuffer(templ_7745c5c3_Buffer)
+ if templ_7745c5c3_Err == nil {
+ templ_7745c5c3_Err = templ_7745c5c3_BufErr
+ }
+ }()
+ }
+ ctx = templ.InitializeContext(ctx)
+ templ_7745c5c3_Var1 := templ.GetChildren(ctx)
+ if templ_7745c5c3_Var1 == nil {
+ templ_7745c5c3_Var1 = templ.NopComponent
+ }
+ ctx = templ.ClearChildren(ctx)
+ templ_7745c5c3_Err = templruntime.WriteString(templ_7745c5c3_Buffer, 1, "")
+ if templ_7745c5c3_Err != nil {
+ return templ_7745c5c3_Err
+ }
+ var templ_7745c5c3_Var2 string
+ templ_7745c5c3_Var2, templ_7745c5c3_Err = templ.JoinStringErrs(title)
+ if templ_7745c5c3_Err != nil {
+ return templ.Error{Err: templ_7745c5c3_Err, FileName: `internal/web/templates/layout.templ`, Line: 9, Col: 17}
+ }
+ _, templ_7745c5c3_Err = templ_7745c5c3_Buffer.WriteString(templ.EscapeString(templ_7745c5c3_Var2))
+ if templ_7745c5c3_Err != nil {
+ return templ_7745c5c3_Err
+ }
+ templ_7745c5c3_Err = templruntime.WriteString(templ_7745c5c3_Buffer, 2, " - msgvault")
+ if templ_7745c5c3_Err != nil {
+ return templ_7745c5c3_Err
+ }
+ templ_7745c5c3_Err = templ_7745c5c3_Var1.Render(ctx, templ_7745c5c3_Buffer)
+ if templ_7745c5c3_Err != nil {
+ return templ_7745c5c3_Err
+ }
+ templ_7745c5c3_Err = templruntime.WriteString(templ_7745c5c3_Buffer, 11, "
")
+ if templ_7745c5c3_Err != nil {
+ return templ_7745c5c3_Err
+ }
+ templ_7745c5c3_Err = helpOverlay().Render(ctx, templ_7745c5c3_Buffer)
+ if templ_7745c5c3_Err != nil {
+ return templ_7745c5c3_Err
+ }
+ templ_7745c5c3_Err = templruntime.WriteString(templ_7745c5c3_Buffer, 12, "")
+ if templ_7745c5c3_Err != nil {
+ return templ_7745c5c3_Err
+ }
+ return nil
+ })
+}
+
+func helpOverlay() templ.Component {
+ return templruntime.GeneratedTemplate(func(templ_7745c5c3_Input templruntime.GeneratedComponentInput) (templ_7745c5c3_Err error) {
+ templ_7745c5c3_W, ctx := templ_7745c5c3_Input.Writer, templ_7745c5c3_Input.Context
+ if templ_7745c5c3_CtxErr := ctx.Err(); templ_7745c5c3_CtxErr != nil {
+ return templ_7745c5c3_CtxErr
+ }
+ templ_7745c5c3_Buffer, templ_7745c5c3_IsBuffer := templruntime.GetBuffer(templ_7745c5c3_W)
+ if !templ_7745c5c3_IsBuffer {
+ defer func() {
+ templ_7745c5c3_BufErr := templruntime.ReleaseBuffer(templ_7745c5c3_Buffer)
+ if templ_7745c5c3_Err == nil {
+ templ_7745c5c3_Err = templ_7745c5c3_BufErr
+ }
+ }()
+ }
+ ctx = templ.InitializeContext(ctx)
+ templ_7745c5c3_Var11 := templ.GetChildren(ctx)
+ if templ_7745c5c3_Var11 == nil {
+ templ_7745c5c3_Var11 = templ.NopComponent
+ }
+ ctx = templ.ClearChildren(ctx)
+ templ_7745c5c3_Err = templruntime.WriteString(templ_7745c5c3_Buffer, 13, "Keyboard Shortcuts
| j k | Navigate rows up / down |
| Enter | Drill into selected row |
| o | Open messages for row |
| g G | Jump to first / last row |
| n p | Next / previous page |
| / | Focus search (or go to search) |
| Esc | Blur search / close help |
| d | Enter delete mode |
| Space | Toggle selection (delete mode) |
| A | Select all (delete mode) |
| x | Clear selection (delete mode) |
| ← → | Previous / next message |
| Bksp | Go back (breadcrumb) |
| H | Go to Dashboard |
| B | Go to Browse |
| ? | Toggle this help |
Press ? or Esc to close
")
+ if templ_7745c5c3_Err != nil {
+ return templ_7745c5c3_Err
+ }
+ return nil
+ })
+}
+
+var _ = templruntime.GeneratedTemplate
diff --git a/internal/web/templates/message_detail.templ b/internal/web/templates/message_detail.templ
new file mode 100644
index 00000000..0b9a1a74
--- /dev/null
+++ b/internal/web/templates/message_detail.templ
@@ -0,0 +1,144 @@
+package templates
+
+import (
+ "fmt"
+ "strings"
+ "github.com/wesm/msgvault/internal/query"
+)
+
+type MessageDetailData struct {
+ Message *query.MessageDetail
+ // Navigation context for back link
+ BackURL string
+}
+
+func formatAddress(a query.Address) string {
+ if a.Name != "" {
+ return a.Name + " <" + a.Email + ">"
+ }
+ return a.Email
+}
+
+func formatAddressList(addrs []query.Address) string {
+ parts := make([]string, len(addrs))
+ for i, a := range addrs {
+ parts[i] = formatAddress(a)
+ }
+ return strings.Join(parts, ", ")
+}
+
+templ MessageDetailPage(data MessageDetailData) {
+ @Layout("Message", "messages") {
+ if data.Message == nil {
+
+
Message not found
+
The requested message could not be loaded.
+
+ } else {
+
+ @messageHeader(data.Message)
+ @messageBody(data.Message)
+ }
+ }
+}
+
+templ messageHeader(msg *query.MessageDetail) {
+
+
+ if msg.Subject != "" {
+ { msg.Subject }
+ } else {
+ (no subject)
+ }
+
+
+ if len(msg.Attachments) > 0 {
+
+
+ Attachments ({ fmt.Sprintf("%d", len(msg.Attachments)) })
+
+ for _, att := range msg.Attachments {
+
+ if att.ContentHash != "" {
+
+ { att.Filename }
+
+ } else {
+ { att.Filename }
+ }
+
({ formatBytes(att.Size) })
+
+ }
+
+ }
+ if msg.ConversationID > 0 {
+
+ }
+
+
+
+
+}
+
+templ messageBody(msg *query.MessageDetail) {
+
+ if msg.BodyText != "" {
+
{ msg.BodyText }
+ } else if msg.BodyHTML != "" {
+
{ htmlToPlainText(msg.BodyHTML) }
+ } else {
+
(No message content)
+ }
+
+}
diff --git a/internal/web/templates/message_detail_templ.go b/internal/web/templates/message_detail_templ.go
new file mode 100644
index 00000000..ca40b0fa
--- /dev/null
+++ b/internal/web/templates/message_detail_templ.go
@@ -0,0 +1,491 @@
+// Code generated by templ - DO NOT EDIT.
+
+// templ: version: v0.3.977
+package templates
+
+//lint:file-ignore SA4006 This context is only used if a nested component is present.
+
+import "github.com/a-h/templ"
+import templruntime "github.com/a-h/templ/runtime"
+
+import (
+ "fmt"
+ "github.com/wesm/msgvault/internal/query"
+ "strings"
+)
+
+type MessageDetailData struct {
+ Message *query.MessageDetail
+ // Navigation context for back link
+ BackURL string
+}
+
+func formatAddress(a query.Address) string {
+ if a.Name != "" {
+ return a.Name + " <" + a.Email + ">"
+ }
+ return a.Email
+}
+
+func formatAddressList(addrs []query.Address) string {
+ parts := make([]string, len(addrs))
+ for i, a := range addrs {
+ parts[i] = formatAddress(a)
+ }
+ return strings.Join(parts, ", ")
+}
+
+func MessageDetailPage(data MessageDetailData) templ.Component {
+ return templruntime.GeneratedTemplate(func(templ_7745c5c3_Input templruntime.GeneratedComponentInput) (templ_7745c5c3_Err error) {
+ templ_7745c5c3_W, ctx := templ_7745c5c3_Input.Writer, templ_7745c5c3_Input.Context
+ if templ_7745c5c3_CtxErr := ctx.Err(); templ_7745c5c3_CtxErr != nil {
+ return templ_7745c5c3_CtxErr
+ }
+ templ_7745c5c3_Buffer, templ_7745c5c3_IsBuffer := templruntime.GetBuffer(templ_7745c5c3_W)
+ if !templ_7745c5c3_IsBuffer {
+ defer func() {
+ templ_7745c5c3_BufErr := templruntime.ReleaseBuffer(templ_7745c5c3_Buffer)
+ if templ_7745c5c3_Err == nil {
+ templ_7745c5c3_Err = templ_7745c5c3_BufErr
+ }
+ }()
+ }
+ ctx = templ.InitializeContext(ctx)
+ templ_7745c5c3_Var1 := templ.GetChildren(ctx)
+ if templ_7745c5c3_Var1 == nil {
+ templ_7745c5c3_Var1 = templ.NopComponent
+ }
+ ctx = templ.ClearChildren(ctx)
+ templ_7745c5c3_Var2 := templruntime.GeneratedTemplate(func(templ_7745c5c3_Input templruntime.GeneratedComponentInput) (templ_7745c5c3_Err error) {
+ templ_7745c5c3_W, ctx := templ_7745c5c3_Input.Writer, templ_7745c5c3_Input.Context
+ templ_7745c5c3_Buffer, templ_7745c5c3_IsBuffer := templruntime.GetBuffer(templ_7745c5c3_W)
+ if !templ_7745c5c3_IsBuffer {
+ defer func() {
+ templ_7745c5c3_BufErr := templruntime.ReleaseBuffer(templ_7745c5c3_Buffer)
+ if templ_7745c5c3_Err == nil {
+ templ_7745c5c3_Err = templ_7745c5c3_BufErr
+ }
+ }()
+ }
+ ctx = templ.InitializeContext(ctx)
+ if data.Message == nil {
+ templ_7745c5c3_Err = templruntime.WriteString(templ_7745c5c3_Buffer, 1, "Message not found
The requested message could not be loaded.
")
+ if templ_7745c5c3_Err != nil {
+ return templ_7745c5c3_Err
+ }
+ } else {
+ templ_7745c5c3_Err = templruntime.WriteString(templ_7745c5c3_Buffer, 2, "")
+ if templ_7745c5c3_Err != nil {
+ return templ_7745c5c3_Err
+ }
+ templ_7745c5c3_Err = messageHeader(data.Message).Render(ctx, templ_7745c5c3_Buffer)
+ if templ_7745c5c3_Err != nil {
+ return templ_7745c5c3_Err
+ }
+ templ_7745c5c3_Err = templruntime.WriteString(templ_7745c5c3_Buffer, 4, " ")
+ if templ_7745c5c3_Err != nil {
+ return templ_7745c5c3_Err
+ }
+ templ_7745c5c3_Err = messageBody(data.Message).Render(ctx, templ_7745c5c3_Buffer)
+ if templ_7745c5c3_Err != nil {
+ return templ_7745c5c3_Err
+ }
+ }
+ return nil
+ })
+ templ_7745c5c3_Err = Layout("Message", "messages").Render(templ.WithChildren(ctx, templ_7745c5c3_Var2), templ_7745c5c3_Buffer)
+ if templ_7745c5c3_Err != nil {
+ return templ_7745c5c3_Err
+ }
+ return nil
+ })
+}
+
+func messageHeader(msg *query.MessageDetail) templ.Component {
+ return templruntime.GeneratedTemplate(func(templ_7745c5c3_Input templruntime.GeneratedComponentInput) (templ_7745c5c3_Err error) {
+ templ_7745c5c3_W, ctx := templ_7745c5c3_Input.Writer, templ_7745c5c3_Input.Context
+ if templ_7745c5c3_CtxErr := ctx.Err(); templ_7745c5c3_CtxErr != nil {
+ return templ_7745c5c3_CtxErr
+ }
+ templ_7745c5c3_Buffer, templ_7745c5c3_IsBuffer := templruntime.GetBuffer(templ_7745c5c3_W)
+ if !templ_7745c5c3_IsBuffer {
+ defer func() {
+ templ_7745c5c3_BufErr := templruntime.ReleaseBuffer(templ_7745c5c3_Buffer)
+ if templ_7745c5c3_Err == nil {
+ templ_7745c5c3_Err = templ_7745c5c3_BufErr
+ }
+ }()
+ }
+ ctx = templ.InitializeContext(ctx)
+ templ_7745c5c3_Var4 := templ.GetChildren(ctx)
+ if templ_7745c5c3_Var4 == nil {
+ templ_7745c5c3_Var4 = templ.NopComponent
+ }
+ ctx = templ.ClearChildren(ctx)
+ templ_7745c5c3_Err = templruntime.WriteString(templ_7745c5c3_Buffer, 5, "")
+ if templ_7745c5c3_Err != nil {
+ return templ_7745c5c3_Err
+ }
+ if msg.Subject != "" {
+ var templ_7745c5c3_Var5 string
+ templ_7745c5c3_Var5, templ_7745c5c3_Err = templ.JoinStringErrs(msg.Subject)
+ if templ_7745c5c3_Err != nil {
+ return templ.Error{Err: templ_7745c5c3_Err, FileName: `internal/web/templates/message_detail.templ`, Line: 51, Col: 17}
+ }
+ _, templ_7745c5c3_Err = templ_7745c5c3_Buffer.WriteString(templ.EscapeString(templ_7745c5c3_Var5))
+ if templ_7745c5c3_Err != nil {
+ return templ_7745c5c3_Err
+ }
+ } else {
+ templ_7745c5c3_Err = templruntime.WriteString(templ_7745c5c3_Buffer, 6, "(no subject)")
+ if templ_7745c5c3_Err != nil {
+ return templ_7745c5c3_Err
+ }
+ }
+ templ_7745c5c3_Err = templruntime.WriteString(templ_7745c5c3_Buffer, 7, "
")
+ if templ_7745c5c3_Err != nil {
+ return templ_7745c5c3_Err
+ }
+ if len(msg.Attachments) > 0 {
+ templ_7745c5c3_Err = templruntime.WriteString(templ_7745c5c3_Buffer, 22, "
Attachments (")
+ if templ_7745c5c3_Err != nil {
+ return templ_7745c5c3_Err
+ }
+ var templ_7745c5c3_Var12 string
+ templ_7745c5c3_Var12, templ_7745c5c3_Err = templ.JoinStringErrs(fmt.Sprintf("%d", len(msg.Attachments)))
+ if templ_7745c5c3_Err != nil {
+ return templ.Error{Err: templ_7745c5c3_Err, FileName: `internal/web/templates/message_detail.templ`, Line: 101, Col: 59}
+ }
+ _, templ_7745c5c3_Err = templ_7745c5c3_Buffer.WriteString(templ.EscapeString(templ_7745c5c3_Var12))
+ if templ_7745c5c3_Err != nil {
+ return templ_7745c5c3_Err
+ }
+ templ_7745c5c3_Err = templruntime.WriteString(templ_7745c5c3_Buffer, 23, ")
")
+ if templ_7745c5c3_Err != nil {
+ return templ_7745c5c3_Err
+ }
+ for _, att := range msg.Attachments {
+ templ_7745c5c3_Err = templruntime.WriteString(templ_7745c5c3_Buffer, 24, "
")
+ if templ_7745c5c3_Err != nil {
+ return templ_7745c5c3_Err
+ }
+ if att.ContentHash != "" {
+ templ_7745c5c3_Err = templruntime.WriteString(templ_7745c5c3_Buffer, 25, "
")
+ if templ_7745c5c3_Err != nil {
+ return templ_7745c5c3_Err
+ }
+ var templ_7745c5c3_Var14 string
+ templ_7745c5c3_Var14, templ_7745c5c3_Err = templ.JoinStringErrs(att.Filename)
+ if templ_7745c5c3_Err != nil {
+ return templ.Error{Err: templ_7745c5c3_Err, FileName: `internal/web/templates/message_detail.templ`, Line: 107, Col: 22}
+ }
+ _, templ_7745c5c3_Err = templ_7745c5c3_Buffer.WriteString(templ.EscapeString(templ_7745c5c3_Var14))
+ if templ_7745c5c3_Err != nil {
+ return templ_7745c5c3_Err
+ }
+ templ_7745c5c3_Err = templruntime.WriteString(templ_7745c5c3_Buffer, 27, " ")
+ if templ_7745c5c3_Err != nil {
+ return templ_7745c5c3_Err
+ }
+ } else {
+ var templ_7745c5c3_Var15 string
+ templ_7745c5c3_Var15, templ_7745c5c3_Err = templ.JoinStringErrs(att.Filename)
+ if templ_7745c5c3_Err != nil {
+ return templ.Error{Err: templ_7745c5c3_Err, FileName: `internal/web/templates/message_detail.templ`, Line: 110, Col: 21}
+ }
+ _, templ_7745c5c3_Err = templ_7745c5c3_Buffer.WriteString(templ.EscapeString(templ_7745c5c3_Var15))
+ if templ_7745c5c3_Err != nil {
+ return templ_7745c5c3_Err
+ }
+ templ_7745c5c3_Err = templruntime.WriteString(templ_7745c5c3_Buffer, 28, " ")
+ if templ_7745c5c3_Err != nil {
+ return templ_7745c5c3_Err
+ }
+ }
+ templ_7745c5c3_Err = templruntime.WriteString(templ_7745c5c3_Buffer, 29, "
(")
+ if templ_7745c5c3_Err != nil {
+ return templ_7745c5c3_Err
+ }
+ var templ_7745c5c3_Var16 string
+ templ_7745c5c3_Var16, templ_7745c5c3_Err = templ.JoinStringErrs(formatBytes(att.Size))
+ if templ_7745c5c3_Err != nil {
+ return templ.Error{Err: templ_7745c5c3_Err, FileName: `internal/web/templates/message_detail.templ`, Line: 112, Col: 64}
+ }
+ _, templ_7745c5c3_Err = templ_7745c5c3_Buffer.WriteString(templ.EscapeString(templ_7745c5c3_Var16))
+ if templ_7745c5c3_Err != nil {
+ return templ_7745c5c3_Err
+ }
+ templ_7745c5c3_Err = templruntime.WriteString(templ_7745c5c3_Buffer, 30, ")")
+ if templ_7745c5c3_Err != nil {
+ return templ_7745c5c3_Err
+ }
+ }
+ templ_7745c5c3_Err = templruntime.WriteString(templ_7745c5c3_Buffer, 31, "
")
+ if templ_7745c5c3_Err != nil {
+ return templ_7745c5c3_Err
+ }
+ }
+ if msg.ConversationID > 0 {
+ templ_7745c5c3_Err = templruntime.WriteString(templ_7745c5c3_Buffer, 32, "
")
+ if templ_7745c5c3_Err != nil {
+ return templ_7745c5c3_Err
+ }
+ }
+ templ_7745c5c3_Err = templruntime.WriteString(templ_7745c5c3_Buffer, 34, "
")
+ if templ_7745c5c3_Err != nil {
+ return templ_7745c5c3_Err
+ }
+ return nil
+ })
+}
+
+func messageBody(msg *query.MessageDetail) templ.Component {
+ return templruntime.GeneratedTemplate(func(templ_7745c5c3_Input templruntime.GeneratedComponentInput) (templ_7745c5c3_Err error) {
+ templ_7745c5c3_W, ctx := templ_7745c5c3_Input.Writer, templ_7745c5c3_Input.Context
+ if templ_7745c5c3_CtxErr := ctx.Err(); templ_7745c5c3_CtxErr != nil {
+ return templ_7745c5c3_CtxErr
+ }
+ templ_7745c5c3_Buffer, templ_7745c5c3_IsBuffer := templruntime.GetBuffer(templ_7745c5c3_W)
+ if !templ_7745c5c3_IsBuffer {
+ defer func() {
+ templ_7745c5c3_BufErr := templruntime.ReleaseBuffer(templ_7745c5c3_Buffer)
+ if templ_7745c5c3_Err == nil {
+ templ_7745c5c3_Err = templ_7745c5c3_BufErr
+ }
+ }()
+ }
+ ctx = templ.InitializeContext(ctx)
+ templ_7745c5c3_Var19 := templ.GetChildren(ctx)
+ if templ_7745c5c3_Var19 == nil {
+ templ_7745c5c3_Var19 = templ.NopComponent
+ }
+ ctx = templ.ClearChildren(ctx)
+ templ_7745c5c3_Err = templruntime.WriteString(templ_7745c5c3_Buffer, 36, "")
+ if templ_7745c5c3_Err != nil {
+ return templ_7745c5c3_Err
+ }
+ if msg.BodyText != "" {
+ templ_7745c5c3_Err = templruntime.WriteString(templ_7745c5c3_Buffer, 37, "
")
+ if templ_7745c5c3_Err != nil {
+ return templ_7745c5c3_Err
+ }
+ var templ_7745c5c3_Var20 string
+ templ_7745c5c3_Var20, templ_7745c5c3_Err = templ.JoinStringErrs(msg.BodyText)
+ if templ_7745c5c3_Err != nil {
+ return templ.Error{Err: templ_7745c5c3_Err, FileName: `internal/web/templates/message_detail.templ`, Line: 137, Col: 39}
+ }
+ _, templ_7745c5c3_Err = templ_7745c5c3_Buffer.WriteString(templ.EscapeString(templ_7745c5c3_Var20))
+ if templ_7745c5c3_Err != nil {
+ return templ_7745c5c3_Err
+ }
+ templ_7745c5c3_Err = templruntime.WriteString(templ_7745c5c3_Buffer, 38, "")
+ if templ_7745c5c3_Err != nil {
+ return templ_7745c5c3_Err
+ }
+ } else if msg.BodyHTML != "" {
+ templ_7745c5c3_Err = templruntime.WriteString(templ_7745c5c3_Buffer, 39, "
")
+ if templ_7745c5c3_Err != nil {
+ return templ_7745c5c3_Err
+ }
+ var templ_7745c5c3_Var21 string
+ templ_7745c5c3_Var21, templ_7745c5c3_Err = templ.JoinStringErrs(htmlToPlainText(msg.BodyHTML))
+ if templ_7745c5c3_Err != nil {
+ return templ.Error{Err: templ_7745c5c3_Err, FileName: `internal/web/templates/message_detail.templ`, Line: 139, Col: 56}
+ }
+ _, templ_7745c5c3_Err = templ_7745c5c3_Buffer.WriteString(templ.EscapeString(templ_7745c5c3_Var21))
+ if templ_7745c5c3_Err != nil {
+ return templ_7745c5c3_Err
+ }
+ templ_7745c5c3_Err = templruntime.WriteString(templ_7745c5c3_Buffer, 40, "")
+ if templ_7745c5c3_Err != nil {
+ return templ_7745c5c3_Err
+ }
+ } else {
+ templ_7745c5c3_Err = templruntime.WriteString(templ_7745c5c3_Buffer, 41, "
(No message content)
")
+ if templ_7745c5c3_Err != nil {
+ return templ_7745c5c3_Err
+ }
+ }
+ templ_7745c5c3_Err = templruntime.WriteString(templ_7745c5c3_Buffer, 42, "
")
+ if templ_7745c5c3_Err != nil {
+ return templ_7745c5c3_Err
+ }
+ return nil
+ })
+}
+
+var _ = templruntime.GeneratedTemplate
diff --git a/internal/web/templates/messages.templ b/internal/web/templates/messages.templ
new file mode 100644
index 00000000..f150ec2c
--- /dev/null
+++ b/internal/web/templates/messages.templ
@@ -0,0 +1,255 @@
+package templates
+
+import (
+ "fmt"
+ "net/url"
+ "strings"
+ "github.com/wesm/msgvault/internal/query"
+)
+
+type MessagesData struct {
+ Messages []query.MessageSummary
+ Page int
+ PageSize int
+ HasMore bool
+ SortField string
+ SortDir string
+ // Filter context
+ Filters map[string]string
+ AccountID string
+ Attachments bool
+ HideDeleted bool
+}
+
+func (d MessagesData) baseQuery() url.Values {
+ q := url.Values{}
+ q.Set("sort", d.SortField)
+ q.Set("dir", d.SortDir)
+ for k, v := range d.Filters {
+ q.Set(k, v)
+ }
+ if d.AccountID != "" {
+ q.Set("account", d.AccountID)
+ }
+ if d.Attachments {
+ q.Set("attachments", "1")
+ }
+ if d.HideDeleted {
+ q.Set("hide_deleted", "1")
+ }
+ return q
+}
+
+func (d MessagesData) sortURL(field string) string {
+ dir := "desc"
+ if d.SortField == field && d.SortDir == "desc" {
+ dir = "asc"
+ }
+ q := d.baseQuery()
+ q.Set("sort", field)
+ q.Set("dir", dir)
+ q.Del("page")
+ return "/messages?" + q.Encode()
+}
+
+func (d MessagesData) sortIndicator(field string) string {
+ if d.SortField != field {
+ return ""
+ }
+ if d.SortDir == "asc" {
+ return " ↑"
+ }
+ return " ↓"
+}
+
+func (d MessagesData) pageURL(page int) string {
+ q := d.baseQuery()
+ if page > 1 {
+ q.Set("page", fmt.Sprintf("%d", page))
+ } else {
+ q.Del("page")
+ }
+ return "/messages?" + q.Encode()
+}
+
+func (d MessagesData) filterSummary() string {
+ var parts []string
+ order := []struct{ key, label string }{
+ {"sender", "Sender"},
+ {"sender_name", "Name"},
+ {"recipient", "Recipient"},
+ {"recipient_name", "Recipient Name"},
+ {"domain", "Domain"},
+ {"label", "Label"},
+ {"time_period", "Period"},
+ }
+ for _, item := range order {
+ if v, ok := d.Filters[item.key]; ok {
+ if v == "" {
+ parts = append(parts, item.label+": (empty)")
+ } else {
+ parts = append(parts, item.label+": "+v)
+ }
+ }
+ }
+ if len(parts) == 0 {
+ return "All Messages"
+ }
+ return strings.Join(parts, " / ")
+}
+
+func (d MessagesData) browseBackURL() string {
+ q := url.Values{}
+ // Map filters back to browse view type
+ viewType := "senders"
+ filterOrder := []struct{ param, view string }{
+ {"label", "labels"},
+ {"domain", "domains"},
+ {"recipient_name", "recipient_names"},
+ {"recipient", "recipients"},
+ {"sender_name", "sender_names"},
+ {"sender", "senders"},
+ {"time_period", "time"},
+ }
+ for _, f := range filterOrder {
+ if _, ok := d.Filters[f.param]; ok {
+ viewType = f.view
+ break
+ }
+ }
+ q.Set("view", viewType)
+ if d.AccountID != "" {
+ q.Set("account", d.AccountID)
+ }
+ if d.Attachments {
+ q.Set("attachments", "1")
+ }
+ if d.HideDeleted {
+ q.Set("hide_deleted", "1")
+ }
+ return "/browse?" + q.Encode()
+}
+
+templ Messages(data MessagesData) {
+ @Layout("Messages", "messages") {
+
+ if len(data.Messages) == 0 {
+
+
No messages
+
No messages match the current filters.
+
+ } else {
+
+ Showing { formatCount(int64(len(data.Messages))) } messages
+ if data.Page > 1 {
+ (page { fmt.Sprintf("%d", data.Page) })
+ }
+
+ @MessageTable(data)
+ @pagination(data)
+ }
+ }
+}
+
+templ MessageTable(data MessagesData) {
+
+}
+
+templ pagination(data MessagesData) {
+ if data.Page > 1 || data.HasMore {
+
+ }
+}
+
+
+templ selectionBar() {
+
+ Select messages to stage for deletion
+
+
+
+
+
+}
+
+// messageRow renders a single message row, shared across all message tables.
+templ messageRow(msg query.MessageSummary) {
+
+ |
+ if msg.SourceMessageID != "" {
+
+ }
+ |
+
+ { formatMessageDate(msg.SentAt) }
+ |
+
+ if msg.FromName != "" {
+ { msg.FromName }
+ } else {
+ { msg.FromEmail }
+ }
+ |
+
+
+ if msg.Subject != "" {
+ { msg.Subject }
+ } else {
+ (no subject)
+ }
+
+ if msg.HasAttachments {
+
+ { fmt.Sprintf("[%d]", msg.AttachmentCount) }
+
+ }
+ |
+ { formatBytes(msg.SizeEstimate) } |
+
+}
diff --git a/internal/web/templates/messages_templ.go b/internal/web/templates/messages_templ.go
new file mode 100644
index 00000000..62588e2b
--- /dev/null
+++ b/internal/web/templates/messages_templ.go
@@ -0,0 +1,690 @@
+// Code generated by templ - DO NOT EDIT.
+
+// templ: version: v0.3.977
+package templates
+
+//lint:file-ignore SA4006 This context is only used if a nested component is present.
+
+import "github.com/a-h/templ"
+import templruntime "github.com/a-h/templ/runtime"
+
+import (
+ "fmt"
+ "github.com/wesm/msgvault/internal/query"
+ "net/url"
+ "strings"
+)
+
+type MessagesData struct {
+ Messages []query.MessageSummary
+ Page int
+ PageSize int
+ HasMore bool
+ SortField string
+ SortDir string
+ // Filter context
+ Filters map[string]string
+ AccountID string
+ Attachments bool
+ HideDeleted bool
+}
+
+func (d MessagesData) baseQuery() url.Values {
+ q := url.Values{}
+ q.Set("sort", d.SortField)
+ q.Set("dir", d.SortDir)
+ for k, v := range d.Filters {
+ q.Set(k, v)
+ }
+ if d.AccountID != "" {
+ q.Set("account", d.AccountID)
+ }
+ if d.Attachments {
+ q.Set("attachments", "1")
+ }
+ if d.HideDeleted {
+ q.Set("hide_deleted", "1")
+ }
+ return q
+}
+
+func (d MessagesData) sortURL(field string) string {
+ dir := "desc"
+ if d.SortField == field && d.SortDir == "desc" {
+ dir = "asc"
+ }
+ q := d.baseQuery()
+ q.Set("sort", field)
+ q.Set("dir", dir)
+ q.Del("page")
+ return "/messages?" + q.Encode()
+}
+
+func (d MessagesData) sortIndicator(field string) string {
+ if d.SortField != field {
+ return ""
+ }
+ if d.SortDir == "asc" {
+ return " ↑"
+ }
+ return " ↓"
+}
+
+func (d MessagesData) pageURL(page int) string {
+ q := d.baseQuery()
+ if page > 1 {
+ q.Set("page", fmt.Sprintf("%d", page))
+ } else {
+ q.Del("page")
+ }
+ return "/messages?" + q.Encode()
+}
+
+func (d MessagesData) filterSummary() string {
+ var parts []string
+ order := []struct{ key, label string }{
+ {"sender", "Sender"},
+ {"sender_name", "Name"},
+ {"recipient", "Recipient"},
+ {"recipient_name", "Recipient Name"},
+ {"domain", "Domain"},
+ {"label", "Label"},
+ {"time_period", "Period"},
+ }
+ for _, item := range order {
+ if v, ok := d.Filters[item.key]; ok {
+ if v == "" {
+ parts = append(parts, item.label+": (empty)")
+ } else {
+ parts = append(parts, item.label+": "+v)
+ }
+ }
+ }
+ if len(parts) == 0 {
+ return "All Messages"
+ }
+ return strings.Join(parts, " / ")
+}
+
+func (d MessagesData) browseBackURL() string {
+ q := url.Values{}
+ // Map filters back to browse view type
+ viewType := "senders"
+ filterOrder := []struct{ param, view string }{
+ {"label", "labels"},
+ {"domain", "domains"},
+ {"recipient_name", "recipient_names"},
+ {"recipient", "recipients"},
+ {"sender_name", "sender_names"},
+ {"sender", "senders"},
+ {"time_period", "time"},
+ }
+ for _, f := range filterOrder {
+ if _, ok := d.Filters[f.param]; ok {
+ viewType = f.view
+ break
+ }
+ }
+ q.Set("view", viewType)
+ if d.AccountID != "" {
+ q.Set("account", d.AccountID)
+ }
+ if d.Attachments {
+ q.Set("attachments", "1")
+ }
+ if d.HideDeleted {
+ q.Set("hide_deleted", "1")
+ }
+ return "/browse?" + q.Encode()
+}
+
+func Messages(data MessagesData) templ.Component {
+ return templruntime.GeneratedTemplate(func(templ_7745c5c3_Input templruntime.GeneratedComponentInput) (templ_7745c5c3_Err error) {
+ templ_7745c5c3_W, ctx := templ_7745c5c3_Input.Writer, templ_7745c5c3_Input.Context
+ if templ_7745c5c3_CtxErr := ctx.Err(); templ_7745c5c3_CtxErr != nil {
+ return templ_7745c5c3_CtxErr
+ }
+ templ_7745c5c3_Buffer, templ_7745c5c3_IsBuffer := templruntime.GetBuffer(templ_7745c5c3_W)
+ if !templ_7745c5c3_IsBuffer {
+ defer func() {
+ templ_7745c5c3_BufErr := templruntime.ReleaseBuffer(templ_7745c5c3_Buffer)
+ if templ_7745c5c3_Err == nil {
+ templ_7745c5c3_Err = templ_7745c5c3_BufErr
+ }
+ }()
+ }
+ ctx = templ.InitializeContext(ctx)
+ templ_7745c5c3_Var1 := templ.GetChildren(ctx)
+ if templ_7745c5c3_Var1 == nil {
+ templ_7745c5c3_Var1 = templ.NopComponent
+ }
+ ctx = templ.ClearChildren(ctx)
+ templ_7745c5c3_Var2 := templruntime.GeneratedTemplate(func(templ_7745c5c3_Input templruntime.GeneratedComponentInput) (templ_7745c5c3_Err error) {
+ templ_7745c5c3_W, ctx := templ_7745c5c3_Input.Writer, templ_7745c5c3_Input.Context
+ templ_7745c5c3_Buffer, templ_7745c5c3_IsBuffer := templruntime.GetBuffer(templ_7745c5c3_W)
+ if !templ_7745c5c3_IsBuffer {
+ defer func() {
+ templ_7745c5c3_BufErr := templruntime.ReleaseBuffer(templ_7745c5c3_Buffer)
+ if templ_7745c5c3_Err == nil {
+ templ_7745c5c3_Err = templ_7745c5c3_BufErr
+ }
+ }()
+ }
+ ctx = templ.InitializeContext(ctx)
+ templ_7745c5c3_Err = templruntime.WriteString(templ_7745c5c3_Buffer, 1, "")
+ if templ_7745c5c3_Err != nil {
+ return templ_7745c5c3_Err
+ }
+ if len(data.Messages) == 0 {
+ templ_7745c5c3_Err = templruntime.WriteString(templ_7745c5c3_Buffer, 4, "No messages
No messages match the current filters.
")
+ if templ_7745c5c3_Err != nil {
+ return templ_7745c5c3_Err
+ }
+ } else {
+ templ_7745c5c3_Err = templruntime.WriteString(templ_7745c5c3_Buffer, 5, "Showing ")
+ if templ_7745c5c3_Err != nil {
+ return templ_7745c5c3_Err
+ }
+ var templ_7745c5c3_Var5 string
+ templ_7745c5c3_Var5, templ_7745c5c3_Err = templ.JoinStringErrs(formatCount(int64(len(data.Messages))))
+ if templ_7745c5c3_Err != nil {
+ return templ.Error{Err: templ_7745c5c3_Err, FileName: `internal/web/templates/messages.templ`, Line: 147, Col: 52}
+ }
+ _, templ_7745c5c3_Err = templ_7745c5c3_Buffer.WriteString(templ.EscapeString(templ_7745c5c3_Var5))
+ if templ_7745c5c3_Err != nil {
+ return templ_7745c5c3_Err
+ }
+ templ_7745c5c3_Err = templruntime.WriteString(templ_7745c5c3_Buffer, 6, " messages ")
+ if templ_7745c5c3_Err != nil {
+ return templ_7745c5c3_Err
+ }
+ if data.Page > 1 {
+ templ_7745c5c3_Err = templruntime.WriteString(templ_7745c5c3_Buffer, 7, "(page ")
+ if templ_7745c5c3_Err != nil {
+ return templ_7745c5c3_Err
+ }
+ var templ_7745c5c3_Var6 string
+ templ_7745c5c3_Var6, templ_7745c5c3_Err = templ.JoinStringErrs(fmt.Sprintf("%d", data.Page))
+ if templ_7745c5c3_Err != nil {
+ return templ.Error{Err: templ_7745c5c3_Err, FileName: `internal/web/templates/messages.templ`, Line: 149, Col: 41}
+ }
+ _, templ_7745c5c3_Err = templ_7745c5c3_Buffer.WriteString(templ.EscapeString(templ_7745c5c3_Var6))
+ if templ_7745c5c3_Err != nil {
+ return templ_7745c5c3_Err
+ }
+ templ_7745c5c3_Err = templruntime.WriteString(templ_7745c5c3_Buffer, 8, ")")
+ if templ_7745c5c3_Err != nil {
+ return templ_7745c5c3_Err
+ }
+ }
+ templ_7745c5c3_Err = templruntime.WriteString(templ_7745c5c3_Buffer, 9, "
")
+ if templ_7745c5c3_Err != nil {
+ return templ_7745c5c3_Err
+ }
+ templ_7745c5c3_Err = MessageTable(data).Render(ctx, templ_7745c5c3_Buffer)
+ if templ_7745c5c3_Err != nil {
+ return templ_7745c5c3_Err
+ }
+ templ_7745c5c3_Err = templruntime.WriteString(templ_7745c5c3_Buffer, 10, " ")
+ if templ_7745c5c3_Err != nil {
+ return templ_7745c5c3_Err
+ }
+ templ_7745c5c3_Err = pagination(data).Render(ctx, templ_7745c5c3_Buffer)
+ if templ_7745c5c3_Err != nil {
+ return templ_7745c5c3_Err
+ }
+ }
+ return nil
+ })
+ templ_7745c5c3_Err = Layout("Messages", "messages").Render(templ.WithChildren(ctx, templ_7745c5c3_Var2), templ_7745c5c3_Buffer)
+ if templ_7745c5c3_Err != nil {
+ return templ_7745c5c3_Err
+ }
+ return nil
+ })
+}
+
+func MessageTable(data MessagesData) templ.Component {
+ return templruntime.GeneratedTemplate(func(templ_7745c5c3_Input templruntime.GeneratedComponentInput) (templ_7745c5c3_Err error) {
+ templ_7745c5c3_W, ctx := templ_7745c5c3_Input.Writer, templ_7745c5c3_Input.Context
+ if templ_7745c5c3_CtxErr := ctx.Err(); templ_7745c5c3_CtxErr != nil {
+ return templ_7745c5c3_CtxErr
+ }
+ templ_7745c5c3_Buffer, templ_7745c5c3_IsBuffer := templruntime.GetBuffer(templ_7745c5c3_W)
+ if !templ_7745c5c3_IsBuffer {
+ defer func() {
+ templ_7745c5c3_BufErr := templruntime.ReleaseBuffer(templ_7745c5c3_Buffer)
+ if templ_7745c5c3_Err == nil {
+ templ_7745c5c3_Err = templ_7745c5c3_BufErr
+ }
+ }()
+ }
+ ctx = templ.InitializeContext(ctx)
+ templ_7745c5c3_Var7 := templ.GetChildren(ctx)
+ if templ_7745c5c3_Var7 == nil {
+ templ_7745c5c3_Var7 = templ.NopComponent
+ }
+ ctx = templ.ClearChildren(ctx)
+ templ_7745c5c3_Err = templruntime.WriteString(templ_7745c5c3_Buffer, 11, "")
+ if templ_7745c5c3_Err != nil {
+ return templ_7745c5c3_Err
+ }
+ return nil
+ })
+}
+
+func pagination(data MessagesData) templ.Component {
+ return templruntime.GeneratedTemplate(func(templ_7745c5c3_Input templruntime.GeneratedComponentInput) (templ_7745c5c3_Err error) {
+ templ_7745c5c3_W, ctx := templ_7745c5c3_Input.Writer, templ_7745c5c3_Input.Context
+ if templ_7745c5c3_CtxErr := ctx.Err(); templ_7745c5c3_CtxErr != nil {
+ return templ_7745c5c3_CtxErr
+ }
+ templ_7745c5c3_Buffer, templ_7745c5c3_IsBuffer := templruntime.GetBuffer(templ_7745c5c3_W)
+ if !templ_7745c5c3_IsBuffer {
+ defer func() {
+ templ_7745c5c3_BufErr := templruntime.ReleaseBuffer(templ_7745c5c3_Buffer)
+ if templ_7745c5c3_Err == nil {
+ templ_7745c5c3_Err = templ_7745c5c3_BufErr
+ }
+ }()
+ }
+ ctx = templ.InitializeContext(ctx)
+ templ_7745c5c3_Var14 := templ.GetChildren(ctx)
+ if templ_7745c5c3_Var14 == nil {
+ templ_7745c5c3_Var14 = templ.NopComponent
+ }
+ ctx = templ.ClearChildren(ctx)
+ if data.Page > 1 || data.HasMore {
+ templ_7745c5c3_Err = templruntime.WriteString(templ_7745c5c3_Buffer, 20, "")
+ if templ_7745c5c3_Err != nil {
+ return templ_7745c5c3_Err
+ }
+ }
+ return nil
+ })
+}
+
+func selectionBar() templ.Component {
+ return templruntime.GeneratedTemplate(func(templ_7745c5c3_Input templruntime.GeneratedComponentInput) (templ_7745c5c3_Err error) {
+ templ_7745c5c3_W, ctx := templ_7745c5c3_Input.Writer, templ_7745c5c3_Input.Context
+ if templ_7745c5c3_CtxErr := ctx.Err(); templ_7745c5c3_CtxErr != nil {
+ return templ_7745c5c3_CtxErr
+ }
+ templ_7745c5c3_Buffer, templ_7745c5c3_IsBuffer := templruntime.GetBuffer(templ_7745c5c3_W)
+ if !templ_7745c5c3_IsBuffer {
+ defer func() {
+ templ_7745c5c3_BufErr := templruntime.ReleaseBuffer(templ_7745c5c3_Buffer)
+ if templ_7745c5c3_Err == nil {
+ templ_7745c5c3_Err = templ_7745c5c3_BufErr
+ }
+ }()
+ }
+ ctx = templ.InitializeContext(ctx)
+ templ_7745c5c3_Var18 := templ.GetChildren(ctx)
+ if templ_7745c5c3_Var18 == nil {
+ templ_7745c5c3_Var18 = templ.NopComponent
+ }
+ ctx = templ.ClearChildren(ctx)
+ templ_7745c5c3_Err = templruntime.WriteString(templ_7745c5c3_Buffer, 28, "Select messages to stage for deletion
")
+ if templ_7745c5c3_Err != nil {
+ return templ_7745c5c3_Err
+ }
+ return nil
+ })
+}
+
+// messageRow renders a single message row, shared across all message tables.
+func messageRow(msg query.MessageSummary) templ.Component {
+ return templruntime.GeneratedTemplate(func(templ_7745c5c3_Input templruntime.GeneratedComponentInput) (templ_7745c5c3_Err error) {
+ templ_7745c5c3_W, ctx := templ_7745c5c3_Input.Writer, templ_7745c5c3_Input.Context
+ if templ_7745c5c3_CtxErr := ctx.Err(); templ_7745c5c3_CtxErr != nil {
+ return templ_7745c5c3_CtxErr
+ }
+ templ_7745c5c3_Buffer, templ_7745c5c3_IsBuffer := templruntime.GetBuffer(templ_7745c5c3_W)
+ if !templ_7745c5c3_IsBuffer {
+ defer func() {
+ templ_7745c5c3_BufErr := templruntime.ReleaseBuffer(templ_7745c5c3_Buffer)
+ if templ_7745c5c3_Err == nil {
+ templ_7745c5c3_Err = templ_7745c5c3_BufErr
+ }
+ }()
+ }
+ ctx = templ.InitializeContext(ctx)
+ templ_7745c5c3_Var19 := templ.GetChildren(ctx)
+ if templ_7745c5c3_Var19 == nil {
+ templ_7745c5c3_Var19 = templ.NopComponent
+ }
+ ctx = templ.ClearChildren(ctx)
+ templ_7745c5c3_Err = templruntime.WriteString(templ_7745c5c3_Buffer, 29, "| ")
+ if templ_7745c5c3_Err != nil {
+ return templ_7745c5c3_Err
+ }
+ if msg.SourceMessageID != "" {
+ templ_7745c5c3_Err = templruntime.WriteString(templ_7745c5c3_Buffer, 30, "")
+ if templ_7745c5c3_Err != nil {
+ return templ_7745c5c3_Err
+ }
+ }
+ templ_7745c5c3_Err = templruntime.WriteString(templ_7745c5c3_Buffer, 32, " | ")
+ if templ_7745c5c3_Err != nil {
+ return templ_7745c5c3_Err
+ }
+ var templ_7745c5c3_Var21 string
+ templ_7745c5c3_Var21, templ_7745c5c3_Err = templ.JoinStringErrs(formatMessageDate(msg.SentAt))
+ if templ_7745c5c3_Err != nil {
+ return templ.Error{Err: templ_7745c5c3_Err, FileName: `internal/web/templates/messages.templ`, Line: 230, Col: 34}
+ }
+ _, templ_7745c5c3_Err = templ_7745c5c3_Buffer.WriteString(templ.EscapeString(templ_7745c5c3_Var21))
+ if templ_7745c5c3_Err != nil {
+ return templ_7745c5c3_Err
+ }
+ templ_7745c5c3_Err = templruntime.WriteString(templ_7745c5c3_Buffer, 33, " | ")
+ if templ_7745c5c3_Err != nil {
+ return templ_7745c5c3_Err
+ }
+ if msg.FromName != "" {
+ var templ_7745c5c3_Var22 string
+ templ_7745c5c3_Var22, templ_7745c5c3_Err = templ.JoinStringErrs(msg.FromName)
+ if templ_7745c5c3_Err != nil {
+ return templ.Error{Err: templ_7745c5c3_Err, FileName: `internal/web/templates/messages.templ`, Line: 234, Col: 18}
+ }
+ _, templ_7745c5c3_Err = templ_7745c5c3_Buffer.WriteString(templ.EscapeString(templ_7745c5c3_Var22))
+ if templ_7745c5c3_Err != nil {
+ return templ_7745c5c3_Err
+ }
+ } else {
+ var templ_7745c5c3_Var23 string
+ templ_7745c5c3_Var23, templ_7745c5c3_Err = templ.JoinStringErrs(msg.FromEmail)
+ if templ_7745c5c3_Err != nil {
+ return templ.Error{Err: templ_7745c5c3_Err, FileName: `internal/web/templates/messages.templ`, Line: 236, Col: 19}
+ }
+ _, templ_7745c5c3_Err = templ_7745c5c3_Buffer.WriteString(templ.EscapeString(templ_7745c5c3_Var23))
+ if templ_7745c5c3_Err != nil {
+ return templ_7745c5c3_Err
+ }
+ }
+ templ_7745c5c3_Err = templruntime.WriteString(templ_7745c5c3_Buffer, 34, " | ")
+ if templ_7745c5c3_Err != nil {
+ return templ_7745c5c3_Err
+ }
+ if msg.Subject != "" {
+ var templ_7745c5c3_Var25 string
+ templ_7745c5c3_Var25, templ_7745c5c3_Err = templ.JoinStringErrs(msg.Subject)
+ if templ_7745c5c3_Err != nil {
+ return templ.Error{Err: templ_7745c5c3_Err, FileName: `internal/web/templates/messages.templ`, Line: 242, Col: 18}
+ }
+ _, templ_7745c5c3_Err = templ_7745c5c3_Buffer.WriteString(templ.EscapeString(templ_7745c5c3_Var25))
+ if templ_7745c5c3_Err != nil {
+ return templ_7745c5c3_Err
+ }
+ } else {
+ templ_7745c5c3_Err = templruntime.WriteString(templ_7745c5c3_Buffer, 36, "(no subject)")
+ if templ_7745c5c3_Err != nil {
+ return templ_7745c5c3_Err
+ }
+ }
+ templ_7745c5c3_Err = templruntime.WriteString(templ_7745c5c3_Buffer, 37, " ")
+ if templ_7745c5c3_Err != nil {
+ return templ_7745c5c3_Err
+ }
+ if msg.HasAttachments {
+ templ_7745c5c3_Err = templruntime.WriteString(templ_7745c5c3_Buffer, 38, "")
+ if templ_7745c5c3_Err != nil {
+ return templ_7745c5c3_Err
+ }
+ var templ_7745c5c3_Var27 string
+ templ_7745c5c3_Var27, templ_7745c5c3_Err = templ.JoinStringErrs(fmt.Sprintf("[%d]", msg.AttachmentCount))
+ if templ_7745c5c3_Err != nil {
+ return templ.Error{Err: templ_7745c5c3_Err, FileName: `internal/web/templates/messages.templ`, Line: 249, Col: 47}
+ }
+ _, templ_7745c5c3_Err = templ_7745c5c3_Buffer.WriteString(templ.EscapeString(templ_7745c5c3_Var27))
+ if templ_7745c5c3_Err != nil {
+ return templ_7745c5c3_Err
+ }
+ templ_7745c5c3_Err = templruntime.WriteString(templ_7745c5c3_Buffer, 40, "")
+ if templ_7745c5c3_Err != nil {
+ return templ_7745c5c3_Err
+ }
+ }
+ templ_7745c5c3_Err = templruntime.WriteString(templ_7745c5c3_Buffer, 41, " | ")
+ if templ_7745c5c3_Err != nil {
+ return templ_7745c5c3_Err
+ }
+ var templ_7745c5c3_Var28 string
+ templ_7745c5c3_Var28, templ_7745c5c3_Err = templ.JoinStringErrs(formatBytes(msg.SizeEstimate))
+ if templ_7745c5c3_Err != nil {
+ return templ.Error{Err: templ_7745c5c3_Err, FileName: `internal/web/templates/messages.templ`, Line: 253, Col: 49}
+ }
+ _, templ_7745c5c3_Err = templ_7745c5c3_Buffer.WriteString(templ.EscapeString(templ_7745c5c3_Var28))
+ if templ_7745c5c3_Err != nil {
+ return templ_7745c5c3_Err
+ }
+ templ_7745c5c3_Err = templruntime.WriteString(templ_7745c5c3_Buffer, 42, " |
")
+ if templ_7745c5c3_Err != nil {
+ return templ_7745c5c3_Err
+ }
+ return nil
+ })
+}
+
+var _ = templruntime.GeneratedTemplate
diff --git a/internal/web/templates/search.templ b/internal/web/templates/search.templ
new file mode 100644
index 00000000..043c8cce
--- /dev/null
+++ b/internal/web/templates/search.templ
@@ -0,0 +1,240 @@
+package templates
+
+import (
+ "fmt"
+ "net/url"
+ "github.com/wesm/msgvault/internal/query"
+)
+
+type SearchData struct {
+ Query string
+ Mode string // "fast" or "deep"
+ Messages []query.MessageSummary
+ Page int
+ PageSize int
+ HasMore bool
+ Stats *query.TotalStats
+ HideDeleted bool
+ Attachments bool
+ SortField string
+ SortDir string
+}
+
+func (d SearchData) baseQuery() url.Values {
+ q := url.Values{}
+ q.Set("q", d.Query)
+ q.Set("mode", d.Mode)
+ if d.SortField != "" && d.SortField != "date" {
+ q.Set("sort", d.SortField)
+ }
+ if d.SortDir != "" && d.SortDir != "desc" {
+ q.Set("dir", d.SortDir)
+ }
+ if d.HideDeleted {
+ q.Set("hide_deleted", "1")
+ }
+ if d.Attachments {
+ q.Set("attachments", "1")
+ }
+ return q
+}
+
+func (d SearchData) searchURL(page int) string {
+ q := d.baseQuery()
+ if page > 1 {
+ q.Set("page", fmt.Sprintf("%d", page))
+ }
+ return "/search?" + q.Encode()
+}
+
+func (d SearchData) modeToggleURL() string {
+ q := d.baseQuery()
+ if d.Mode == "fast" {
+ q.Set("mode", "deep")
+ } else {
+ q.Set("mode", "fast")
+ }
+ q.Del("page")
+ return "/search?" + q.Encode()
+}
+
+func (d SearchData) filterToggleURL(key string, currentlyOn bool) string {
+ q := d.baseQuery()
+ if currentlyOn {
+ q.Del(key)
+ } else {
+ q.Set(key, "1")
+ }
+ q.Del("page")
+ return "/search?" + q.Encode()
+}
+
+func (d SearchData) searchSortURL(field string) string {
+ dir := "desc"
+ if d.SortField == field && d.SortDir == "desc" {
+ dir = "asc"
+ }
+ q := d.baseQuery()
+ q.Set("sort", field)
+ q.Set("dir", dir)
+ q.Del("page")
+ return "/search?" + q.Encode()
+}
+
+func (d SearchData) searchSortIndicator(field string) string {
+ if d.SortField != field {
+ return ""
+ }
+ if d.SortDir == "asc" {
+ return " ↑"
+ }
+ return " ↓"
+}
+
+templ Search(data SearchData) {
+ @Layout("Search", "search") {
+ if data.Stats != nil {
+ @StatsBar(data.Stats)
+ }
+
+ if data.Query == "" {
+
+
Search your archive
+
Use Gmail-like syntax: from:user@example.com, subject:invoice, has:attachment, before:2024/01/01
+
+ } else if len(data.Messages) == 0 {
+
+
No results
+
+ No messages match "{ data.Query }"
+ if data.Mode == "fast" {
+ — try deep search to include message bodies
+ }
+
+
+ } else {
+
+ Showing { formatCount(int64(len(data.Messages))) } results
+ if data.Page > 1 {
+ (page { fmt.Sprintf("%d", data.Page) })
+ }
+
+ @searchMessageTable(data)
+ @searchPagination(data)
+ }
+ }
+}
+
+templ searchMessageTable(data SearchData) {
+
+}
+
+templ searchPagination(data SearchData) {
+ if data.Page > 1 || data.HasMore {
+
+ }
+}
diff --git a/internal/web/templates/search_templ.go b/internal/web/templates/search_templ.go
new file mode 100644
index 00000000..240d3dd3
--- /dev/null
+++ b/internal/web/templates/search_templ.go
@@ -0,0 +1,661 @@
+// Code generated by templ - DO NOT EDIT.
+
+// templ: version: v0.3.977
+package templates
+
+//lint:file-ignore SA4006 This context is only used if a nested component is present.
+
+import "github.com/a-h/templ"
+import templruntime "github.com/a-h/templ/runtime"
+
+import (
+ "fmt"
+ "github.com/wesm/msgvault/internal/query"
+ "net/url"
+)
+
+type SearchData struct {
+ Query string
+ Mode string // "fast" or "deep"
+ Messages []query.MessageSummary
+ Page int
+ PageSize int
+ HasMore bool
+ Stats *query.TotalStats
+ HideDeleted bool
+ Attachments bool
+ SortField string
+ SortDir string
+}
+
+func (d SearchData) baseQuery() url.Values {
+ q := url.Values{}
+ q.Set("q", d.Query)
+ q.Set("mode", d.Mode)
+ if d.SortField != "" && d.SortField != "date" {
+ q.Set("sort", d.SortField)
+ }
+ if d.SortDir != "" && d.SortDir != "desc" {
+ q.Set("dir", d.SortDir)
+ }
+ if d.HideDeleted {
+ q.Set("hide_deleted", "1")
+ }
+ if d.Attachments {
+ q.Set("attachments", "1")
+ }
+ return q
+}
+
+func (d SearchData) searchURL(page int) string {
+ q := d.baseQuery()
+ if page > 1 {
+ q.Set("page", fmt.Sprintf("%d", page))
+ }
+ return "/search?" + q.Encode()
+}
+
+func (d SearchData) modeToggleURL() string {
+ q := d.baseQuery()
+ if d.Mode == "fast" {
+ q.Set("mode", "deep")
+ } else {
+ q.Set("mode", "fast")
+ }
+ q.Del("page")
+ return "/search?" + q.Encode()
+}
+
+func (d SearchData) filterToggleURL(key string, currentlyOn bool) string {
+ q := d.baseQuery()
+ if currentlyOn {
+ q.Del(key)
+ } else {
+ q.Set(key, "1")
+ }
+ q.Del("page")
+ return "/search?" + q.Encode()
+}
+
+func (d SearchData) searchSortURL(field string) string {
+ dir := "desc"
+ if d.SortField == field && d.SortDir == "desc" {
+ dir = "asc"
+ }
+ q := d.baseQuery()
+ q.Set("sort", field)
+ q.Set("dir", dir)
+ q.Del("page")
+ return "/search?" + q.Encode()
+}
+
+func (d SearchData) searchSortIndicator(field string) string {
+ if d.SortField != field {
+ return ""
+ }
+ if d.SortDir == "asc" {
+ return " ↑"
+ }
+ return " ↓"
+}
+
+func Search(data SearchData) templ.Component {
+ return templruntime.GeneratedTemplate(func(templ_7745c5c3_Input templruntime.GeneratedComponentInput) (templ_7745c5c3_Err error) {
+ templ_7745c5c3_W, ctx := templ_7745c5c3_Input.Writer, templ_7745c5c3_Input.Context
+ if templ_7745c5c3_CtxErr := ctx.Err(); templ_7745c5c3_CtxErr != nil {
+ return templ_7745c5c3_CtxErr
+ }
+ templ_7745c5c3_Buffer, templ_7745c5c3_IsBuffer := templruntime.GetBuffer(templ_7745c5c3_W)
+ if !templ_7745c5c3_IsBuffer {
+ defer func() {
+ templ_7745c5c3_BufErr := templruntime.ReleaseBuffer(templ_7745c5c3_Buffer)
+ if templ_7745c5c3_Err == nil {
+ templ_7745c5c3_Err = templ_7745c5c3_BufErr
+ }
+ }()
+ }
+ ctx = templ.InitializeContext(ctx)
+ templ_7745c5c3_Var1 := templ.GetChildren(ctx)
+ if templ_7745c5c3_Var1 == nil {
+ templ_7745c5c3_Var1 = templ.NopComponent
+ }
+ ctx = templ.ClearChildren(ctx)
+ templ_7745c5c3_Var2 := templruntime.GeneratedTemplate(func(templ_7745c5c3_Input templruntime.GeneratedComponentInput) (templ_7745c5c3_Err error) {
+ templ_7745c5c3_W, ctx := templ_7745c5c3_Input.Writer, templ_7745c5c3_Input.Context
+ templ_7745c5c3_Buffer, templ_7745c5c3_IsBuffer := templruntime.GetBuffer(templ_7745c5c3_W)
+ if !templ_7745c5c3_IsBuffer {
+ defer func() {
+ templ_7745c5c3_BufErr := templruntime.ReleaseBuffer(templ_7745c5c3_Buffer)
+ if templ_7745c5c3_Err == nil {
+ templ_7745c5c3_Err = templ_7745c5c3_BufErr
+ }
+ }()
+ }
+ ctx = templ.InitializeContext(ctx)
+ if data.Stats != nil {
+ templ_7745c5c3_Err = StatsBar(data.Stats).Render(ctx, templ_7745c5c3_Buffer)
+ if templ_7745c5c3_Err != nil {
+ return templ_7745c5c3_Err
+ }
+ }
+ templ_7745c5c3_Err = templruntime.WriteString(templ_7745c5c3_Buffer, 1, " ")
+ if templ_7745c5c3_Err != nil {
+ return templ_7745c5c3_Err
+ }
+ if data.Query == "" {
+ templ_7745c5c3_Err = templruntime.WriteString(templ_7745c5c3_Buffer, 24, "Search your archive
Use Gmail-like syntax: from:user@example.com, subject:invoice, has:attachment, before:2024/01/01
")
+ if templ_7745c5c3_Err != nil {
+ return templ_7745c5c3_Err
+ }
+ } else if len(data.Messages) == 0 {
+ templ_7745c5c3_Err = templruntime.WriteString(templ_7745c5c3_Buffer, 25, "No results
No messages match \"")
+ if templ_7745c5c3_Err != nil {
+ return templ_7745c5c3_Err
+ }
+ var templ_7745c5c3_Var11 string
+ templ_7745c5c3_Var11, templ_7745c5c3_Err = templ.JoinStringErrs(data.Query)
+ if templ_7745c5c3_Err != nil {
+ return templ.Error{Err: templ_7745c5c3_Err, FileName: `internal/web/templates/search.templ`, Line: 166, Col: 36}
+ }
+ _, templ_7745c5c3_Err = templ_7745c5c3_Buffer.WriteString(templ.EscapeString(templ_7745c5c3_Var11))
+ if templ_7745c5c3_Err != nil {
+ return templ_7745c5c3_Err
+ }
+ templ_7745c5c3_Err = templruntime.WriteString(templ_7745c5c3_Buffer, 26, "\" ")
+ if templ_7745c5c3_Err != nil {
+ return templ_7745c5c3_Err
+ }
+ if data.Mode == "fast" {
+ templ_7745c5c3_Err = templruntime.WriteString(templ_7745c5c3_Buffer, 27, "— try deep search to include message bodies")
+ if templ_7745c5c3_Err != nil {
+ return templ_7745c5c3_Err
+ }
+ }
+ templ_7745c5c3_Err = templruntime.WriteString(templ_7745c5c3_Buffer, 29, "
")
+ if templ_7745c5c3_Err != nil {
+ return templ_7745c5c3_Err
+ }
+ } else {
+ templ_7745c5c3_Err = templruntime.WriteString(templ_7745c5c3_Buffer, 30, "Showing ")
+ if templ_7745c5c3_Err != nil {
+ return templ_7745c5c3_Err
+ }
+ var templ_7745c5c3_Var13 string
+ templ_7745c5c3_Var13, templ_7745c5c3_Err = templ.JoinStringErrs(formatCount(int64(len(data.Messages))))
+ if templ_7745c5c3_Err != nil {
+ return templ.Error{Err: templ_7745c5c3_Err, FileName: `internal/web/templates/search.templ`, Line: 174, Col: 52}
+ }
+ _, templ_7745c5c3_Err = templ_7745c5c3_Buffer.WriteString(templ.EscapeString(templ_7745c5c3_Var13))
+ if templ_7745c5c3_Err != nil {
+ return templ_7745c5c3_Err
+ }
+ templ_7745c5c3_Err = templruntime.WriteString(templ_7745c5c3_Buffer, 31, " results ")
+ if templ_7745c5c3_Err != nil {
+ return templ_7745c5c3_Err
+ }
+ if data.Page > 1 {
+ templ_7745c5c3_Err = templruntime.WriteString(templ_7745c5c3_Buffer, 32, "(page ")
+ if templ_7745c5c3_Err != nil {
+ return templ_7745c5c3_Err
+ }
+ var templ_7745c5c3_Var14 string
+ templ_7745c5c3_Var14, templ_7745c5c3_Err = templ.JoinStringErrs(fmt.Sprintf("%d", data.Page))
+ if templ_7745c5c3_Err != nil {
+ return templ.Error{Err: templ_7745c5c3_Err, FileName: `internal/web/templates/search.templ`, Line: 176, Col: 41}
+ }
+ _, templ_7745c5c3_Err = templ_7745c5c3_Buffer.WriteString(templ.EscapeString(templ_7745c5c3_Var14))
+ if templ_7745c5c3_Err != nil {
+ return templ_7745c5c3_Err
+ }
+ templ_7745c5c3_Err = templruntime.WriteString(templ_7745c5c3_Buffer, 33, ")")
+ if templ_7745c5c3_Err != nil {
+ return templ_7745c5c3_Err
+ }
+ }
+ templ_7745c5c3_Err = templruntime.WriteString(templ_7745c5c3_Buffer, 34, "
")
+ if templ_7745c5c3_Err != nil {
+ return templ_7745c5c3_Err
+ }
+ templ_7745c5c3_Err = searchMessageTable(data).Render(ctx, templ_7745c5c3_Buffer)
+ if templ_7745c5c3_Err != nil {
+ return templ_7745c5c3_Err
+ }
+ templ_7745c5c3_Err = templruntime.WriteString(templ_7745c5c3_Buffer, 35, " ")
+ if templ_7745c5c3_Err != nil {
+ return templ_7745c5c3_Err
+ }
+ templ_7745c5c3_Err = searchPagination(data).Render(ctx, templ_7745c5c3_Buffer)
+ if templ_7745c5c3_Err != nil {
+ return templ_7745c5c3_Err
+ }
+ }
+ return nil
+ })
+ templ_7745c5c3_Err = Layout("Search", "search").Render(templ.WithChildren(ctx, templ_7745c5c3_Var2), templ_7745c5c3_Buffer)
+ if templ_7745c5c3_Err != nil {
+ return templ_7745c5c3_Err
+ }
+ return nil
+ })
+}
+
+func searchMessageTable(data SearchData) templ.Component {
+ return templruntime.GeneratedTemplate(func(templ_7745c5c3_Input templruntime.GeneratedComponentInput) (templ_7745c5c3_Err error) {
+ templ_7745c5c3_W, ctx := templ_7745c5c3_Input.Writer, templ_7745c5c3_Input.Context
+ if templ_7745c5c3_CtxErr := ctx.Err(); templ_7745c5c3_CtxErr != nil {
+ return templ_7745c5c3_CtxErr
+ }
+ templ_7745c5c3_Buffer, templ_7745c5c3_IsBuffer := templruntime.GetBuffer(templ_7745c5c3_W)
+ if !templ_7745c5c3_IsBuffer {
+ defer func() {
+ templ_7745c5c3_BufErr := templruntime.ReleaseBuffer(templ_7745c5c3_Buffer)
+ if templ_7745c5c3_Err == nil {
+ templ_7745c5c3_Err = templ_7745c5c3_BufErr
+ }
+ }()
+ }
+ ctx = templ.InitializeContext(ctx)
+ templ_7745c5c3_Var15 := templ.GetChildren(ctx)
+ if templ_7745c5c3_Var15 == nil {
+ templ_7745c5c3_Var15 = templ.NopComponent
+ }
+ ctx = templ.ClearChildren(ctx)
+ templ_7745c5c3_Err = templruntime.WriteString(templ_7745c5c3_Buffer, 36, "")
+ if templ_7745c5c3_Err != nil {
+ return templ_7745c5c3_Err
+ }
+ return nil
+ })
+}
+
+func searchPagination(data SearchData) templ.Component {
+ return templruntime.GeneratedTemplate(func(templ_7745c5c3_Input templruntime.GeneratedComponentInput) (templ_7745c5c3_Err error) {
+ templ_7745c5c3_W, ctx := templ_7745c5c3_Input.Writer, templ_7745c5c3_Input.Context
+ if templ_7745c5c3_CtxErr := ctx.Err(); templ_7745c5c3_CtxErr != nil {
+ return templ_7745c5c3_CtxErr
+ }
+ templ_7745c5c3_Buffer, templ_7745c5c3_IsBuffer := templruntime.GetBuffer(templ_7745c5c3_W)
+ if !templ_7745c5c3_IsBuffer {
+ defer func() {
+ templ_7745c5c3_BufErr := templruntime.ReleaseBuffer(templ_7745c5c3_Buffer)
+ if templ_7745c5c3_Err == nil {
+ templ_7745c5c3_Err = templ_7745c5c3_BufErr
+ }
+ }()
+ }
+ ctx = templ.InitializeContext(ctx)
+ templ_7745c5c3_Var22 := templ.GetChildren(ctx)
+ if templ_7745c5c3_Var22 == nil {
+ templ_7745c5c3_Var22 = templ.NopComponent
+ }
+ ctx = templ.ClearChildren(ctx)
+ if data.Page > 1 || data.HasMore {
+ templ_7745c5c3_Err = templruntime.WriteString(templ_7745c5c3_Buffer, 48, "")
+ if templ_7745c5c3_Err != nil {
+ return templ_7745c5c3_Err
+ }
+ }
+ return nil
+ })
+}
+
+var _ = templruntime.GeneratedTemplate