feat: sidebar nav (default), group member management, per-customer ticketing identity mapping
- Navigation: new per-user setting (default "side") renders the page links as a left sidebar while keeping theme/bell/avatar/logout in the top-right; falls back to a top bar under 760px or when set to "top". Profile selector added. - Group conversations: record a creatorId; the creator gets a "Manage members" button to add/remove participants (new GET/POST/DELETE members endpoints, creator-only). Existing groups backfilled. - Customer ticketing: admin can map each assigned consultant to their username in that customer's ticketing system. Per-customer mapping takes precedence over the consultant's global ticketingIdentities during sync. Co-Authored-By: Claude Fable 5 <noreply@anthropic.com>
This commit is contained in:
@@ -303,6 +303,52 @@ table, .badge, input[type=number] { font-variant-numeric: tabular-nums; }
|
||||
}
|
||||
.topnav .right { display: flex; align-items: center; gap: 8px; }
|
||||
|
||||
/* ---- sidebar navigation (default; user can switch to top bar) ---- */
|
||||
body[data-nav=side] .topnav {
|
||||
justify-content: flex-end; /* only the right controls remain in the flow */
|
||||
padding-left: 232px;
|
||||
}
|
||||
body[data-nav=side] .topnav .brand {
|
||||
position: fixed; top: 14px; left: 18px; z-index: 41;
|
||||
font-size: 1.05rem;
|
||||
}
|
||||
body[data-nav=side] .topnav .links {
|
||||
position: fixed; top: 0; left: 0; bottom: 0;
|
||||
width: 214px;
|
||||
flex: none; flex-direction: column; flex-wrap: nowrap; align-items: stretch;
|
||||
gap: 4px;
|
||||
background-color: var(--surface2);
|
||||
background-image: var(--dither);
|
||||
border-right: 2px solid var(--border);
|
||||
padding: 58px 12px 18px;
|
||||
overflow-y: auto;
|
||||
z-index: 40;
|
||||
}
|
||||
:root[data-theme=light] body[data-nav=side] .topnav .links { background-image: none; }
|
||||
body[data-nav=side] .topnav .links a {
|
||||
border-bottom: none;
|
||||
border-left: 3px solid transparent;
|
||||
border-radius: 0;
|
||||
padding: 9px 12px;
|
||||
}
|
||||
body[data-nav=side] .topnav .links a[aria-current=page] {
|
||||
border-left-color: var(--accent);
|
||||
background: var(--surface);
|
||||
}
|
||||
body[data-nav=side] main.container { margin-left: 214px; }
|
||||
@media (max-width: 760px) {
|
||||
/* collapse the sidebar back into a normal top bar on small screens */
|
||||
body[data-nav=side] .topnav { padding-left: 20px; justify-content: flex-start; flex-wrap: wrap; }
|
||||
body[data-nav=side] .topnav .brand { position: static; }
|
||||
body[data-nav=side] .topnav .links {
|
||||
position: static; width: auto; flex: 1; flex-direction: row; flex-wrap: wrap;
|
||||
background: none; border-right: none; padding: 0; overflow: visible;
|
||||
}
|
||||
body[data-nav=side] .topnav .links a { border-left: none; border-bottom: 2px solid transparent; }
|
||||
body[data-nav=side] .topnav .links a[aria-current=page] { border-bottom-color: var(--accent); background: none; }
|
||||
body[data-nav=side] main.container { margin-left: auto; }
|
||||
}
|
||||
|
||||
/* ---- cards: printed stock with hard Y2K shadows ---- */
|
||||
.card {
|
||||
background: var(--surface);
|
||||
|
||||
@@ -57,6 +57,36 @@ async function loadConsultants() {
|
||||
}));
|
||||
}
|
||||
|
||||
// Render one username input per selected consultant, prefilled from the
|
||||
// customer's saved per-consultant ticketing identity map.
|
||||
function renderIdentities() {
|
||||
const wrap = document.getElementById('c-identities-wrap');
|
||||
const host = document.getElementById('c-identities');
|
||||
const selectedIds = [...document.getElementById('c-consultants').selectedOptions].map((o) => o.value);
|
||||
const existing = (editingCustomer && editingCustomer.ticketing.consultantIdentities) || {};
|
||||
if (!selectedIds.length) { wrap.hidden = true; host.replaceChildren(); return; }
|
||||
wrap.hidden = false;
|
||||
host.replaceChildren(...selectedIds.map((id) => {
|
||||
const u = consultants.find((c) => c.id === id) || { name: id, email: '' };
|
||||
const row = document.createElement('div');
|
||||
row.className = 'spread';
|
||||
row.style.marginTop = '6px';
|
||||
const label = document.createElement('span');
|
||||
label.className = 'muted';
|
||||
label.style.flex = '1';
|
||||
label.textContent = u.name;
|
||||
const inp = document.createElement('input');
|
||||
inp.type = 'text';
|
||||
inp.dataset.identity = id;
|
||||
inp.placeholder = 'username in ticketing system';
|
||||
inp.value = existing[id] || '';
|
||||
inp.style.flex = '1';
|
||||
row.append(label, inp);
|
||||
return row;
|
||||
}));
|
||||
}
|
||||
document.getElementById('c-consultants').addEventListener('change', renderIdentities);
|
||||
|
||||
function openCustomerDialog(customer) {
|
||||
editingCustomer = customer || null;
|
||||
document.getElementById('customer-dialog-title').textContent =
|
||||
@@ -73,6 +103,7 @@ function openCustomerDialog(customer) {
|
||||
[...sel.options].forEach((o) => {
|
||||
o.selected = customer ? customer.consultantIds.includes(o.value) : false;
|
||||
});
|
||||
renderIdentities();
|
||||
document.getElementById('c-test-result').textContent =
|
||||
customer && customer.ticketing.hasCredentials ? 'Stored credentials are kept unless you enter new ones.' : '';
|
||||
dlg.showModal();
|
||||
@@ -114,6 +145,11 @@ document.getElementById('customer-form').addEventListener('submit', async (e) =>
|
||||
defaultBudget: parseFloat(val('c-budget')) || 0,
|
||||
consultantIds: [...document.getElementById('c-consultants').selectedOptions].map((o) => o.value),
|
||||
};
|
||||
const identities = {};
|
||||
document.querySelectorAll('#c-identities input[data-identity]').forEach((inp) => {
|
||||
if (inp.value.trim()) identities[inp.dataset.identity] = inp.value.trim();
|
||||
});
|
||||
body.consultantIdentities = identities;
|
||||
if (!editingCustomer || credsEntered(type)) body.credentials = credsFromForm(type);
|
||||
try {
|
||||
if (editingCustomer) {
|
||||
|
||||
@@ -62,6 +62,8 @@ async function openConversation(c) {
|
||||
current = c;
|
||||
oldestCursor = '';
|
||||
document.getElementById('chat-title').textContent = c.title;
|
||||
const mbtn = document.getElementById('chat-members');
|
||||
mbtn.hidden = !(c.kind !== 'dm' && c.creatorId && c.creatorId === meId);
|
||||
form.hidden = false;
|
||||
msgHost.replaceChildren();
|
||||
renderConvList();
|
||||
@@ -297,6 +299,73 @@ document.getElementById('newconv-form').addEventListener('submit', async (e) =>
|
||||
} catch (err) { toast(err.message, 'err'); }
|
||||
});
|
||||
|
||||
// ---- manage members (group creator only) ----
|
||||
const membersDlg = document.getElementById('members-dialog');
|
||||
let mmMemberIds = new Set();
|
||||
|
||||
async function renderMembers() {
|
||||
const res = await api('GET', `/api/v1/conversations/${current.id}/members`);
|
||||
mmMemberIds = new Set(res.members.map((u) => u.id));
|
||||
const host = document.getElementById('mm-list');
|
||||
host.replaceChildren(...res.members.map((u) => {
|
||||
const li = document.createElement('li');
|
||||
li.className = 'conv-item';
|
||||
li.style.justifyContent = 'space-between';
|
||||
li.innerHTML = `<span>${esc(u.name)} ${u.isCreator ? '<span class="badge accent">creator</span>' : ''}
|
||||
<br><span class="muted">${esc(u.email)}</span></span>`;
|
||||
if (res.canManage && !u.isCreator) {
|
||||
const rm = document.createElement('button');
|
||||
rm.className = 'btn small';
|
||||
rm.textContent = 'Remove';
|
||||
rm.addEventListener('click', async () => {
|
||||
try {
|
||||
await api('DELETE', `/api/v1/conversations/${current.id}/members/${u.id}`);
|
||||
await renderMembers();
|
||||
} catch (e) { toast(e.message, 'err'); }
|
||||
});
|
||||
li.appendChild(rm);
|
||||
}
|
||||
return li;
|
||||
}));
|
||||
document.getElementById('mm-add-wrap').hidden = !res.canManage;
|
||||
}
|
||||
|
||||
let mmTimer = null;
|
||||
async function mmSearch() {
|
||||
const q = document.getElementById('mm-search').value.trim();
|
||||
const res = await api('GET', `/api/v1/users?q=${encodeURIComponent(q)}`);
|
||||
const host = document.getElementById('mm-results');
|
||||
const rows = res.users.filter((u) => !mmMemberIds.has(u.id)).map((u) => {
|
||||
const b = document.createElement('button');
|
||||
b.type = 'button';
|
||||
b.className = 'nc-row';
|
||||
b.textContent = `${u.name} — ${u.email}`;
|
||||
b.addEventListener('click', async () => {
|
||||
try {
|
||||
await api('POST', `/api/v1/conversations/${current.id}/members`, { userId: u.id });
|
||||
document.getElementById('mm-search').value = '';
|
||||
host.replaceChildren();
|
||||
await renderMembers();
|
||||
} catch (e) { toast(e.message, 'err'); }
|
||||
});
|
||||
return b;
|
||||
});
|
||||
host.replaceChildren(...rows);
|
||||
}
|
||||
|
||||
document.getElementById('chat-members').addEventListener('click', async () => {
|
||||
if (!current) return;
|
||||
document.getElementById('mm-search').value = '';
|
||||
document.getElementById('mm-results').replaceChildren();
|
||||
try { await renderMembers(); membersDlg.showModal(); }
|
||||
catch (e) { toast(e.message, 'err'); }
|
||||
});
|
||||
document.getElementById('mm-close').addEventListener('click', () => membersDlg.close());
|
||||
document.getElementById('mm-search').addEventListener('input', () => {
|
||||
clearTimeout(mmTimer);
|
||||
mmTimer = setTimeout(mmSearch, 250);
|
||||
});
|
||||
|
||||
(async () => {
|
||||
try {
|
||||
const me = await api('GET', '/api/v1/auth/me');
|
||||
|
||||
@@ -56,12 +56,16 @@ form.addEventListener('submit', async (e) => {
|
||||
links,
|
||||
},
|
||||
extra,
|
||||
settings: { theme: document.getElementById('p-theme').value },
|
||||
settings: {
|
||||
theme: document.getElementById('p-theme').value,
|
||||
navLayout: document.getElementById('p-nav').value,
|
||||
},
|
||||
});
|
||||
version = res.user.version;
|
||||
const theme = res.user.settings.theme;
|
||||
document.documentElement.dataset.theme = theme;
|
||||
try { localStorage.setItem('theme', theme); } catch (err) { /* ignore */ }
|
||||
document.body.dataset.nav = res.user.settings.navLayout || 'side';
|
||||
okBox.textContent = 'Profile saved.';
|
||||
} catch (err) {
|
||||
if (err.status === 409) {
|
||||
|
||||
@@ -84,6 +84,11 @@
|
||||
<select id="c-consultants" multiple size="4"></select>
|
||||
<p class="hint">Hold Ctrl/Cmd to select multiple. At least one is needed for syncing.</p>
|
||||
</div>
|
||||
<div class="field" id="c-identities-wrap" hidden>
|
||||
<label>Ticketing account mapping</label>
|
||||
<p class="hint">Username in this project's ticketing system for each assigned consultant. Overrides the consultant's global identity. Leave blank to use their global one.</p>
|
||||
<div id="c-identities"></div>
|
||||
</div>
|
||||
<div class="spread">
|
||||
<span id="c-test-result" class="hint" aria-live="polite"></span>
|
||||
<span>
|
||||
|
||||
@@ -7,7 +7,7 @@
|
||||
<link rel="stylesheet" href="/static/css/app.css">
|
||||
<script src="/static/js/theme.js"></script>
|
||||
</head>
|
||||
<body {{if .User}}data-logged-in="1"{{end}}>
|
||||
<body {{if .User}}data-logged-in="1"{{end}} data-nav="{{if .User}}{{.NavLayout}}{{else}}top{{end}}">
|
||||
<nav class="topnav">
|
||||
<a class="brand" href="/">Bounty Board</a>
|
||||
<div class="links">
|
||||
|
||||
@@ -11,7 +11,10 @@
|
||||
<section class="chat-main card">
|
||||
<div class="spread">
|
||||
<h2 id="chat-title">Select a conversation</h2>
|
||||
<span class="muted" id="typing-indicator" aria-live="polite"></span>
|
||||
<span style="display:flex;align-items:center;gap:10px">
|
||||
<span class="muted" id="typing-indicator" aria-live="polite"></span>
|
||||
<button class="btn small" id="chat-members" type="button" hidden>Manage members</button>
|
||||
</span>
|
||||
</div>
|
||||
<div id="chat-messages" class="chat-messages" aria-live="polite"></div>
|
||||
<form id="chat-form" hidden>
|
||||
@@ -66,6 +69,21 @@
|
||||
</form>
|
||||
</dialog>
|
||||
|
||||
<dialog id="members-dialog">
|
||||
<div class="stack" style="min-width:420px">
|
||||
<div class="spread">
|
||||
<h2 style="margin:0">Group members</h2>
|
||||
<button class="btn small" type="button" id="mm-close">Close</button>
|
||||
</div>
|
||||
<ul id="mm-list" class="conv-list"></ul>
|
||||
<div class="field" id="mm-add-wrap">
|
||||
<label for="mm-search">Add someone</label>
|
||||
<input type="search" id="mm-search" placeholder="Search people…">
|
||||
<div id="mm-results" role="listbox" aria-label="Search results"></div>
|
||||
</div>
|
||||
</div>
|
||||
</dialog>
|
||||
|
||||
<dialog id="lightbox" class="lightbox">
|
||||
<img id="lightbox-img" alt="">
|
||||
</dialog>
|
||||
|
||||
@@ -65,6 +65,13 @@
|
||||
<option value="sunset" {{if eq .User.Settings.Theme "sunset"}}selected{{end}}>Sunset (vaporwave)</option>
|
||||
</select>
|
||||
</div>
|
||||
<div class="field">
|
||||
<label for="p-nav">Navigation</label>
|
||||
<select id="p-nav">
|
||||
<option value="side" {{if ne .User.Settings.NavLayout "top"}}selected{{end}}>Sidebar (left)</option>
|
||||
<option value="top" {{if eq .User.Settings.NavLayout "top"}}selected{{end}}>Top bar</option>
|
||||
</select>
|
||||
</div>
|
||||
|
||||
<button class="btn primary" type="submit">Save profile</button>
|
||||
</form>
|
||||
|
||||
Reference in New Issue
Block a user