feat: @-mention autocomplete; consultant bounty-board access; clickable atomization tasks
- @-mentions: typing "@" in task comments, the messages composer and the chat
widget now opens a user picker (new shared mention.js) that inserts "@Name ".
Backed by the existing /api/v1/users search; menus de-dupe per field.
- Consultants can now open the bounty board (read-only): nav link + page/API
access extended to consultants, board visibility resolves their assigned
customers, and cards show "Open" instead of claim actions for non-developers.
- Atomization board task titles (roots and subtasks) link to /tasks/{id} so
consultants can reach the detail view (comments, assignment, timeline).
Co-Authored-By: Claude Fable 5 <noreply@anthropic.com>
This commit is contained in:
+27
-10
@@ -19,7 +19,9 @@ import (
|
|||||||
|
|
||||||
func (s *Server) routesBoard(mux *http.ServeMux) {
|
func (s *Server) routesBoard(mux *http.ServeMux) {
|
||||||
dev := func(h http.HandlerFunc) http.Handler { return s.authedRole("developer", h) }
|
dev := func(h http.HandlerFunc) http.Handler { return s.authedRole("developer", h) }
|
||||||
mux.Handle("GET /api/v1/board", s.requireAuth(s.requireRole("developer", http.HandlerFunc(s.handleBoard))))
|
// Developers and consultants can both read the board (consultants browse to
|
||||||
|
// open task detail; claim/start/etc. below stay developer-only).
|
||||||
|
mux.Handle("GET /api/v1/board", s.requireAuth(http.HandlerFunc(s.handleBoard)))
|
||||||
mux.Handle("GET /api/v1/my-tasks", s.requireAuth(s.requireRole("developer", http.HandlerFunc(s.handleMyTasks))))
|
mux.Handle("GET /api/v1/my-tasks", s.requireAuth(s.requireRole("developer", http.HandlerFunc(s.handleMyTasks))))
|
||||||
mux.Handle("POST /api/v1/tasks/{id}/claim", dev(s.handleClaim))
|
mux.Handle("POST /api/v1/tasks/{id}/claim", dev(s.handleClaim))
|
||||||
mux.Handle("POST /api/v1/tasks/{id}/claim/withdraw", dev(s.handleWithdrawClaim))
|
mux.Handle("POST /api/v1/tasks/{id}/claim/withdraw", dev(s.handleWithdrawClaim))
|
||||||
@@ -39,17 +41,10 @@ func (s *Server) routesBoard(mux *http.ServeMux) {
|
|||||||
// assigned to a customer manage its tasks, so a task published by any of
|
// assigned to a customer manage its tasks, so a task published by any of
|
||||||
// them belongs to the same board.
|
// them belongs to the same board.
|
||||||
func (s *Server) boardCustomers(r *http.Request) ([]domain.Customer, error) {
|
func (s *Server) boardCustomers(r *http.Request) ([]domain.Customer, error) {
|
||||||
consultants, err := s.store.PoolConsultantIDs(r.Context(), CurrentUser(r.Context()).ID)
|
me := CurrentUser(r.Context())
|
||||||
if err != nil {
|
|
||||||
return nil, err
|
|
||||||
}
|
|
||||||
seen := map[string]bool{}
|
seen := map[string]bool{}
|
||||||
out := []domain.Customer{}
|
out := []domain.Customer{}
|
||||||
for _, cid := range consultants {
|
add := func(customers []domain.Customer) {
|
||||||
customers, err := s.store.ListCustomers(r.Context(), cid, false)
|
|
||||||
if err != nil {
|
|
||||||
return nil, err
|
|
||||||
}
|
|
||||||
for _, c := range customers {
|
for _, c := range customers {
|
||||||
if !seen[c.ID] {
|
if !seen[c.ID] {
|
||||||
seen[c.ID] = true
|
seen[c.ID] = true
|
||||||
@@ -57,6 +52,28 @@ func (s *Server) boardCustomers(r *http.Request) ([]domain.Customer, error) {
|
|||||||
}
|
}
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
// Developers: every customer that has a consultant who pooled them.
|
||||||
|
if me.Roles.Developer {
|
||||||
|
consultants, err := s.store.PoolConsultantIDs(r.Context(), me.ID)
|
||||||
|
if err != nil {
|
||||||
|
return nil, err
|
||||||
|
}
|
||||||
|
for _, cid := range consultants {
|
||||||
|
customers, err := s.store.ListCustomers(r.Context(), cid, false)
|
||||||
|
if err != nil {
|
||||||
|
return nil, err
|
||||||
|
}
|
||||||
|
add(customers)
|
||||||
|
}
|
||||||
|
}
|
||||||
|
// Consultants: every customer they are assigned to.
|
||||||
|
if me.Roles.Consultant {
|
||||||
|
customers, err := s.store.ListCustomers(r.Context(), me.ID, false)
|
||||||
|
if err != nil {
|
||||||
|
return nil, err
|
||||||
|
}
|
||||||
|
add(customers)
|
||||||
|
}
|
||||||
return out, nil
|
return out, nil
|
||||||
}
|
}
|
||||||
|
|
||||||
|
|||||||
@@ -238,7 +238,10 @@ func (s *Server) routesWeb(mux *http.ServeMux) {
|
|||||||
}
|
}
|
||||||
isDev := func(u *domain.User) bool { return u.Roles.Developer }
|
isDev := func(u *domain.User) bool { return u.Roles.Developer }
|
||||||
isCons := func(u *domain.User) bool { return u.Roles.Consultant }
|
isCons := func(u *domain.User) bool { return u.Roles.Consultant }
|
||||||
rolePage("/board", "board.html", "Bounty Board", "board", "/static/js/board.js", isDev)
|
isDevOrCons := func(u *domain.User) bool { return u.Roles.Developer || u.Roles.Consultant }
|
||||||
|
// Consultants can browse the board too (read-only: they open task detail to
|
||||||
|
// review comments/assignments); only developers see claim actions.
|
||||||
|
rolePage("/board", "board.html", "Bounty Board", "board", "/static/js/board.js", isDevOrCons)
|
||||||
rolePage("/my-tasks", "my-tasks.html", "My Tasks", "my-tasks", "/static/js/my-tasks.js", isDev)
|
rolePage("/my-tasks", "my-tasks.html", "My Tasks", "my-tasks", "/static/js/my-tasks.js", isDev)
|
||||||
rolePage("/consultant/reviews", "reviews.html", "Review Queue", "reviews", "/static/js/reviews.js", isCons)
|
rolePage("/consultant/reviews", "reviews.html", "Review Queue", "reviews", "/static/js/reviews.js", isCons)
|
||||||
rolePage("/consultant/pool", "pool.html", "Developer Pool", "pool", "/static/js/pool.js", isCons)
|
rolePage("/consultant/pool", "pool.html", "Developer Pool", "pool", "/static/js/pool.js", isCons)
|
||||||
|
|||||||
@@ -766,6 +766,23 @@ input[type=range] { width: 100%; accent-color: var(--accent); }
|
|||||||
border-color: color-mix(in srgb, var(--accent) 70%, black);
|
border-color: color-mix(in srgb, var(--accent) 70%, black);
|
||||||
}
|
}
|
||||||
|
|
||||||
|
/* ---- @-mention autocomplete menu ---- */
|
||||||
|
.mention-menu {
|
||||||
|
position: absolute; z-index: 80;
|
||||||
|
background: var(--surface); border: var(--hairline);
|
||||||
|
border-radius: var(--radius);
|
||||||
|
box-shadow: var(--ink-shadow);
|
||||||
|
max-height: 220px; overflow-y: auto;
|
||||||
|
padding: 4px;
|
||||||
|
}
|
||||||
|
.mention-row {
|
||||||
|
display: block; width: 100%; text-align: left;
|
||||||
|
font: inherit; font-size: 0.9rem;
|
||||||
|
background: none; border: none; color: var(--text);
|
||||||
|
padding: 7px 10px; cursor: pointer; border-radius: var(--radius);
|
||||||
|
}
|
||||||
|
.mention-row.active, .mention-row:hover { background: var(--surface2); }
|
||||||
|
|
||||||
/* ---- dialogs: paper slips ---- */
|
/* ---- dialogs: paper slips ---- */
|
||||||
dialog {
|
dialog {
|
||||||
background: var(--surface); color: var(--text);
|
background: var(--surface); color: var(--text);
|
||||||
|
|||||||
@@ -82,7 +82,7 @@ function renderRoot(root) {
|
|||||||
? '<span class="badge" style="color:var(--err)" title="No longer returned by the upstream system">orphaned</span>' : '';
|
? '<span class="badge" style="color:var(--err)" title="No longer returned by the upstream system">orphaned</span>' : '';
|
||||||
card.innerHTML = `
|
card.innerHTML = `
|
||||||
<div class="spread">
|
<div class="spread">
|
||||||
<h3>${icon} ${esc(root.title)} ${statusBadge(root)} ${orphan}</h3>
|
<h3>${icon} <a href="/tasks/${root.id}">${esc(root.title)}</a> ${statusBadge(root)} ${orphan}</h3>
|
||||||
<span class="muted">${esc(cust ? cust.name : '')} ${root.external ? '· ' + esc(root.external.key) : ''}</span>
|
<span class="muted">${esc(cust ? cust.name : '')} ${root.external ? '· ' + esc(root.external.key) : ''}</span>
|
||||||
</div>
|
</div>
|
||||||
<p class="muted">${esc((root.description || '').slice(0, 240))}</p>
|
<p class="muted">${esc((root.description || '').slice(0, 240))}</p>
|
||||||
@@ -135,7 +135,7 @@ function renderChildren(parent, kids, depth) {
|
|||||||
<div class="spread">
|
<div class="spread">
|
||||||
<span>
|
<span>
|
||||||
${k.status === 'atomized' ? `<input type="checkbox" data-sel aria-label="Select ${esc(k.title)} for publishing" ${selected.has(k.id) ? 'checked' : ''}>` : ''}
|
${k.status === 'atomized' ? `<input type="checkbox" data-sel aria-label="Select ${esc(k.title)} for publishing" ${selected.has(k.id) ? 'checked' : ''}>` : ''}
|
||||||
<strong>${esc(k.title)}</strong> ${ext} ${statusBadge(k)}
|
<a href="/tasks/${k.id}"><strong>${esc(k.title)}</strong></a> ${ext} ${statusBadge(k)}
|
||||||
</span>
|
</span>
|
||||||
<span>bounty <strong data-bounty>${k.bounty}</strong></span>
|
<span>bounty <strong data-bounty>${k.bounty}</strong></span>
|
||||||
</div>
|
</div>
|
||||||
|
|||||||
@@ -4,6 +4,7 @@ import { subscribe, onPollFallback } from '/static/js/ws.js';
|
|||||||
const grid = document.getElementById('board-grid');
|
const grid = document.getElementById('board-grid');
|
||||||
const errorBox = document.getElementById('error');
|
const errorBox = document.getElementById('error');
|
||||||
let meId = '';
|
let meId = '';
|
||||||
|
let isDeveloper = false;
|
||||||
let tasks = [];
|
let tasks = [];
|
||||||
const customerNames = new Map();
|
const customerNames = new Map();
|
||||||
|
|
||||||
@@ -69,7 +70,9 @@ function renderCard(t) {
|
|||||||
${myClaim ? '<span class="badge" style="color:var(--ok)">requested by you</span>' : ''}
|
${myClaim ? '<span class="badge" style="color:var(--ok)">requested by you</span>' : ''}
|
||||||
${others ? `<span class="badge">${others} other request(s)</span>` : ''}
|
${others ? `<span class="badge">${others} other request(s)</span>` : ''}
|
||||||
</span>
|
</span>
|
||||||
${myClaim
|
${!isDeveloper
|
||||||
|
? '<a class="btn small" href="/tasks/' + t.id + '">Open</a>'
|
||||||
|
: myClaim
|
||||||
? '<button class="btn small" data-act="withdraw">Withdraw</button>'
|
? '<button class="btn small" data-act="withdraw">Withdraw</button>'
|
||||||
: '<button class="btn primary small" data-act="claim">Request assignment</button>'}
|
: '<button class="btn primary small" data-act="claim">Request assignment</button>'}
|
||||||
</div>`;
|
</div>`;
|
||||||
@@ -121,6 +124,7 @@ async function restoreFilters() {
|
|||||||
try {
|
try {
|
||||||
const me = await api('GET', '/api/v1/auth/me');
|
const me = await api('GET', '/api/v1/auth/me');
|
||||||
meId = me.user.id;
|
meId = me.user.id;
|
||||||
|
isDeveloper = !!(me.user.roles && me.user.roles.developer);
|
||||||
const saved = me.user.extra && me.user.extra.savedBoardFilters;
|
const saved = me.user.extra && me.user.extra.savedBoardFilters;
|
||||||
if (saved) {
|
if (saved) {
|
||||||
const f = JSON.parse(saved);
|
const f = JSON.parse(saved);
|
||||||
|
|||||||
@@ -3,6 +3,7 @@
|
|||||||
// plain-text composer. Reuses the conversations API + live WS channel.
|
// plain-text composer. Reuses the conversations API + live WS channel.
|
||||||
import { api } from '/static/js/api.js';
|
import { api } from '/static/js/api.js';
|
||||||
import { subscribe, onPollFallback } from '/static/js/ws.js';
|
import { subscribe, onPollFallback } from '/static/js/ws.js';
|
||||||
|
import { attachMentions } from '/static/js/mention.js';
|
||||||
|
|
||||||
if (document.body.dataset.loggedIn === '1' && location.pathname !== '/messages') {
|
if (document.body.dataset.loggedIn === '1' && location.pathname !== '/messages') {
|
||||||
let meId = '';
|
let meId = '';
|
||||||
@@ -117,9 +118,12 @@ if (document.body.dataset.loggedIn === '1' && location.pathname !== '/messages')
|
|||||||
} catch (e) { input.value = text; }
|
} catch (e) { input.value = text; }
|
||||||
}
|
}
|
||||||
panel.querySelector('#cw-send').addEventListener('click', send);
|
panel.querySelector('#cw-send').addEventListener('click', send);
|
||||||
panel.querySelector('#cw-input').addEventListener('keydown', (e) => {
|
const cwInput = panel.querySelector('#cw-input');
|
||||||
|
cwInput.addEventListener('keydown', (e) => {
|
||||||
|
if (cwInput.dataset.mentionActive === '1') return; // mention picker owns Enter
|
||||||
if (e.key === 'Enter') { e.preventDefault(); send(); }
|
if (e.key === 'Enter') { e.preventDefault(); send(); }
|
||||||
});
|
});
|
||||||
|
attachMentions(cwInput);
|
||||||
|
|
||||||
fab.addEventListener('click', async () => {
|
fab.addEventListener('click', async () => {
|
||||||
open = !open;
|
open = !open;
|
||||||
|
|||||||
@@ -0,0 +1,129 @@
|
|||||||
|
// @-mention autocomplete. Attaches to a <textarea>/<input> or a
|
||||||
|
// contenteditable element: typing "@" + letters shows a user picker; choosing
|
||||||
|
// one inserts "@Name ". While the menu is open the host element has
|
||||||
|
// data-mention-active="1" so the host's own Enter handler can defer to us.
|
||||||
|
import { api } from '/static/js/api.js';
|
||||||
|
|
||||||
|
const TOKEN_RE = /(?:^|\s)@([\w.\-]{0,30})$/;
|
||||||
|
|
||||||
|
export function attachMentions(el) {
|
||||||
|
const isCE = el.isContentEditable;
|
||||||
|
// drop any stale menu left over from a previous attach on the same field
|
||||||
|
// (task/comment views recreate their composer on every re-render)
|
||||||
|
if (el.id) {
|
||||||
|
document.querySelectorAll(`.mention-menu[data-mention-for="${el.id}"]`).forEach((m) => m.remove());
|
||||||
|
}
|
||||||
|
const menu = document.createElement('div');
|
||||||
|
menu.className = 'mention-menu';
|
||||||
|
menu.hidden = true;
|
||||||
|
if (el.id) menu.dataset.mentionFor = el.id;
|
||||||
|
document.body.appendChild(menu);
|
||||||
|
|
||||||
|
let items = [];
|
||||||
|
let active = 0;
|
||||||
|
let seq = 0;
|
||||||
|
|
||||||
|
function close() {
|
||||||
|
menu.hidden = true;
|
||||||
|
items = [];
|
||||||
|
delete el.dataset.mentionActive;
|
||||||
|
}
|
||||||
|
|
||||||
|
function context() {
|
||||||
|
if (isCE) {
|
||||||
|
const sel = window.getSelection();
|
||||||
|
if (!sel || !sel.rangeCount) return null;
|
||||||
|
const range = sel.getRangeAt(0);
|
||||||
|
const node = range.startContainer;
|
||||||
|
if (node.nodeType !== 3) return null;
|
||||||
|
const before = node.textContent.slice(0, range.startOffset);
|
||||||
|
const m = before.match(TOKEN_RE);
|
||||||
|
if (!m) return null;
|
||||||
|
return { query: m[1], node, end: range.startOffset, start: range.startOffset - m[1].length - 1 };
|
||||||
|
}
|
||||||
|
const pos = el.selectionStart;
|
||||||
|
const m = el.value.slice(0, pos).match(TOKEN_RE);
|
||||||
|
if (!m) return null;
|
||||||
|
return { query: m[1], end: pos, start: pos - m[1].length - 1 };
|
||||||
|
}
|
||||||
|
|
||||||
|
async function update() {
|
||||||
|
const ctx = context();
|
||||||
|
if (!ctx) { close(); return; }
|
||||||
|
const mySeq = ++seq;
|
||||||
|
let users;
|
||||||
|
try { users = (await api('GET', `/api/v1/users?q=${encodeURIComponent(ctx.query)}`)).users; }
|
||||||
|
catch (e) { return; }
|
||||||
|
if (mySeq !== seq) return; // a newer keystroke superseded this fetch
|
||||||
|
const ctx2 = context();
|
||||||
|
if (!ctx2) { close(); return; }
|
||||||
|
items = users.slice(0, 6);
|
||||||
|
if (!items.length) { close(); return; }
|
||||||
|
active = 0;
|
||||||
|
render(ctx2);
|
||||||
|
}
|
||||||
|
|
||||||
|
function render(ctx) {
|
||||||
|
menu.replaceChildren(...items.map((u, i) => {
|
||||||
|
const row = document.createElement('button');
|
||||||
|
row.type = 'button';
|
||||||
|
row.className = 'mention-row' + (i === active ? ' active' : '');
|
||||||
|
row.innerHTML = `<strong>${escapeHtml(u.name)}</strong> <span class="muted">${escapeHtml(u.email)}</span>`;
|
||||||
|
row.addEventListener('mousedown', (e) => { e.preventDefault(); pick(u, ctx); });
|
||||||
|
return row;
|
||||||
|
}));
|
||||||
|
position();
|
||||||
|
menu.hidden = false;
|
||||||
|
el.dataset.mentionActive = '1';
|
||||||
|
}
|
||||||
|
|
||||||
|
function position() {
|
||||||
|
const rect = el.getBoundingClientRect();
|
||||||
|
menu.style.left = `${rect.left + window.scrollX}px`;
|
||||||
|
menu.style.top = `${rect.bottom + window.scrollY + 2}px`;
|
||||||
|
menu.style.minWidth = `${Math.min(Math.max(rect.width, 220), 360)}px`;
|
||||||
|
}
|
||||||
|
|
||||||
|
function highlight() {
|
||||||
|
[...menu.children].forEach((c, i) => c.classList.toggle('active', i === active));
|
||||||
|
}
|
||||||
|
|
||||||
|
function pick(u, ctx) {
|
||||||
|
const text = '@' + u.name + ' ';
|
||||||
|
if (isCE) {
|
||||||
|
const node = ctx.node;
|
||||||
|
const t = node.textContent;
|
||||||
|
node.textContent = t.slice(0, ctx.start) + text + t.slice(ctx.end);
|
||||||
|
const sel = window.getSelection();
|
||||||
|
const range = document.createRange();
|
||||||
|
range.setStart(node, ctx.start + text.length);
|
||||||
|
range.collapse(true);
|
||||||
|
sel.removeAllRanges();
|
||||||
|
sel.addRange(range);
|
||||||
|
} else {
|
||||||
|
const v = el.value;
|
||||||
|
el.value = v.slice(0, ctx.start) + text + v.slice(ctx.end);
|
||||||
|
const caret = ctx.start + text.length;
|
||||||
|
el.setSelectionRange(caret, caret);
|
||||||
|
}
|
||||||
|
close();
|
||||||
|
el.focus();
|
||||||
|
el.dispatchEvent(new Event('input', { bubbles: true }));
|
||||||
|
}
|
||||||
|
|
||||||
|
el.addEventListener('input', update);
|
||||||
|
el.addEventListener('keydown', (e) => {
|
||||||
|
if (menu.hidden) return;
|
||||||
|
if (e.key === 'ArrowDown') { e.preventDefault(); active = (active + 1) % items.length; highlight(); }
|
||||||
|
else if (e.key === 'ArrowUp') { e.preventDefault(); active = (active - 1 + items.length) % items.length; highlight(); }
|
||||||
|
else if (e.key === 'Enter' || e.key === 'Tab') { e.preventDefault(); e.stopPropagation(); pick(items[active], context()); }
|
||||||
|
else if (e.key === 'Escape') { e.preventDefault(); close(); }
|
||||||
|
});
|
||||||
|
el.addEventListener('blur', () => setTimeout(close, 150));
|
||||||
|
}
|
||||||
|
|
||||||
|
function escapeHtml(s) {
|
||||||
|
return String(s ?? '').replace(/[&<>"']/g, (c) => ({
|
||||||
|
'&': '&', '<': '<', '>': '>', '"': '"', "'": ''',
|
||||||
|
}[c]));
|
||||||
|
}
|
||||||
@@ -1,5 +1,6 @@
|
|||||||
import { api, toast } from '/static/js/api.js';
|
import { api, toast } from '/static/js/api.js';
|
||||||
import { subscribe, send as wsSend, onPollFallback } from '/static/js/ws.js';
|
import { subscribe, send as wsSend, onPollFallback } from '/static/js/ws.js';
|
||||||
|
import { attachMentions } from '/static/js/mention.js';
|
||||||
|
|
||||||
const errorBox = document.getElementById('error');
|
const errorBox = document.getElementById('error');
|
||||||
const convList = document.getElementById('conv-list');
|
const convList = document.getElementById('conv-list');
|
||||||
@@ -146,11 +147,13 @@ document.querySelectorAll('[data-cmd]').forEach((btn) => {
|
|||||||
});
|
});
|
||||||
|
|
||||||
composer.addEventListener('keydown', (e) => {
|
composer.addEventListener('keydown', (e) => {
|
||||||
|
if (composer.dataset.mentionActive === '1') return; // mention picker owns Enter
|
||||||
if (e.key === 'Enter' && !e.shiftKey) {
|
if (e.key === 'Enter' && !e.shiftKey) {
|
||||||
e.preventDefault();
|
e.preventDefault();
|
||||||
form.requestSubmit();
|
form.requestSubmit();
|
||||||
}
|
}
|
||||||
});
|
});
|
||||||
|
attachMentions(composer);
|
||||||
|
|
||||||
let typingTimer = null;
|
let typingTimer = null;
|
||||||
composer.addEventListener('input', () => {
|
composer.addEventListener('input', () => {
|
||||||
|
|||||||
@@ -1,5 +1,6 @@
|
|||||||
import { api, toast } from '/static/js/api.js';
|
import { api, toast } from '/static/js/api.js';
|
||||||
import { subscribe } from '/static/js/ws.js';
|
import { subscribe } from '/static/js/ws.js';
|
||||||
|
import { attachMentions } from '/static/js/mention.js';
|
||||||
|
|
||||||
const root = document.getElementById('task-root');
|
const root = document.getElementById('task-root');
|
||||||
const errorBox = document.getElementById('error');
|
const errorBox = document.getElementById('error');
|
||||||
@@ -188,6 +189,7 @@ function wireHandlers() {
|
|||||||
load();
|
load();
|
||||||
} catch (err) { toast(err.message, 'err'); }
|
} catch (err) { toast(err.message, 'err'); }
|
||||||
});
|
});
|
||||||
|
attachMentions(document.getElementById('comment-body'));
|
||||||
const timeForm = document.getElementById('time-form');
|
const timeForm = document.getElementById('time-form');
|
||||||
if (timeForm) {
|
if (timeForm) {
|
||||||
timeForm.addEventListener('submit', async (e) => {
|
timeForm.addEventListener('submit', async (e) => {
|
||||||
|
|||||||
@@ -15,6 +15,8 @@
|
|||||||
{{if .User.Roles.Developer}}
|
{{if .User.Roles.Developer}}
|
||||||
<a href="/board" {{if eq .Active "board"}}aria-current="page"{{end}}>Bounty Board</a>
|
<a href="/board" {{if eq .Active "board"}}aria-current="page"{{end}}>Bounty Board</a>
|
||||||
<a href="/my-tasks" {{if eq .Active "my-tasks"}}aria-current="page"{{end}}>My Tasks</a>
|
<a href="/my-tasks" {{if eq .Active "my-tasks"}}aria-current="page"{{end}}>My Tasks</a>
|
||||||
|
{{else if .User.Roles.Consultant}}
|
||||||
|
<a href="/board" {{if eq .Active "board"}}aria-current="page"{{end}}>Bounty Board</a>
|
||||||
{{end}}
|
{{end}}
|
||||||
{{if .User.Roles.Consultant}}
|
{{if .User.Roles.Consultant}}
|
||||||
<a href="/consultant/board" {{if eq .Active "atomization"}}aria-current="page"{{end}}>Atomization</a>
|
<a href="/consultant/board" {{if eq .Active "atomization"}}aria-current="page"{{end}}>Atomization</a>
|
||||||
|
|||||||
Reference in New Issue
Block a user