Compare commits
17 Commits
| Author | SHA1 | Date | |
|---|---|---|---|
| 0dcc0bc823 | |||
| c4f666209c | |||
| 9a5e302a26 | |||
| 88fa5d19c8 | |||
| c65aeb4bac | |||
| 46e2099542 | |||
| 35168b9b83 | |||
| 0676f53280 | |||
| f54c557e70 | |||
| b232b4b3d3 | |||
| 70cb664246 | |||
| b4e919502f | |||
| df08682bed | |||
| 420f045033 | |||
| 9bacb8092d | |||
| e8beb7d4f3 | |||
| 0d28f69320 |
@@ -3,3 +3,8 @@ bin/
|
|||||||
backups/
|
backups/
|
||||||
*.test
|
*.test
|
||||||
coverage.out
|
coverage.out
|
||||||
|
|
||||||
|
# Docker volume data (bind-mounted under the repo; never committed)
|
||||||
|
/.volumes/
|
||||||
|
.env.bak*
|
||||||
|
.env.local
|
||||||
|
|||||||
@@ -25,7 +25,7 @@ test-contract:
|
|||||||
$(GO) test -tags=contract -count=1 -timeout=8m ./internal/contract/
|
$(GO) test -tags=contract -count=1 -timeout=8m ./internal/contract/
|
||||||
|
|
||||||
lint:
|
lint:
|
||||||
@unformatted=$$(gofmt -l .); if [ -n "$$unformatted" ]; then \
|
@unformatted=$$(gofmt -l $$(git ls-files '*.go')); if [ -n "$$unformatted" ]; then \
|
||||||
echo "gofmt needed on:"; echo "$$unformatted"; exit 1; fi
|
echo "gofmt needed on:"; echo "$$unformatted"; exit 1; fi
|
||||||
$(GO) vet ./...
|
$(GO) vet ./...
|
||||||
|
|
||||||
|
|||||||
+3
-6
@@ -13,7 +13,8 @@ services:
|
|||||||
# Mongo is reachable only on the compose network; never published to the
|
# Mongo is reachable only on the compose network; never published to the
|
||||||
# host (§12).
|
# host (§12).
|
||||||
volumes:
|
volumes:
|
||||||
- mongo-data:/data/db
|
# data lives in-repo (gitignored, dot-prefixed so go tooling skips it)
|
||||||
|
- ./.volumes/mongo:/data/db
|
||||||
healthcheck:
|
healthcheck:
|
||||||
test: ["CMD", "mongosh", "--quiet", "--eval", "db.adminCommand('ping').ok"]
|
test: ["CMD", "mongosh", "--quiet", "--eval", "db.adminCommand('ping').ok"]
|
||||||
interval: 10s
|
interval: 10s
|
||||||
@@ -82,7 +83,7 @@ services:
|
|||||||
volumes:
|
volumes:
|
||||||
- ${HOME}/.claude:/home/node/.claude
|
- ${HOME}/.claude:/home/node/.claude
|
||||||
- ${HOME}/.claude.json:/home/node/.claude.json
|
- ${HOME}/.claude.json:/home/node/.claude.json
|
||||||
- work-data:/work
|
- ./.volumes/work:/work
|
||||||
ports:
|
ports:
|
||||||
- "127.0.0.1:8091:8091"
|
- "127.0.0.1:8091:8091"
|
||||||
extra_hosts:
|
extra_hosts:
|
||||||
@@ -93,7 +94,3 @@ services:
|
|||||||
timeout: 5s
|
timeout: 5s
|
||||||
retries: 3
|
retries: 3
|
||||||
start_period: 5s
|
start_period: 5s
|
||||||
|
|
||||||
volumes:
|
|
||||||
mongo-data:
|
|
||||||
work-data:
|
|
||||||
|
|||||||
@@ -31,10 +31,10 @@ func SanitizeHTML(in string) string {
|
|||||||
if c != '<' {
|
if c != '<' {
|
||||||
j := strings.IndexByte(in[i:], '<')
|
j := strings.IndexByte(in[i:], '<')
|
||||||
if j < 0 {
|
if j < 0 {
|
||||||
out.WriteString(html.EscapeString(in[i:]))
|
out.WriteString(escapeText(in[i:]))
|
||||||
break
|
break
|
||||||
}
|
}
|
||||||
out.WriteString(html.EscapeString(in[i : i+j]))
|
out.WriteString(escapeText(in[i : i+j]))
|
||||||
i += j
|
i += j
|
||||||
continue
|
continue
|
||||||
}
|
}
|
||||||
@@ -53,6 +53,31 @@ func SanitizeHTML(in string) string {
|
|||||||
name, attrs := splitTag(raw)
|
name, attrs := splitTag(raw)
|
||||||
name = strings.ToLower(name)
|
name = strings.ToLower(name)
|
||||||
|
|
||||||
|
// @-mention tokens: <span class="mention" data-user-card="ULID">@Name</span>.
|
||||||
|
// Only this exact shape is allowed; any other span is dropped.
|
||||||
|
if name == "span" {
|
||||||
|
if closing {
|
||||||
|
for n := len(stack) - 1; n >= 0; n-- {
|
||||||
|
if stack[n] == "span" {
|
||||||
|
for len(stack) > n {
|
||||||
|
top := stack[len(stack)-1]
|
||||||
|
stack = stack[:len(stack)-1]
|
||||||
|
out.WriteString("</" + top + ">")
|
||||||
|
}
|
||||||
|
break
|
||||||
|
}
|
||||||
|
}
|
||||||
|
continue
|
||||||
|
}
|
||||||
|
uid := mentionUID(attrs)
|
||||||
|
if uid == "" {
|
||||||
|
continue // drop the opening span, keep its text
|
||||||
|
}
|
||||||
|
out.WriteString(`<span class="mention" data-user-card="` + html.EscapeString(uid) + `">`)
|
||||||
|
stack = append(stack, "span")
|
||||||
|
continue
|
||||||
|
}
|
||||||
|
|
||||||
if !allowedTags[name] {
|
if !allowedTags[name] {
|
||||||
continue // drop the tag entirely, keep surrounding text
|
continue // drop the tag entirely, keep surrounding text
|
||||||
}
|
}
|
||||||
@@ -93,6 +118,15 @@ func SanitizeHTML(in string) string {
|
|||||||
return out.String()
|
return out.String()
|
||||||
}
|
}
|
||||||
|
|
||||||
|
// escapeText normalizes a text run from (possibly entity-encoded) input HTML:
|
||||||
|
// decode existing entities, then re-escape. This keeps a single round of
|
||||||
|
// escaping so entities the browser emits — e.g. for spaces around inline
|
||||||
|
// elements, or & for a typed "&" — don't get double-escaped into visible
|
||||||
|
// " "/"&" text. Re-escaping keeps the output XSS-safe.
|
||||||
|
func escapeText(s string) string {
|
||||||
|
return html.EscapeString(html.UnescapeString(s))
|
||||||
|
}
|
||||||
|
|
||||||
// splitTag separates the tag name from its raw attribute string.
|
// splitTag separates the tag name from its raw attribute string.
|
||||||
func splitTag(raw string) (string, string) {
|
func splitTag(raw string) (string, string) {
|
||||||
for i := 0; i < len(raw); i++ {
|
for i := 0; i < len(raw); i++ {
|
||||||
@@ -104,6 +138,50 @@ func splitTag(raw string) (string, string) {
|
|||||||
return raw, ""
|
return raw, ""
|
||||||
}
|
}
|
||||||
|
|
||||||
|
// mentionUID validates a mention span's attributes and returns the referenced
|
||||||
|
// user id (alphanumeric, ULID-shaped), or "" if the span is not a valid
|
||||||
|
// mention.
|
||||||
|
func mentionUID(attrs string) string {
|
||||||
|
low := strings.ToLower(attrs)
|
||||||
|
if !strings.Contains(low, "mention") {
|
||||||
|
return ""
|
||||||
|
}
|
||||||
|
idx := strings.Index(low, "data-user-card")
|
||||||
|
if idx < 0 {
|
||||||
|
return ""
|
||||||
|
}
|
||||||
|
rest := strings.TrimSpace(attrs[idx+len("data-user-card"):])
|
||||||
|
if !strings.HasPrefix(rest, "=") {
|
||||||
|
return ""
|
||||||
|
}
|
||||||
|
rest = strings.TrimSpace(rest[1:])
|
||||||
|
var val string
|
||||||
|
switch {
|
||||||
|
case strings.HasPrefix(rest, `"`):
|
||||||
|
if e := strings.IndexByte(rest[1:], '"'); e >= 0 {
|
||||||
|
val = rest[1 : 1+e]
|
||||||
|
}
|
||||||
|
case strings.HasPrefix(rest, "'"):
|
||||||
|
if e := strings.IndexByte(rest[1:], '\''); e >= 0 {
|
||||||
|
val = rest[1 : 1+e]
|
||||||
|
}
|
||||||
|
default:
|
||||||
|
val = rest
|
||||||
|
if sp := strings.IndexAny(val, " \t"); sp >= 0 {
|
||||||
|
val = val[:sp]
|
||||||
|
}
|
||||||
|
}
|
||||||
|
if val == "" || len(val) > 32 {
|
||||||
|
return ""
|
||||||
|
}
|
||||||
|
for _, r := range val {
|
||||||
|
if !((r >= '0' && r <= '9') || (r >= 'A' && r <= 'Z') || (r >= 'a' && r <= 'z')) {
|
||||||
|
return ""
|
||||||
|
}
|
||||||
|
}
|
||||||
|
return val
|
||||||
|
}
|
||||||
|
|
||||||
// safeHref extracts href and accepts only http, https, or mailto schemes.
|
// safeHref extracts href and accepts only http, https, or mailto schemes.
|
||||||
func safeHref(attrs string) string {
|
func safeHref(attrs string) string {
|
||||||
lower := strings.ToLower(attrs)
|
lower := strings.ToLower(attrs)
|
||||||
@@ -150,10 +228,10 @@ func SanitizePlain(in string) string {
|
|||||||
if in[i] != '<' {
|
if in[i] != '<' {
|
||||||
j := strings.IndexByte(in[i:], '<')
|
j := strings.IndexByte(in[i:], '<')
|
||||||
if j < 0 {
|
if j < 0 {
|
||||||
out.WriteString(html.EscapeString(in[i:]))
|
out.WriteString(escapeText(in[i:]))
|
||||||
break
|
break
|
||||||
}
|
}
|
||||||
out.WriteString(html.EscapeString(in[i : i+j]))
|
out.WriteString(escapeText(in[i : i+j]))
|
||||||
i += j
|
i += j
|
||||||
continue
|
continue
|
||||||
}
|
}
|
||||||
|
|||||||
@@ -12,6 +12,10 @@ func TestSanitizeHTML(t *testing.T) {
|
|||||||
want string
|
want string
|
||||||
}{
|
}{
|
||||||
{"plain text escaped", `hello <world> & "friends"`, "hello & "friends""},
|
{"plain text escaped", `hello <world> & "friends"`, "hello & "friends""},
|
||||||
|
{"existing entity not double-escaped", `a & b <ok>`, "a & b <ok>"},
|
||||||
|
{"mention span kept with sanitized uid", `<span class="mention" data-user-card="01ABCdef">@Jane Doe</span> hi`,
|
||||||
|
`<span class="mention" data-user-card="01ABCdef">@Jane Doe</span> hi`},
|
||||||
|
{"non-mention span dropped", `<span style="x">keep text</span>`, "keep text"},
|
||||||
{"allowed formatting kept", "<b>bold</b> <i>it</i> <u>u</u> <s>s</s>", "<b>bold</b> <i>it</i> <u>u</u> <s>s</s>"},
|
{"allowed formatting kept", "<b>bold</b> <i>it</i> <u>u</u> <s>s</s>", "<b>bold</b> <i>it</i> <u>u</u> <s>s</s>"},
|
||||||
{"paragraph and lists", "<p>a</p><ul><li>x</li><li>y</li></ul>", "<p>a</p><ul><li>x</li><li>y</li></ul>"},
|
{"paragraph and lists", "<p>a</p><ul><li>x</li><li>y</li></ul>", "<p>a</p><ul><li>x</li><li>y</li></ul>"},
|
||||||
{"code and pre", "<pre><code>x := 1</code></pre>", "<pre><code>x := 1</code></pre>"},
|
{"code and pre", "<pre><code>x := 1</code></pre>", "<pre><code>x := 1</code></pre>"},
|
||||||
|
|||||||
@@ -28,6 +28,11 @@ type Ticketing struct {
|
|||||||
CredentialsEnc string `bson:"credentialsEnc" json:"-"`
|
CredentialsEnc string `bson:"credentialsEnc" json:"-"`
|
||||||
ProjectKey string `bson:"projectKey" json:"projectKey"`
|
ProjectKey string `bson:"projectKey" json:"projectKey"`
|
||||||
PollIntervalSec int `bson:"pollIntervalSec" json:"pollIntervalSec"`
|
PollIntervalSec int `bson:"pollIntervalSec" json:"pollIntervalSec"`
|
||||||
|
// ConsultantIdentities maps a bounty-board consultant id to that
|
||||||
|
// consultant's username in THIS customer's ticketing system. It takes
|
||||||
|
// precedence over the consultant's global extra.ticketingIdentities so an
|
||||||
|
// admin can map accounts per project (§5.3).
|
||||||
|
ConsultantIdentities map[string]string `bson:"consultantIdentities,omitempty" json:"consultantIdentities,omitempty"`
|
||||||
LastSyncAt time.Time `bson:"lastSyncAt" json:"lastSyncAt"`
|
LastSyncAt time.Time `bson:"lastSyncAt" json:"lastSyncAt"`
|
||||||
LastSyncStatus string `bson:"lastSyncStatus" json:"lastSyncStatus"` // "", ok, error
|
LastSyncStatus string `bson:"lastSyncStatus" json:"lastSyncStatus"` // "", ok, error
|
||||||
LastSyncError string `bson:"lastSyncError" json:"lastSyncError"`
|
LastSyncError string `bson:"lastSyncError" json:"lastSyncError"`
|
||||||
|
|||||||
@@ -54,6 +54,7 @@ type NotificationPrefs struct {
|
|||||||
|
|
||||||
type UserSettings struct {
|
type UserSettings struct {
|
||||||
Theme string `bson:"theme" json:"theme"`
|
Theme string `bson:"theme" json:"theme"`
|
||||||
|
NavLayout string `bson:"navLayout" json:"navLayout"` // "side" (default) | "top"
|
||||||
Notifications NotificationPrefs `bson:"notifications" json:"notifications"`
|
Notifications NotificationPrefs `bson:"notifications" json:"notifications"`
|
||||||
LeaderboardOptOut bool `bson:"leaderboardOptOut" json:"leaderboardOptOut"`
|
LeaderboardOptOut bool `bson:"leaderboardOptOut" json:"leaderboardOptOut"`
|
||||||
}
|
}
|
||||||
|
|||||||
@@ -102,14 +102,20 @@ func (s *Server) handleAdminPatchSettings(w http.ResponseWriter, r *http.Request
|
|||||||
|
|
||||||
func (s *Server) handleAdminAuditLog(w http.ResponseWriter, r *http.Request) {
|
func (s *Server) handleAdminAuditLog(w http.ResponseWriter, r *http.Request) {
|
||||||
q := r.URL.Query()
|
q := r.URL.Query()
|
||||||
|
limit := atoiDefault(q.Get("limit"), 50)
|
||||||
|
if limit <= 0 || limit > 200 {
|
||||||
|
limit = 50
|
||||||
|
}
|
||||||
entries, err := s.store.ListAudit(r.Context(), q.Get("actorId"), q.Get("entityId"),
|
entries, err := s.store.ListAudit(r.Context(), q.Get("actorId"), q.Get("entityId"),
|
||||||
q.Get("cursor"), atoiDefault(q.Get("limit"), 50))
|
q.Get("cursor"), limit)
|
||||||
if err != nil {
|
if err != nil {
|
||||||
s.internalError(w, r, "list audit", err)
|
s.internalError(w, r, "list audit", err)
|
||||||
return
|
return
|
||||||
}
|
}
|
||||||
|
// Only advertise a next cursor when the page is full; otherwise this is the
|
||||||
|
// last page and a further fetch would return nothing (the "click twice" bug).
|
||||||
next := ""
|
next := ""
|
||||||
if len(entries) > 0 {
|
if len(entries) == limit {
|
||||||
next = entries[len(entries)-1].ID
|
next = entries[len(entries)-1].ID
|
||||||
}
|
}
|
||||||
writeJSON(w, http.StatusOK, map[string]any{"entries": entries, "nextCursor": next})
|
writeJSON(w, http.StatusOK, map[string]any{"entries": entries, "nextCursor": next})
|
||||||
|
|||||||
@@ -26,6 +26,7 @@ type customerPayload struct {
|
|||||||
Credentials *syncpkg.Credentials `json:"credentials"`
|
Credentials *syncpkg.Credentials `json:"credentials"`
|
||||||
PollInterval *int `json:"pollIntervalSec"`
|
PollInterval *int `json:"pollIntervalSec"`
|
||||||
ConsultantIDs *[]string `json:"consultantIds"`
|
ConsultantIDs *[]string `json:"consultantIds"`
|
||||||
|
ConsultantIdentities *map[string]string `json:"consultantIdentities"`
|
||||||
DefaultBudget *float64 `json:"defaultBudget"`
|
DefaultBudget *float64 `json:"defaultBudget"`
|
||||||
Archived *bool `json:"archived"`
|
Archived *bool `json:"archived"`
|
||||||
Version *int64 `json:"version"`
|
Version *int64 `json:"version"`
|
||||||
@@ -65,6 +66,7 @@ func customerJSON(c *domain.Customer) map[string]any {
|
|||||||
"lastSyncAt": c.Ticketing.LastSyncAt, "lastSyncStatus": c.Ticketing.LastSyncStatus,
|
"lastSyncAt": c.Ticketing.LastSyncAt, "lastSyncStatus": c.Ticketing.LastSyncStatus,
|
||||||
"lastSyncError": c.Ticketing.LastSyncError,
|
"lastSyncError": c.Ticketing.LastSyncError,
|
||||||
"hasCredentials": c.Ticketing.CredentialsEnc != "",
|
"hasCredentials": c.Ticketing.CredentialsEnc != "",
|
||||||
|
"consultantIdentities": c.Ticketing.ConsultantIdentities,
|
||||||
},
|
},
|
||||||
"consultantIds": c.ConsultantIDs, "defaultBudget": c.DefaultBudget,
|
"consultantIds": c.ConsultantIDs, "defaultBudget": c.DefaultBudget,
|
||||||
"archived": c.Archived, "createdAt": c.CreatedAt, "updatedAt": c.UpdatedAt,
|
"archived": c.Archived, "createdAt": c.CreatedAt, "updatedAt": c.UpdatedAt,
|
||||||
@@ -99,6 +101,18 @@ func (s *Server) handleAdminGetCustomer(w http.ResponseWriter, r *http.Request)
|
|||||||
writeJSON(w, http.StatusOK, map[string]any{"customer": customerJSON(c)})
|
writeJSON(w, http.StatusOK, map[string]any{"customer": customerJSON(c)})
|
||||||
}
|
}
|
||||||
|
|
||||||
|
// cleanIdentities trims values and drops empty mappings so we don't persist
|
||||||
|
// blank usernames for consultants the admin left unmapped.
|
||||||
|
func cleanIdentities(in map[string]string) map[string]string {
|
||||||
|
out := map[string]string{}
|
||||||
|
for k, v := range in {
|
||||||
|
if t := strings.TrimSpace(v); t != "" {
|
||||||
|
out[k] = t
|
||||||
|
}
|
||||||
|
}
|
||||||
|
return out
|
||||||
|
}
|
||||||
|
|
||||||
func (s *Server) validateConsultants(ctx context.Context, ids []string) (string, bool) {
|
func (s *Server) validateConsultants(ctx context.Context, ids []string) (string, bool) {
|
||||||
for _, id := range ids {
|
for _, id := range ids {
|
||||||
u, err := s.store.UserByID(ctx, id)
|
u, err := s.store.UserByID(ctx, id)
|
||||||
@@ -156,6 +170,9 @@ func (s *Server) handleAdminCreateCustomer(w http.ResponseWriter, r *http.Reques
|
|||||||
}
|
}
|
||||||
c.ConsultantIDs = *req.ConsultantIDs
|
c.ConsultantIDs = *req.ConsultantIDs
|
||||||
}
|
}
|
||||||
|
if req.ConsultantIdentities != nil {
|
||||||
|
c.Ticketing.ConsultantIdentities = cleanIdentities(*req.ConsultantIdentities)
|
||||||
|
}
|
||||||
creds := syncpkg.Credentials{}
|
creds := syncpkg.Credentials{}
|
||||||
if req.Credentials != nil {
|
if req.Credentials != nil {
|
||||||
creds = *req.Credentials
|
creds = *req.Credentials
|
||||||
@@ -236,6 +253,10 @@ func (s *Server) handleAdminPatchCustomer(w http.ResponseWriter, r *http.Request
|
|||||||
set["consultantIds"] = *req.ConsultantIDs
|
set["consultantIds"] = *req.ConsultantIDs
|
||||||
diff["consultantIds"] = *req.ConsultantIDs
|
diff["consultantIds"] = *req.ConsultantIDs
|
||||||
}
|
}
|
||||||
|
if req.ConsultantIdentities != nil {
|
||||||
|
set["ticketing.consultantIdentities"] = cleanIdentities(*req.ConsultantIdentities)
|
||||||
|
diff["consultantIdentities"] = "updated"
|
||||||
|
}
|
||||||
if req.DefaultBudget != nil {
|
if req.DefaultBudget != nil {
|
||||||
set["defaultBudget"] = *req.DefaultBudget
|
set["defaultBudget"] = *req.DefaultBudget
|
||||||
diff["defaultBudget"] = *req.DefaultBudget
|
diff["defaultBudget"] = *req.DefaultBudget
|
||||||
|
|||||||
+48
-33
@@ -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
|
||||||
}
|
}
|
||||||
|
|
||||||
@@ -319,7 +336,9 @@ func (s *Server) handleAbandonTask(w http.ResponseWriter, r *http.Request) {
|
|||||||
w.WriteHeader(http.StatusNoContent)
|
w.WriteHeader(http.StatusNoContent)
|
||||||
}
|
}
|
||||||
|
|
||||||
var mentionRe = regexp.MustCompile(`@([\w.+-]+@[\w.-]+\.\w+|\w+)`)
|
// mentionUIDRe pulls user ids out of mention spans the sanitizer emitted:
|
||||||
|
// <span class="mention" data-user-card="ULID">@Name</span>.
|
||||||
|
var mentionUIDRe = regexp.MustCompile(`data-user-card="([0-9A-Za-z]+)"`)
|
||||||
|
|
||||||
func (s *Server) handleAddComment(w http.ResponseWriter, r *http.Request) {
|
func (s *Server) handleAddComment(w http.ResponseWriter, r *http.Request) {
|
||||||
var req struct {
|
var req struct {
|
||||||
@@ -375,45 +394,41 @@ func (s *Server) handleAddComment(w http.ResponseWriter, r *http.Request) {
|
|||||||
writeJSON(w, http.StatusCreated, map[string]any{"comment": comment})
|
writeJSON(w, http.StatusCreated, map[string]any{"comment": comment})
|
||||||
}
|
}
|
||||||
|
|
||||||
// notifyMentions resolves @name / @email tokens against task participants
|
// notifyMentions notifies users referenced by mention spans in the comment
|
||||||
// (§11.3).
|
// body, but only those who actually have access to the task (§11.3).
|
||||||
func (s *Server) notifyMentions(r *http.Request, t *domain.Task, author *domain.User, body string) {
|
func (s *Server) notifyMentions(r *http.Request, t *domain.Task, author *domain.User, body string) {
|
||||||
plain := chat.SanitizePlain(body)
|
matches := mentionUIDRe.FindAllStringSubmatch(body, 20)
|
||||||
matches := mentionRe.FindAllStringSubmatch(plain, 10)
|
|
||||||
if len(matches) == 0 {
|
if len(matches) == 0 {
|
||||||
return
|
return
|
||||||
}
|
}
|
||||||
// candidate participants
|
ctx := r.Context()
|
||||||
ids := map[string]bool{t.ConsultantID: true}
|
// who can see this task: its consultants, assignee, claimers, commenters
|
||||||
|
access := map[string]bool{t.ConsultantID: true}
|
||||||
|
if c, err := s.store.CustomerByID(ctx, t.CustomerID); err == nil {
|
||||||
|
for _, id := range c.ConsultantIDs {
|
||||||
|
access[id] = true
|
||||||
|
}
|
||||||
|
}
|
||||||
if t.Assignee != nil && t.Assignee.UserID != "" {
|
if t.Assignee != nil && t.Assignee.UserID != "" {
|
||||||
ids[t.Assignee.UserID] = true
|
access[t.Assignee.UserID] = true
|
||||||
}
|
}
|
||||||
for _, cr := range t.ClaimRequests {
|
for _, cr := range t.ClaimRequests {
|
||||||
ids[cr.DeveloperID] = true
|
access[cr.DeveloperID] = true
|
||||||
}
|
}
|
||||||
for _, c := range t.Comments {
|
for _, c := range t.Comments {
|
||||||
ids[c.AuthorID] = true
|
access[c.AuthorID] = true
|
||||||
}
|
}
|
||||||
notified := map[string]bool{}
|
notified := map[string]bool{}
|
||||||
for _, m := range matches {
|
for _, m := range matches {
|
||||||
token := strings.ToLower(m[1])
|
uid := m[1]
|
||||||
for id := range ids {
|
if uid == author.ID || notified[uid] || !access[uid] {
|
||||||
if id == author.ID || notified[id] {
|
|
||||||
continue
|
continue
|
||||||
}
|
}
|
||||||
u, err := s.store.UserByID(r.Context(), id)
|
notified[uid] = true
|
||||||
if err != nil {
|
s.notifyUser(ctx, uid, "mention",
|
||||||
continue
|
|
||||||
}
|
|
||||||
first := strings.ToLower(strings.SplitN(u.Name, " ", 2)[0])
|
|
||||||
if token == strings.ToLower(u.Email) || token == first {
|
|
||||||
notified[id] = true
|
|
||||||
s.notifyUser(r.Context(), id, "mention",
|
|
||||||
author.Name+" mentioned you", "On task: "+t.Title, "/tasks/"+t.ID)
|
author.Name+" mentioned you", "On task: "+t.Title, "/tasks/"+t.ID)
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
}
|
|
||||||
}
|
|
||||||
|
|
||||||
func (s *Server) handleLogTime(w http.ResponseWriter, r *http.Request) {
|
func (s *Server) handleLogTime(w http.ResponseWriter, r *http.Request) {
|
||||||
var req struct {
|
var req struct {
|
||||||
|
|||||||
+214
-8
@@ -24,8 +24,12 @@ func (s *Server) routesMessages(mux *http.ServeMux) {
|
|||||||
mux.Handle("GET /api/v1/conversations/{id}/messages", s.requireAuth(http.HandlerFunc(s.handleListMessages)))
|
mux.Handle("GET /api/v1/conversations/{id}/messages", s.requireAuth(http.HandlerFunc(s.handleListMessages)))
|
||||||
mux.Handle("POST /api/v1/conversations/{id}/messages", s.authed(s.handleSendMessage))
|
mux.Handle("POST /api/v1/conversations/{id}/messages", s.authed(s.handleSendMessage))
|
||||||
mux.Handle("POST /api/v1/conversations/{id}/read", s.authed(s.handleMarkRead))
|
mux.Handle("POST /api/v1/conversations/{id}/read", s.authed(s.handleMarkRead))
|
||||||
|
mux.Handle("GET /api/v1/conversations/{id}/members", s.requireAuth(http.HandlerFunc(s.handleListMembers)))
|
||||||
|
mux.Handle("POST /api/v1/conversations/{id}/members", s.authed(s.handleAddMember))
|
||||||
|
mux.Handle("DELETE /api/v1/conversations/{id}/members/{userId}", s.authed(s.handleRemoveMember))
|
||||||
mux.Handle("POST /api/v1/files", s.authed(s.handleUploadFile))
|
mux.Handle("POST /api/v1/files", s.authed(s.handleUploadFile))
|
||||||
mux.Handle("GET /api/v1/users", s.requireAuth(http.HandlerFunc(s.handleSearchUsers)))
|
mux.Handle("GET /api/v1/users", s.requireAuth(http.HandlerFunc(s.handleSearchUsers)))
|
||||||
|
mux.Handle("GET /api/v1/messages/search", s.requireAuth(http.HandlerFunc(s.handleSearchMessages)))
|
||||||
}
|
}
|
||||||
|
|
||||||
// conversation loads + authorizes participant access.
|
// conversation loads + authorizes participant access.
|
||||||
@@ -81,6 +85,7 @@ func (s *Server) handleListConversations(w http.ResponseWriter, r *http.Request)
|
|||||||
out[i] = map[string]any{
|
out[i] = map[string]any{
|
||||||
"id": c.ID, "kind": c.Kind, "title": title,
|
"id": c.ID, "kind": c.Kind, "title": title,
|
||||||
"customerId": c.CustomerID, "participantIds": c.ParticipantIDs,
|
"customerId": c.CustomerID, "participantIds": c.ParticipantIDs,
|
||||||
|
"creatorId": c.CreatorID,
|
||||||
"lastMessageAt": c.LastMessageAt, "unread": unread[c.ID],
|
"lastMessageAt": c.LastMessageAt, "unread": unread[c.ID],
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
@@ -135,6 +140,7 @@ func (s *Server) handleCreateConversation(w http.ResponseWriter, r *http.Request
|
|||||||
conv := &store.Conversation{
|
conv := &store.Conversation{
|
||||||
Kind: req.Kind,
|
Kind: req.Kind,
|
||||||
Title: strings.TrimSpace(req.Title),
|
Title: strings.TrimSpace(req.Title),
|
||||||
|
CreatorID: me.ID,
|
||||||
ParticipantIDs: participants,
|
ParticipantIDs: participants,
|
||||||
}
|
}
|
||||||
if req.Kind == "project" {
|
if req.Kind == "project" {
|
||||||
@@ -226,20 +232,22 @@ func (s *Server) handleSendMessage(w http.ResponseWriter, r *http.Request) {
|
|||||||
if s.sendTo != nil {
|
if s.sendTo != nil {
|
||||||
s.sendTo(conv.ParticipantIDs, "chat", "message", msg)
|
s.sendTo(conv.ParticipantIDs, "chat", "message", msg)
|
||||||
}
|
}
|
||||||
|
// Notify mentioned users who are participants (i.e. have access §11.1).
|
||||||
plain := chat.SanitizePlain(body)
|
plain := chat.SanitizePlain(body)
|
||||||
|
participants := map[string]bool{}
|
||||||
for _, pid := range conv.ParticipantIDs {
|
for _, pid := range conv.ParticipantIDs {
|
||||||
if pid == me.ID {
|
participants[pid] = true
|
||||||
|
}
|
||||||
|
notified := map[string]bool{}
|
||||||
|
for _, m := range mentionUIDRe.FindAllStringSubmatch(body, 20) {
|
||||||
|
uid := m[1]
|
||||||
|
if uid == me.ID || notified[uid] || !participants[uid] {
|
||||||
continue
|
continue
|
||||||
}
|
}
|
||||||
if u, err := s.store.UserByID(r.Context(), pid); err == nil {
|
notified[uid] = true
|
||||||
first := strings.ToLower(strings.SplitN(u.Name, " ", 2)[0])
|
s.notifyUser(r.Context(), uid, "mention",
|
||||||
lower := strings.ToLower(plain)
|
|
||||||
if strings.Contains(lower, "@"+strings.ToLower(u.Email)) || strings.Contains(lower, "@"+first) {
|
|
||||||
s.notifyUser(r.Context(), pid, "mention",
|
|
||||||
me.Name+" mentioned you in a chat", truncateStr(plain, 120), "/messages?c="+conv.ID)
|
me.Name+" mentioned you in a chat", truncateStr(plain, 120), "/messages?c="+conv.ID)
|
||||||
}
|
}
|
||||||
}
|
|
||||||
}
|
|
||||||
writeJSON(w, http.StatusCreated, map[string]any{"message": msg})
|
writeJSON(w, http.StatusCreated, map[string]any{"message": msg})
|
||||||
}
|
}
|
||||||
|
|
||||||
@@ -256,6 +264,93 @@ func (s *Server) handleMarkRead(w http.ResponseWriter, r *http.Request) {
|
|||||||
writeJSON(w, http.StatusOK, map[string]any{"marked": n})
|
writeJSON(w, http.StatusOK, map[string]any{"marked": n})
|
||||||
}
|
}
|
||||||
|
|
||||||
|
// groupOwner loads a group/project conversation and authorizes the creator.
|
||||||
|
func (s *Server) groupOwner(w http.ResponseWriter, r *http.Request, id string) (*store.Conversation, bool) {
|
||||||
|
conv, ok := s.conversation(w, r, id)
|
||||||
|
if !ok {
|
||||||
|
return nil, false
|
||||||
|
}
|
||||||
|
if conv.Kind == "dm" {
|
||||||
|
writeError(w, http.StatusBadRequest, "bad_request", "direct messages have no managed members")
|
||||||
|
return nil, false
|
||||||
|
}
|
||||||
|
if conv.CreatorID != CurrentUser(r.Context()).ID {
|
||||||
|
writeError(w, http.StatusForbidden, "forbidden", "only the group creator can manage members")
|
||||||
|
return nil, false
|
||||||
|
}
|
||||||
|
return conv, true
|
||||||
|
}
|
||||||
|
|
||||||
|
// handleListMembers returns the participants; any member may view.
|
||||||
|
func (s *Server) handleListMembers(w http.ResponseWriter, r *http.Request) {
|
||||||
|
conv, ok := s.conversation(w, r, r.PathValue("id"))
|
||||||
|
if !ok {
|
||||||
|
return
|
||||||
|
}
|
||||||
|
me := CurrentUser(r.Context())
|
||||||
|
out := make([]map[string]any, 0, len(conv.ParticipantIDs))
|
||||||
|
for _, id := range conv.ParticipantIDs {
|
||||||
|
u, err := s.store.UserByID(r.Context(), id)
|
||||||
|
if err != nil {
|
||||||
|
continue
|
||||||
|
}
|
||||||
|
out = append(out, map[string]any{
|
||||||
|
"id": u.ID, "name": u.Name, "email": u.Email,
|
||||||
|
"avatarFileId": u.AvatarFileID, "isCreator": id == conv.CreatorID,
|
||||||
|
})
|
||||||
|
}
|
||||||
|
writeJSON(w, http.StatusOK, map[string]any{
|
||||||
|
"members": out,
|
||||||
|
"creatorId": conv.CreatorID,
|
||||||
|
"canManage": conv.Kind != "dm" && conv.CreatorID == me.ID,
|
||||||
|
})
|
||||||
|
}
|
||||||
|
|
||||||
|
// handleAddMember adds a participant to a group (creator only).
|
||||||
|
func (s *Server) handleAddMember(w http.ResponseWriter, r *http.Request) {
|
||||||
|
conv, ok := s.groupOwner(w, r, r.PathValue("id"))
|
||||||
|
if !ok {
|
||||||
|
return
|
||||||
|
}
|
||||||
|
var req struct {
|
||||||
|
UserID string `json:"userId"`
|
||||||
|
}
|
||||||
|
if !decodeJSON(w, r, &req) {
|
||||||
|
return
|
||||||
|
}
|
||||||
|
if req.UserID == "" {
|
||||||
|
writeError(w, http.StatusBadRequest, "bad_request", "userId is required")
|
||||||
|
return
|
||||||
|
}
|
||||||
|
if _, err := s.store.UserByID(r.Context(), req.UserID); err != nil {
|
||||||
|
writeError(w, http.StatusBadRequest, "bad_user", "unknown user")
|
||||||
|
return
|
||||||
|
}
|
||||||
|
if err := s.store.AddParticipant(r.Context(), conv.ID, req.UserID); err != nil {
|
||||||
|
s.internalError(w, r, "add member", err)
|
||||||
|
return
|
||||||
|
}
|
||||||
|
writeJSON(w, http.StatusOK, map[string]any{"ok": true})
|
||||||
|
}
|
||||||
|
|
||||||
|
// handleRemoveMember removes a participant from a group (creator only).
|
||||||
|
func (s *Server) handleRemoveMember(w http.ResponseWriter, r *http.Request) {
|
||||||
|
conv, ok := s.groupOwner(w, r, r.PathValue("id"))
|
||||||
|
if !ok {
|
||||||
|
return
|
||||||
|
}
|
||||||
|
target := r.PathValue("userId")
|
||||||
|
if target == conv.CreatorID {
|
||||||
|
writeError(w, http.StatusBadRequest, "bad_request", "the creator cannot be removed")
|
||||||
|
return
|
||||||
|
}
|
||||||
|
if err := s.store.RemoveParticipant(r.Context(), conv.ID, target); err != nil {
|
||||||
|
s.internalError(w, r, "remove member", err)
|
||||||
|
return
|
||||||
|
}
|
||||||
|
writeJSON(w, http.StatusOK, map[string]any{"ok": true})
|
||||||
|
}
|
||||||
|
|
||||||
// handleUploadFile is the generic upload endpoint (POST /files, §6) used by
|
// handleUploadFile is the generic upload endpoint (POST /files, §6) used by
|
||||||
// chat; scope=chat unless the caller passes scope=task (consultants).
|
// chat; scope=chat unless the caller passes scope=task (consultants).
|
||||||
func (s *Server) handleUploadFile(w http.ResponseWriter, r *http.Request) {
|
func (s *Server) handleUploadFile(w http.ResponseWriter, r *http.Request) {
|
||||||
@@ -322,6 +417,117 @@ func (s *Server) handleSearchUsers(w http.ResponseWriter, r *http.Request) {
|
|||||||
writeJSON(w, http.StatusOK, map[string]any{"users": out})
|
writeJSON(w, http.StatusOK, map[string]any{"users": out})
|
||||||
}
|
}
|
||||||
|
|
||||||
|
// handleSearchMessages finds messages containing the query within the caller's
|
||||||
|
// own conversations, newest first.
|
||||||
|
func (s *Server) handleSearchMessages(w http.ResponseWriter, r *http.Request) {
|
||||||
|
me := CurrentUser(r.Context())
|
||||||
|
q := strings.TrimSpace(r.URL.Query().Get("q"))
|
||||||
|
if len([]rune(q)) < 2 {
|
||||||
|
writeJSON(w, http.StatusOK, map[string]any{"results": []any{}})
|
||||||
|
return
|
||||||
|
}
|
||||||
|
convs, err := s.store.ListConversations(r.Context(), me.ID)
|
||||||
|
if err != nil {
|
||||||
|
s.internalError(w, r, "search: conversations", err)
|
||||||
|
return
|
||||||
|
}
|
||||||
|
if len(convs) == 0 {
|
||||||
|
writeJSON(w, http.StatusOK, map[string]any{"results": []any{}})
|
||||||
|
return
|
||||||
|
}
|
||||||
|
ids := make([]string, 0, len(convs))
|
||||||
|
titles := make(map[string]string, len(convs))
|
||||||
|
for _, c := range convs {
|
||||||
|
ids = append(ids, c.ID)
|
||||||
|
title := c.Title
|
||||||
|
if c.Kind == "dm" {
|
||||||
|
for _, pid := range c.ParticipantIDs {
|
||||||
|
if pid != me.ID {
|
||||||
|
if u, err := s.store.UserByID(r.Context(), pid); err == nil {
|
||||||
|
title = u.Name
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
|
if title == "" {
|
||||||
|
title = "Conversation"
|
||||||
|
}
|
||||||
|
titles[c.ID] = title
|
||||||
|
}
|
||||||
|
cur, err := s.store.DB.Collection("messages").Find(r.Context(), bson.M{
|
||||||
|
"conversationId": bson.M{"$in": ids},
|
||||||
|
"deletedAt": nil,
|
||||||
|
"body": bson.M{"$regex": regexEscape(q), "$options": "i"},
|
||||||
|
}, options.Find().SetSort(bson.D{{Key: "_id", Value: -1}}).SetLimit(30))
|
||||||
|
if err != nil {
|
||||||
|
s.internalError(w, r, "search messages", err)
|
||||||
|
return
|
||||||
|
}
|
||||||
|
var msgs []store.Message
|
||||||
|
if err := cur.All(r.Context(), &msgs); err != nil {
|
||||||
|
s.internalError(w, r, "decode search", err)
|
||||||
|
return
|
||||||
|
}
|
||||||
|
results := make([]map[string]any, 0, len(msgs))
|
||||||
|
for _, m := range msgs {
|
||||||
|
results = append(results, map[string]any{
|
||||||
|
"conversationId": m.ConversationID,
|
||||||
|
"conversationTitle": titles[m.ConversationID],
|
||||||
|
"messageId": m.ID,
|
||||||
|
"senderId": m.SenderID,
|
||||||
|
"snippet": snippetAround(stripTags(m.Body), q),
|
||||||
|
})
|
||||||
|
}
|
||||||
|
writeJSON(w, http.StatusOK, map[string]any{"results": results})
|
||||||
|
}
|
||||||
|
|
||||||
|
// stripTags removes HTML tags and collapses whitespace for plain-text snippets.
|
||||||
|
func stripTags(html string) string {
|
||||||
|
var b strings.Builder
|
||||||
|
inTag := false
|
||||||
|
for _, r := range html {
|
||||||
|
switch {
|
||||||
|
case r == '<':
|
||||||
|
inTag = true
|
||||||
|
case r == '>':
|
||||||
|
inTag = false
|
||||||
|
case !inTag:
|
||||||
|
b.WriteRune(r)
|
||||||
|
}
|
||||||
|
}
|
||||||
|
return strings.Join(strings.Fields(b.String()), " ")
|
||||||
|
}
|
||||||
|
|
||||||
|
// snippetAround returns a short text window centered on the first
|
||||||
|
// case-insensitive match of q.
|
||||||
|
func snippetAround(text, q string) string {
|
||||||
|
runes := []rune(text)
|
||||||
|
idx := strings.Index(strings.ToLower(text), strings.ToLower(q))
|
||||||
|
if idx < 0 {
|
||||||
|
if len(runes) > 100 {
|
||||||
|
return string(runes[:100]) + "…"
|
||||||
|
}
|
||||||
|
return text
|
||||||
|
}
|
||||||
|
start := len([]rune(text[:idx]))
|
||||||
|
from := start - 30
|
||||||
|
if from < 0 {
|
||||||
|
from = 0
|
||||||
|
}
|
||||||
|
to := start + len([]rune(q)) + 50
|
||||||
|
if to > len(runes) {
|
||||||
|
to = len(runes)
|
||||||
|
}
|
||||||
|
out := string(runes[from:to])
|
||||||
|
if from > 0 {
|
||||||
|
out = "…" + out
|
||||||
|
}
|
||||||
|
if to < len(runes) {
|
||||||
|
out += "…"
|
||||||
|
}
|
||||||
|
return out
|
||||||
|
}
|
||||||
|
|
||||||
// HandleInboundWS relays ephemeral client events (typing indicator).
|
// HandleInboundWS relays ephemeral client events (typing indicator).
|
||||||
func (s *Server) HandleInboundWS(userID string, msg ws.Message) {
|
func (s *Server) HandleInboundWS(userID string, msg ws.Message) {
|
||||||
if msg.Channel != "chat" || msg.Event != "typing" {
|
if msg.Channel != "chat" || msg.Event != "typing" {
|
||||||
|
|||||||
@@ -15,6 +15,12 @@ import (
|
|||||||
"bountyboard/internal/store"
|
"bountyboard/internal/store"
|
||||||
)
|
)
|
||||||
|
|
||||||
|
// validThemes are the selectable UI themes (must match web/static/js/theme.js).
|
||||||
|
var validThemes = map[string]bool{
|
||||||
|
"light": true, "dark": true, "neon": true,
|
||||||
|
"terminal": true, "blueprint": true, "sunset": true,
|
||||||
|
}
|
||||||
|
|
||||||
func (s *Server) routesProfile(mux *http.ServeMux) {
|
func (s *Server) routesProfile(mux *http.ServeMux) {
|
||||||
mux.Handle("GET /api/v1/profile", s.requireAuth(http.HandlerFunc(s.handleGetProfile)))
|
mux.Handle("GET /api/v1/profile", s.requireAuth(http.HandlerFunc(s.handleGetProfile)))
|
||||||
mux.Handle("PATCH /api/v1/profile", s.authed(s.handlePatchProfile))
|
mux.Handle("PATCH /api/v1/profile", s.authed(s.handlePatchProfile))
|
||||||
@@ -39,6 +45,7 @@ func (s *Server) handlePatchProfile(w http.ResponseWriter, r *http.Request) {
|
|||||||
Extra *map[string]any `json:"extra"`
|
Extra *map[string]any `json:"extra"`
|
||||||
Settings *struct {
|
Settings *struct {
|
||||||
Theme *string `json:"theme"`
|
Theme *string `json:"theme"`
|
||||||
|
NavLayout *string `json:"navLayout"`
|
||||||
Notifications *domain.NotificationPrefs `json:"notifications"`
|
Notifications *domain.NotificationPrefs `json:"notifications"`
|
||||||
LeaderboardOptOut *bool `json:"leaderboardOptOut"`
|
LeaderboardOptOut *bool `json:"leaderboardOptOut"`
|
||||||
} `json:"settings"`
|
} `json:"settings"`
|
||||||
@@ -71,12 +78,20 @@ func (s *Server) handlePatchProfile(w http.ResponseWriter, r *http.Request) {
|
|||||||
if req.Settings != nil {
|
if req.Settings != nil {
|
||||||
if req.Settings.Theme != nil {
|
if req.Settings.Theme != nil {
|
||||||
theme := *req.Settings.Theme
|
theme := *req.Settings.Theme
|
||||||
if theme != "light" && theme != "dark" {
|
if !validThemes[theme] {
|
||||||
writeError(w, http.StatusBadRequest, "invalid_theme", "theme must be light or dark")
|
writeError(w, http.StatusBadRequest, "invalid_theme", "unknown theme")
|
||||||
return
|
return
|
||||||
}
|
}
|
||||||
set["settings.theme"] = theme
|
set["settings.theme"] = theme
|
||||||
}
|
}
|
||||||
|
if req.Settings.NavLayout != nil {
|
||||||
|
nav := *req.Settings.NavLayout
|
||||||
|
if nav != "side" && nav != "top" {
|
||||||
|
writeError(w, http.StatusBadRequest, "invalid_nav", "navLayout must be side or top")
|
||||||
|
return
|
||||||
|
}
|
||||||
|
set["settings.navLayout"] = nav
|
||||||
|
}
|
||||||
if req.Settings.Notifications != nil {
|
if req.Settings.Notifications != nil {
|
||||||
set["settings.notifications"] = *req.Settings.Notifications
|
set["settings.notifications"] = *req.Settings.Notifications
|
||||||
}
|
}
|
||||||
|
|||||||
@@ -108,7 +108,7 @@ func TestProfilePatchAndThemePersistence(t *testing.T) {
|
|||||||
|
|
||||||
// invalid theme rejected
|
// invalid theme rejected
|
||||||
resp = patchJSON(t, c, ts.URL+"/api/v1/profile", map[string]any{
|
resp = patchJSON(t, c, ts.URL+"/api/v1/profile", map[string]any{
|
||||||
"settings": map[string]any{"theme": "neon"},
|
"settings": map[string]any{"theme": "rainbow-unicorn"},
|
||||||
}, csrf)
|
}, csrf)
|
||||||
if resp.StatusCode != http.StatusBadRequest {
|
if resp.StatusCode != http.StatusBadRequest {
|
||||||
t.Fatalf("invalid theme: %d", resp.StatusCode)
|
t.Fatalf("invalid theme: %d", resp.StatusCode)
|
||||||
|
|||||||
@@ -8,6 +8,7 @@ import (
|
|||||||
"time"
|
"time"
|
||||||
|
|
||||||
"go.mongodb.org/mongo-driver/v2/bson"
|
"go.mongodb.org/mongo-driver/v2/bson"
|
||||||
|
"go.mongodb.org/mongo-driver/v2/mongo/options"
|
||||||
|
|
||||||
"bountyboard/internal/atomize"
|
"bountyboard/internal/atomize"
|
||||||
"bountyboard/internal/domain"
|
"bountyboard/internal/domain"
|
||||||
@@ -23,10 +24,66 @@ func (s *Server) routesTasks(mux *http.ServeMux) {
|
|||||||
mux.Handle("POST /api/v1/tasks/publish", s.authedRole("consultant", s.handlePublishBulk))
|
mux.Handle("POST /api/v1/tasks/publish", s.authedRole("consultant", s.handlePublishBulk))
|
||||||
mux.Handle("POST /api/v1/tasks/{id}/archive", s.authed(s.handleArchiveTask))
|
mux.Handle("POST /api/v1/tasks/{id}/archive", s.authed(s.handleArchiveTask))
|
||||||
mux.Handle("POST /api/v1/tasks/archive", s.authedRole("consultant", s.handleArchiveBulk))
|
mux.Handle("POST /api/v1/tasks/archive", s.authedRole("consultant", s.handleArchiveBulk))
|
||||||
|
mux.Handle("GET /api/v1/archive", s.requireAuth(http.HandlerFunc(s.handleArchive)))
|
||||||
mux.Handle("GET /api/v1/tasks/{id}", s.requireAuth(http.HandlerFunc(s.handleTaskDetail)))
|
mux.Handle("GET /api/v1/tasks/{id}", s.requireAuth(http.HandlerFunc(s.handleTaskDetail)))
|
||||||
mux.Handle("GET /api/v1/service-health", s.requireAuth(http.HandlerFunc(s.handleServiceHealth)))
|
mux.Handle("GET /api/v1/service-health", s.requireAuth(http.HandlerFunc(s.handleServiceHealth)))
|
||||||
}
|
}
|
||||||
|
|
||||||
|
// handleArchive lists archived tasks scoped by role: admins see everything,
|
||||||
|
// consultants see their customers' tasks, developers see tasks they were
|
||||||
|
// assigned to or worked on. Optional ?q= full-text search.
|
||||||
|
func (s *Server) handleArchive(w http.ResponseWriter, r *http.Request) {
|
||||||
|
me := CurrentUser(r.Context())
|
||||||
|
q := bson.M{"status": domain.StatusArchived}
|
||||||
|
if !me.Roles.Admin {
|
||||||
|
ors := []bson.M{}
|
||||||
|
if me.Roles.Consultant {
|
||||||
|
cs, err := s.store.ListCustomers(r.Context(), me.ID, true)
|
||||||
|
if err != nil {
|
||||||
|
s.internalError(w, r, "archive customers", err)
|
||||||
|
return
|
||||||
|
}
|
||||||
|
ids := make([]string, 0, len(cs))
|
||||||
|
for _, c := range cs {
|
||||||
|
ids = append(ids, c.ID)
|
||||||
|
}
|
||||||
|
ors = append(ors, bson.M{"customerId": bson.M{"$in": ids}})
|
||||||
|
}
|
||||||
|
if me.Roles.Developer {
|
||||||
|
ors = append(ors,
|
||||||
|
bson.M{"assignee.userId": me.ID},
|
||||||
|
bson.M{"claimRequests.developerId": me.ID})
|
||||||
|
}
|
||||||
|
// everyone also sees archived tasks they personally acted on
|
||||||
|
ors = append(ors, bson.M{"timeline.actorId": me.ID})
|
||||||
|
q["$or"] = ors
|
||||||
|
}
|
||||||
|
if search := strings.TrimSpace(r.URL.Query().Get("q")); search != "" {
|
||||||
|
q["$text"] = bson.M{"$search": search}
|
||||||
|
}
|
||||||
|
cur, err := s.store.DB.Collection("tasks").Find(r.Context(), q,
|
||||||
|
options.Find().SetSort(bson.D{{Key: "_id", Value: -1}}).SetLimit(200))
|
||||||
|
if err != nil {
|
||||||
|
s.internalError(w, r, "list archive", err)
|
||||||
|
return
|
||||||
|
}
|
||||||
|
tasks := []domain.Task{}
|
||||||
|
if err := cur.All(r.Context(), &tasks); err != nil {
|
||||||
|
s.internalError(w, r, "decode archive", err)
|
||||||
|
return
|
||||||
|
}
|
||||||
|
// resolve customer names for display
|
||||||
|
names := map[string]string{}
|
||||||
|
for _, t := range tasks {
|
||||||
|
if t.CustomerID != "" && names[t.CustomerID] == "" {
|
||||||
|
if c, err := s.store.CustomerByID(r.Context(), t.CustomerID); err == nil {
|
||||||
|
names[t.CustomerID] = c.Name
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
|
writeJSON(w, http.StatusOK, map[string]any{"tasks": tasks, "customerNames": names})
|
||||||
|
}
|
||||||
|
|
||||||
// consultantTask loads a task and authorizes the current user: admins and
|
// consultantTask loads a task and authorizes the current user: admins and
|
||||||
// every consultant assigned to the task's customer may manage it (§4.4).
|
// every consultant assigned to the task's customer may manage it (§4.4).
|
||||||
func (s *Server) consultantTask(w http.ResponseWriter, r *http.Request, id string) (*domain.Task, *domain.Customer, bool) {
|
func (s *Server) consultantTask(w http.ResponseWriter, r *http.Request, id string) (*domain.Task, *domain.Customer, bool) {
|
||||||
|
|||||||
+27
-1
@@ -22,6 +22,8 @@ type pageData struct {
|
|||||||
Active string // nav highlight key
|
Active string // nav highlight key
|
||||||
User *domain.User
|
User *domain.User
|
||||||
DefaultTheme string
|
DefaultTheme string
|
||||||
|
NavLayout string
|
||||||
|
Brand string
|
||||||
Narrow bool
|
Narrow bool
|
||||||
OIDCEnabled bool
|
OIDCEnabled bool
|
||||||
Error string
|
Error string
|
||||||
@@ -81,6 +83,18 @@ func (s *Server) render(w http.ResponseWriter, r *http.Request, page string, dat
|
|||||||
data.DefaultTheme = data.User.Settings.Theme
|
data.DefaultTheme = data.User.Settings.Theme
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
if data.NavLayout == "" {
|
||||||
|
data.NavLayout = "side" // sidebar by default
|
||||||
|
if data.User != nil && data.User.Settings.NavLayout == "top" {
|
||||||
|
data.NavLayout = "top"
|
||||||
|
}
|
||||||
|
}
|
||||||
|
if data.Brand == "" {
|
||||||
|
data.Brand = "Bounty Board"
|
||||||
|
if st, err := s.store.GetSettings(r.Context()); err == nil && st.BrandingName != "" {
|
||||||
|
data.Brand = st.BrandingName
|
||||||
|
}
|
||||||
|
}
|
||||||
w.Header().Set("Content-Type", "text/html; charset=utf-8")
|
w.Header().Set("Content-Type", "text/html; charset=utf-8")
|
||||||
if err := t.ExecuteTemplate(w, "layout", data); err != nil {
|
if err := t.ExecuteTemplate(w, "layout", data); err != nil {
|
||||||
s.log.Error("execute template", "page", page, "err", err)
|
s.log.Error("execute template", "page", page, "err", err)
|
||||||
@@ -231,11 +245,15 @@ 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)
|
||||||
rolePage("/notifications", "notifications.html", "Notifications", "", "/static/js/notifications-page.js", nil)
|
rolePage("/notifications", "notifications.html", "Notifications", "", "/static/js/notifications-page.js", nil)
|
||||||
|
rolePage("/archive", "archive.html", "Archive", "archive", "/static/js/archive.js", nil)
|
||||||
|
|
||||||
mux.HandleFunc("GET /tasks/{id}", s.page(func(w http.ResponseWriter, r *http.Request, u *domain.User) {
|
mux.HandleFunc("GET /tasks/{id}", s.page(func(w http.ResponseWriter, r *http.Request, u *domain.User) {
|
||||||
s.render(w, r, "task.html", &pageData{
|
s.render(w, r, "task.html", &pageData{
|
||||||
@@ -245,6 +263,14 @@ func (s *Server) routesWeb(mux *http.ServeMux) {
|
|||||||
})
|
})
|
||||||
}))
|
}))
|
||||||
|
|
||||||
|
mux.HandleFunc("GET /users/{id}", s.page(func(w http.ResponseWriter, r *http.Request, u *domain.User) {
|
||||||
|
s.render(w, r, "public-profile.html", &pageData{
|
||||||
|
Title: "Profile", User: u,
|
||||||
|
Scripts: []string{"/static/js/public-profile.js"},
|
||||||
|
Data: map[string]any{"UserID": r.PathValue("id")},
|
||||||
|
})
|
||||||
|
}))
|
||||||
|
|
||||||
rolePage("/messages", "messages.html", "Messages", "messages", "/static/js/messages.js", nil)
|
rolePage("/messages", "messages.html", "Messages", "messages", "/static/js/messages.js", nil)
|
||||||
rolePage("/metrics", "metrics.html", "Metrics", "metrics", "/static/js/metrics.js", nil)
|
rolePage("/metrics", "metrics.html", "Metrics", "metrics", "/static/js/metrics.js", nil)
|
||||||
}
|
}
|
||||||
|
|||||||
@@ -19,6 +19,7 @@ type Conversation struct {
|
|||||||
Kind string `bson:"kind" json:"kind"` // dm | group | project
|
Kind string `bson:"kind" json:"kind"` // dm | group | project
|
||||||
CustomerID string `bson:"customerId,omitempty" json:"customerId,omitempty"`
|
CustomerID string `bson:"customerId,omitempty" json:"customerId,omitempty"`
|
||||||
Title string `bson:"title,omitempty" json:"title,omitempty"`
|
Title string `bson:"title,omitempty" json:"title,omitempty"`
|
||||||
|
CreatorID string `bson:"creatorId,omitempty" json:"creatorId,omitempty"`
|
||||||
ParticipantIDs []string `bson:"participantIds" json:"participantIds"`
|
ParticipantIDs []string `bson:"participantIds" json:"participantIds"`
|
||||||
LastMessageAt time.Time `bson:"lastMessageAt" json:"lastMessageAt"`
|
LastMessageAt time.Time `bson:"lastMessageAt" json:"lastMessageAt"`
|
||||||
CreatedAt time.Time `bson:"createdAt" json:"createdAt"`
|
CreatedAt time.Time `bson:"createdAt" json:"createdAt"`
|
||||||
@@ -104,6 +105,28 @@ func (s *Store) ConversationByID(ctx context.Context, id string) (*Conversation,
|
|||||||
return &c, nil
|
return &c, nil
|
||||||
}
|
}
|
||||||
|
|
||||||
|
// AddParticipant adds a user to a conversation (idempotent via $addToSet).
|
||||||
|
func (s *Store) AddParticipant(ctx context.Context, convID, userID string) error {
|
||||||
|
_, err := s.DB.Collection("conversations").UpdateOne(ctx,
|
||||||
|
bson.M{"_id": convID},
|
||||||
|
bson.M{"$addToSet": bson.M{"participantIds": userID}})
|
||||||
|
if err != nil {
|
||||||
|
return fmt.Errorf("add participant: %w", err)
|
||||||
|
}
|
||||||
|
return nil
|
||||||
|
}
|
||||||
|
|
||||||
|
// RemoveParticipant removes a user from a conversation.
|
||||||
|
func (s *Store) RemoveParticipant(ctx context.Context, convID, userID string) error {
|
||||||
|
_, err := s.DB.Collection("conversations").UpdateOne(ctx,
|
||||||
|
bson.M{"_id": convID},
|
||||||
|
bson.M{"$pull": bson.M{"participantIds": userID}})
|
||||||
|
if err != nil {
|
||||||
|
return fmt.Errorf("remove participant: %w", err)
|
||||||
|
}
|
||||||
|
return nil
|
||||||
|
}
|
||||||
|
|
||||||
func (s *Store) ListConversations(ctx context.Context, userID string) ([]Conversation, error) {
|
func (s *Store) ListConversations(ctx context.Context, userID string) ([]Conversation, error) {
|
||||||
cur, err := s.DB.Collection("conversations").Find(ctx,
|
cur, err := s.DB.Collection("conversations").Find(ctx,
|
||||||
bson.M{"participantIds": userID},
|
bson.M{"participantIds": userID},
|
||||||
|
|||||||
@@ -257,7 +257,12 @@ func (m *Manager) SyncCustomer(ctx context.Context, c *domain.Customer) error {
|
|||||||
if err != nil {
|
if err != nil {
|
||||||
continue // consultant deleted; reconcile will catch up
|
continue // consultant deleted; reconcile will catch up
|
||||||
}
|
}
|
||||||
identity := ticketingIdentity(consultant, c.Ticketing.Type)
|
// Per-customer mapping (set by the admin on the customer) wins over
|
||||||
|
// the consultant's global identity; fall back to the global one.
|
||||||
|
identity := c.Ticketing.ConsultantIdentities[consultantID]
|
||||||
|
if identity == "" {
|
||||||
|
identity = ticketingIdentity(consultant, c.Ticketing.Type)
|
||||||
|
}
|
||||||
if identity == "" {
|
if identity == "" {
|
||||||
continue // §5.3: consultant has no linked account in this system
|
continue // §5.3: consultant has no linked account in this system
|
||||||
}
|
}
|
||||||
|
|||||||
+257
-8
@@ -56,8 +56,116 @@
|
|||||||
:root[data-theme=dark] {
|
:root[data-theme=dark] {
|
||||||
--ink-shadow: 3px 3px 0 rgba(0, 0, 0, 0.55);
|
--ink-shadow: 3px 3px 0 rgba(0, 0, 0, 0.55);
|
||||||
--ink-shadow-lift: 5px 5px 0 rgba(0, 0, 0, 0.65);
|
--ink-shadow-lift: 5px 5px 0 rgba(0, 0, 0, 0.65);
|
||||||
--dither: url("data:image/svg+xml,%3Csvg xmlns='http://www.w3.org/2000/svg' width='4' height='4'%3E%3Crect width='1' height='1' x='0' y='0' fill='%23d9a548'/%3E%3Crect width='1' height='1' x='2' y='2' fill='%23d9a548'/%3E%3C/svg%3E");
|
--dither: url("data:image/svg+xml,%3Csvg xmlns='http://www.w3.org/2000/svg' width='8' height='8'%3E%3Crect width='1' height='1' x='0' y='0' fill='%233a2c12'/%3E%3C/svg%3E");
|
||||||
--dither-dense: url("data:image/svg+xml,%3Csvg xmlns='http://www.w3.org/2000/svg' width='2' height='2'%3E%3Crect width='1' height='1' x='0' y='0' fill='%23d9a548'/%3E%3C/svg%3E");
|
--dither-dense: url("data:image/svg+xml,%3Csvg xmlns='http://www.w3.org/2000/svg' width='4' height='4'%3E%3Crect width='1' height='1' x='0' y='0' fill='%233a2c12'/%3E%3C/svg%3E");
|
||||||
|
}
|
||||||
|
|
||||||
|
/* ============================================================= */
|
||||||
|
/* Additional dramatic themes (user-requested). Each redefines the
|
||||||
|
full token set + an atmospheric override for a distinct look. */
|
||||||
|
/* ============================================================= */
|
||||||
|
|
||||||
|
/* ---- NEON — cyber, magenta/cyan gradients, glowing accents ---- */
|
||||||
|
:root[data-theme=neon] {
|
||||||
|
--bg:#080014; --surface:#160a32; --surface2:#221248; --border:#7a3bf0;
|
||||||
|
--text:#ece6ff; --muted:#a48fe0; --accent:#00e5ff; --accent-contrast:#06000f;
|
||||||
|
--ok:#39ff9e; --warn:#ffd23f; --err:#ff3b8b; --radius:8px;
|
||||||
|
--ink-shadow: 0 0 14px color-mix(in srgb, var(--accent) 55%, transparent);
|
||||||
|
--ink-shadow-lift: 0 0 26px color-mix(in srgb, var(--accent) 70%, transparent);
|
||||||
|
--dither: url("data:image/svg+xml,%3Csvg xmlns='http://www.w3.org/2000/svg' width='8' height='8'%3E%3Crect width='1' height='1' x='0' y='0' fill='%23221046'/%3E%3C/svg%3E");
|
||||||
|
--dither-dense: url("data:image/svg+xml,%3Csvg xmlns='http://www.w3.org/2000/svg' width='4' height='4'%3E%3Crect width='1' height='1' x='0' y='0' fill='%23221046'/%3E%3C/svg%3E");
|
||||||
|
}
|
||||||
|
:root[data-theme=neon] body {
|
||||||
|
background:
|
||||||
|
radial-gradient(900px 500px at 12% -8%, rgba(255,59,214,0.28), transparent 60%),
|
||||||
|
radial-gradient(900px 500px at 92% 4%, rgba(0,229,255,0.24), transparent 60%),
|
||||||
|
linear-gradient(170deg, #0c0020 0%, #080014 55%, #04000c 100%);
|
||||||
|
background-attachment: fixed;
|
||||||
|
}
|
||||||
|
:root[data-theme=neon] .card,
|
||||||
|
:root[data-theme=neon] .kanban-col {
|
||||||
|
border-color: color-mix(in srgb, var(--accent) 45%, var(--border));
|
||||||
|
box-shadow: 0 0 0 1px color-mix(in srgb, var(--accent) 18%, transparent),
|
||||||
|
0 0 22px rgba(122,59,240,0.18);
|
||||||
|
}
|
||||||
|
:root[data-theme=neon] .btn.primary {
|
||||||
|
background: linear-gradient(120deg, #00e5ff, #b34dff);
|
||||||
|
border: none; color: #06000f;
|
||||||
|
box-shadow: 0 0 16px rgba(0,229,255,0.5);
|
||||||
|
}
|
||||||
|
:root[data-theme=neon] h1, :root[data-theme=neon] .brand {
|
||||||
|
text-shadow: 0 0 18px rgba(0,229,255,0.45);
|
||||||
|
}
|
||||||
|
|
||||||
|
/* ---- TERMINAL — green phosphor CRT, mono everything, scanlines ---- */
|
||||||
|
:root[data-theme=terminal] {
|
||||||
|
--bg:#000a02; --surface:#04140a; --surface2:#06200f; --border:#1f8f3f;
|
||||||
|
--text:#41ff7a; --muted:#1fb04f; --accent:#7dff9f; --accent-contrast:#021006;
|
||||||
|
--ok:#7dff9f; --warn:#d7ff3f; --err:#ff5d5d; --radius:0px;
|
||||||
|
--ink-shadow: 0 0 10px rgba(65,255,122,0.4);
|
||||||
|
--ink-shadow-lift: 0 0 18px rgba(65,255,122,0.55);
|
||||||
|
/* interlaced CRT scanlines (horizontal lines, not dots) */
|
||||||
|
--dither: url("data:image/svg+xml,%3Csvg xmlns='http://www.w3.org/2000/svg' width='1' height='3'%3E%3Crect width='1' height='1' x='0' y='0' fill='%230a3315'/%3E%3C/svg%3E");
|
||||||
|
--dither-dense: url("data:image/svg+xml,%3Csvg xmlns='http://www.w3.org/2000/svg' width='1' height='2'%3E%3Crect width='1' height='1' x='0' y='0' fill='%230a3315'/%3E%3C/svg%3E");
|
||||||
|
--font-display: "Spline Sans Mono", ui-monospace, monospace;
|
||||||
|
--font-body: "Spline Sans Mono", ui-monospace, monospace;
|
||||||
|
}
|
||||||
|
:root[data-theme=terminal] body {
|
||||||
|
background:
|
||||||
|
repeating-linear-gradient(0deg, rgba(0,0,0,0) 0 2px, rgba(0,40,15,0.55) 2px 3px),
|
||||||
|
radial-gradient(120% 90% at 50% 0%, #03240f 0%, #000a02 70%);
|
||||||
|
background-attachment: fixed;
|
||||||
|
text-shadow: 0 0 6px rgba(65,255,122,0.35);
|
||||||
|
}
|
||||||
|
:root[data-theme=terminal] h1 { text-transform: uppercase; letter-spacing: 0.1em; }
|
||||||
|
|
||||||
|
/* ---- BLUEPRINT — technical drawing, graph paper, cyan ink ---- */
|
||||||
|
:root[data-theme=blueprint] {
|
||||||
|
--bg:#082a4d; --surface:#0c3460; --surface2:#114576; --border:#6fb8ff;
|
||||||
|
--text:#eaf4ff; --muted:#9cc6f0; --accent:#ffd24a; --accent-contrast:#082a4d;
|
||||||
|
--ok:#7fffd4; --warn:#ffd24a; --err:#ff9a8a; --radius:0px;
|
||||||
|
--ink-shadow: 4px 4px 0 rgba(0,0,0,0.3);
|
||||||
|
--ink-shadow-lift: 6px 6px 0 rgba(0,0,0,0.4);
|
||||||
|
--dither: url("data:image/svg+xml,%3Csvg xmlns='http://www.w3.org/2000/svg' width='8' height='8'%3E%3Crect width='1' height='1' x='0' y='0' fill='%230e3c68'/%3E%3C/svg%3E");
|
||||||
|
--dither-dense: url("data:image/svg+xml,%3Csvg xmlns='http://www.w3.org/2000/svg' width='4' height='4'%3E%3Crect width='1' height='1' x='0' y='0' fill='%230e3c68'/%3E%3C/svg%3E");
|
||||||
|
}
|
||||||
|
:root[data-theme=blueprint] body {
|
||||||
|
background:
|
||||||
|
repeating-linear-gradient(0deg, transparent 0 23px, rgba(111,184,255,0.16) 23px 24px),
|
||||||
|
repeating-linear-gradient(90deg, transparent 0 23px, rgba(111,184,255,0.16) 23px 24px),
|
||||||
|
#082a4d;
|
||||||
|
background-attachment: fixed;
|
||||||
|
}
|
||||||
|
:root[data-theme=blueprint] .card,
|
||||||
|
:root[data-theme=blueprint] .kanban-col {
|
||||||
|
background: rgba(12,52,96,0.82);
|
||||||
|
border: 1px solid var(--border);
|
||||||
|
}
|
||||||
|
|
||||||
|
/* ---- SUNSET — warm vaporwave gradient, soft pastel ink ---- */
|
||||||
|
:root[data-theme=sunset] {
|
||||||
|
--bg:#2a1138; --surface:#3a1a4e; --surface2:#4d2363; --border:#ff8ec7;
|
||||||
|
--text:#fff0fb; --muted:#e0a9d6; --accent:#ffb347; --accent-contrast:#2a1138;
|
||||||
|
--ok:#7ef0c4; --warn:#ffd76a; --err:#ff6b8e; --radius:12px;
|
||||||
|
--ink-shadow: 0 8px 24px rgba(255,107,142,0.25);
|
||||||
|
--ink-shadow-lift: 0 12px 34px rgba(255,142,199,0.35);
|
||||||
|
--dither: url("data:image/svg+xml,%3Csvg xmlns='http://www.w3.org/2000/svg' width='8' height='8'%3E%3Crect width='1' height='1' x='0' y='0' fill='%233a1a4e'/%3E%3C/svg%3E");
|
||||||
|
--dither-dense: url("data:image/svg+xml,%3Csvg xmlns='http://www.w3.org/2000/svg' width='4' height='4'%3E%3Crect width='1' height='1' x='0' y='0' fill='%233a1a4e'/%3E%3C/svg%3E");
|
||||||
|
}
|
||||||
|
:root[data-theme=sunset] body {
|
||||||
|
background:
|
||||||
|
linear-gradient(180deg, #ff6b8e 0%, #b14d9c 26%, #5e2a72 55%, #2a1138 100%);
|
||||||
|
background-attachment: fixed;
|
||||||
|
}
|
||||||
|
:root[data-theme=sunset] .card,
|
||||||
|
:root[data-theme=sunset] .kanban-col {
|
||||||
|
background: rgba(58,26,78,0.7);
|
||||||
|
backdrop-filter: blur(6px);
|
||||||
|
border: 1px solid color-mix(in srgb, var(--border) 60%, transparent);
|
||||||
|
}
|
||||||
|
:root[data-theme=sunset] .btn.primary {
|
||||||
|
background: linear-gradient(120deg, #ffb347, #ff6b8e);
|
||||||
|
border: none; color: #2a1138;
|
||||||
}
|
}
|
||||||
|
|
||||||
/* ---- base ---- */
|
/* ---- base ---- */
|
||||||
@@ -100,7 +208,7 @@ h1 { font-size: 2.1rem; }
|
|||||||
h1::after {
|
h1::after {
|
||||||
content: "";
|
content: "";
|
||||||
display: block;
|
display: block;
|
||||||
width: 88px; height: 8px;
|
width: 100%; height: 8px;
|
||||||
margin-top: 10px;
|
margin-top: 10px;
|
||||||
background-image: var(--dither-dense);
|
background-image: var(--dither-dense);
|
||||||
border-bottom: 2px solid var(--accent);
|
border-bottom: 2px solid var(--accent);
|
||||||
@@ -177,7 +285,8 @@ table, .badge, input[type=number] { font-variant-numeric: tabular-nums; }
|
|||||||
.topnav .brand:hover { text-decoration: none; }
|
.topnav .brand:hover { text-decoration: none; }
|
||||||
.topnav .links { display: flex; gap: 2px; flex: 1; flex-wrap: wrap; }
|
.topnav .links { display: flex; gap: 2px; flex: 1; flex-wrap: wrap; }
|
||||||
.topnav .links a {
|
.topnav .links a {
|
||||||
color: var(--muted);
|
/* brighter than --muted so menu items stay readable on dark themes */
|
||||||
|
color: color-mix(in srgb, var(--text) 72%, var(--muted));
|
||||||
padding: 7px 11px;
|
padding: 7px 11px;
|
||||||
border-radius: var(--radius);
|
border-radius: var(--radius);
|
||||||
font-weight: 500;
|
font-weight: 500;
|
||||||
@@ -196,6 +305,56 @@ table, .badge, input[type=number] { font-variant-numeric: tabular-nums; }
|
|||||||
}
|
}
|
||||||
.topnav .right { display: flex; align-items: center; gap: 8px; }
|
.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: 16px; z-index: 41;
|
||||||
|
font-size: 1rem;
|
||||||
|
/* keep a long brand name inside the sidebar instead of overflowing it */
|
||||||
|
width: 182px;
|
||||||
|
white-space: normal;
|
||||||
|
line-height: 1.15;
|
||||||
|
}
|
||||||
|
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);
|
||||||
|
/* top padding clears the (possibly two-line) brand above */
|
||||||
|
padding: 76px 12px 18px;
|
||||||
|
overflow-y: auto;
|
||||||
|
z-index: 40;
|
||||||
|
}
|
||||||
|
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 ---- */
|
/* ---- cards: printed stock with hard Y2K shadows ---- */
|
||||||
.card {
|
.card {
|
||||||
background: var(--surface);
|
background: var(--surface);
|
||||||
@@ -348,6 +507,22 @@ table.list tbody tr:nth-child(even) { background: color-mix(in srgb, var(--surfa
|
|||||||
|
|
||||||
.grid { display: grid; gap: 18px; grid-template-columns: repeat(auto-fill, minmax(280px, 1fr)); }
|
.grid { display: grid; gap: 18px; grid-template-columns: repeat(auto-fill, minmax(280px, 1fr)); }
|
||||||
.grid > .card { display: flex; flex-direction: column; } /* equal-height rows */
|
.grid > .card { display: flex; flex-direction: column; } /* equal-height rows */
|
||||||
|
/* pin the action row to the bottom so buttons line up across cards */
|
||||||
|
.grid > .card > .spread:last-child { margin-top: auto; }
|
||||||
|
|
||||||
|
/* Audit log: span the full screen width; long detail values wrap, never overflow */
|
||||||
|
#tab-audit {
|
||||||
|
width: calc(100vw - 32px);
|
||||||
|
margin-left: calc(50% - 50vw + 16px);
|
||||||
|
margin-right: calc(50% - 50vw + 16px);
|
||||||
|
}
|
||||||
|
body[data-nav=side] #tab-audit {
|
||||||
|
width: calc(100vw - 214px - 32px);
|
||||||
|
margin-left: 0;
|
||||||
|
margin-right: 0;
|
||||||
|
}
|
||||||
|
#audit-table td, #audit-table th { white-space: normal; overflow-wrap: anywhere; vertical-align: top; }
|
||||||
|
#audit-table td:last-child { font-family: var(--font-mono); font-size: 0.82rem; }
|
||||||
|
|
||||||
/* ---- toolbar: filter rows boxed and baseline-aligned ---- */
|
/* ---- toolbar: filter rows boxed and baseline-aligned ---- */
|
||||||
.toolbar {
|
.toolbar {
|
||||||
@@ -391,6 +566,7 @@ table.list tbody tr:nth-child(even) { background: color-mix(in srgb, var(--surfa
|
|||||||
|
|
||||||
.muted { color: var(--muted); }
|
.muted { color: var(--muted); }
|
||||||
.spread { display: flex; justify-content: space-between; align-items: center; gap: 16px; }
|
.spread { display: flex; justify-content: space-between; align-items: center; gap: 16px; }
|
||||||
|
.login-actions { display: flex; flex-wrap: wrap; align-items: center; gap: 10px; margin-top: 16px; }
|
||||||
.stack > * + * { margin-top: 16px; }
|
.stack > * + * { margin-top: 16px; }
|
||||||
.mt { margin-top: 16px; }
|
.mt { margin-top: 16px; }
|
||||||
|
|
||||||
@@ -458,8 +634,8 @@ table.list tbody tr:nth-child(even) { background: color-mix(in srgb, var(--surfa
|
|||||||
}
|
}
|
||||||
|
|
||||||
/* ---- chat ---- */
|
/* ---- chat ---- */
|
||||||
.chat-layout { display: grid; grid-template-columns: 280px 1fr; gap: 18px; min-height: 70vh; }
|
.chat-layout { display: grid; grid-template-columns: 280px 1fr; gap: 18px; height: calc(100vh - 150px); }
|
||||||
.chat-sidebar { overflow: auto; }
|
.chat-sidebar { overflow: auto; min-height: 0; }
|
||||||
.conv-list { list-style: none; margin: 8px 0 0; padding: 0; }
|
.conv-list { list-style: none; margin: 8px 0 0; padding: 0; }
|
||||||
.conv-list li { border-bottom: var(--hairline); }
|
.conv-list li { border-bottom: var(--hairline); }
|
||||||
.conv-list li.active .conv-item {
|
.conv-list li.active .conv-item {
|
||||||
@@ -473,8 +649,10 @@ table.list tbody tr:nth-child(even) { background: color-mix(in srgb, var(--surfa
|
|||||||
border-radius: var(--radius);
|
border-radius: var(--radius);
|
||||||
}
|
}
|
||||||
.conv-item:hover { background: var(--surface2); }
|
.conv-item:hover { background: var(--surface2); }
|
||||||
.chat-main { display: flex; flex-direction: column; }
|
.chat-main { display: flex; flex-direction: column; min-height: 0; }
|
||||||
.chat-messages { flex: 1; overflow: auto; padding: 8px 0; min-height: 320px; }
|
/* flex:1 + min-height:0 lets the message list scroll internally instead of
|
||||||
|
growing the whole page */
|
||||||
|
.chat-messages { flex: 1; overflow-y: auto; padding: 8px 0; min-height: 0; }
|
||||||
.chat-msg {
|
.chat-msg {
|
||||||
max-width: 75%; margin: 10px 0; padding: 9px 13px;
|
max-width: 75%; margin: 10px 0; padding: 9px 13px;
|
||||||
background: var(--surface2);
|
background: var(--surface2);
|
||||||
@@ -527,6 +705,26 @@ table.list tbody tr:nth-child(even) { background: color-mix(in srgb, var(--surfa
|
|||||||
letter-spacing: 0.16em;
|
letter-spacing: 0.16em;
|
||||||
color: var(--muted);
|
color: var(--muted);
|
||||||
}
|
}
|
||||||
|
/* Project (customer) label on bounty cards */
|
||||||
|
.card-project {
|
||||||
|
font-family: var(--font-mono);
|
||||||
|
font-size: 0.68rem;
|
||||||
|
text-transform: uppercase;
|
||||||
|
letter-spacing: 0.12em;
|
||||||
|
color: var(--accent);
|
||||||
|
margin: 6px 0 0;
|
||||||
|
}
|
||||||
|
/* My Tasks: stack status columns vertically so each task gets full width */
|
||||||
|
.kanban.mt { grid-template-columns: 1fr; }
|
||||||
|
.kanban.mt .kanban-col { min-height: 0; }
|
||||||
|
/* Card action rows: wrap and right-align so buttons never overflow the card */
|
||||||
|
.kanban .card .spread { flex-wrap: wrap; }
|
||||||
|
.kanban .card [data-actions] {
|
||||||
|
display: flex;
|
||||||
|
gap: 8px;
|
||||||
|
flex-wrap: wrap;
|
||||||
|
justify-content: flex-end;
|
||||||
|
}
|
||||||
|
|
||||||
/* ---- notification bell ---- */
|
/* ---- notification bell ---- */
|
||||||
.bell-badge {
|
.bell-badge {
|
||||||
@@ -574,6 +772,44 @@ 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 chips + links inside chat/comments ---- */
|
||||||
|
.mention {
|
||||||
|
color: var(--accent);
|
||||||
|
font-weight: 600;
|
||||||
|
cursor: pointer;
|
||||||
|
border-radius: var(--radius);
|
||||||
|
padding: 0 2px;
|
||||||
|
white-space: nowrap;
|
||||||
|
}
|
||||||
|
.mention:hover {
|
||||||
|
background: color-mix(in srgb, var(--accent) 16%, transparent);
|
||||||
|
text-decoration: underline;
|
||||||
|
}
|
||||||
|
/* links in messages and comments are underlined so they read as clickable */
|
||||||
|
.chat-msg a:not(.btn), .cw-msg a:not(.btn), .comment-body a:not(.btn) {
|
||||||
|
color: var(--accent);
|
||||||
|
text-decoration: underline;
|
||||||
|
text-underline-offset: 2px;
|
||||||
|
font-weight: 500;
|
||||||
|
}
|
||||||
|
|
||||||
|
/* ---- @-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);
|
||||||
@@ -625,6 +861,19 @@ legend {
|
|||||||
#nc-results .nc-empty { color: var(--muted); padding: 10px; font-size: 0.88rem; }
|
#nc-results .nc-empty { color: var(--muted); padding: 10px; font-size: 0.88rem; }
|
||||||
#nc-selected { min-height: 30px; display: flex; gap: 6px; flex-wrap: wrap; }
|
#nc-selected { min-height: 30px; display: flex; gap: 6px; flex-wrap: wrap; }
|
||||||
|
|
||||||
|
/* members dialog: fixed width, vertically-stacked results (no horizontal growth) */
|
||||||
|
#members-dialog > .stack { width: 440px; max-width: 86vw; }
|
||||||
|
#mm-list li { justify-content: space-between; }
|
||||||
|
#mm-results { max-height: 200px; overflow: auto; margin-top: 6px; }
|
||||||
|
#mm-results .nc-row {
|
||||||
|
display: block; width: 100%; text-align: left;
|
||||||
|
font: inherit; font-size: 0.92rem;
|
||||||
|
background: none; border: none; color: var(--text);
|
||||||
|
padding: 8px 10px; cursor: pointer;
|
||||||
|
border-bottom: var(--hairline); border-radius: var(--radius);
|
||||||
|
}
|
||||||
|
#mm-results .nc-row:hover { background: var(--surface2); }
|
||||||
|
|
||||||
/* ---- floating messages bubble + mini panel ---- */
|
/* ---- floating messages bubble + mini panel ---- */
|
||||||
.chat-fab {
|
.chat-fab {
|
||||||
position: fixed; bottom: 16px; right: 16px; z-index: 2147483645;
|
position: fixed; bottom: 16px; right: 16px; z-index: 2147483645;
|
||||||
|
|||||||
@@ -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) {
|
function openCustomerDialog(customer) {
|
||||||
editingCustomer = customer || null;
|
editingCustomer = customer || null;
|
||||||
document.getElementById('customer-dialog-title').textContent =
|
document.getElementById('customer-dialog-title').textContent =
|
||||||
@@ -73,6 +103,7 @@ function openCustomerDialog(customer) {
|
|||||||
[...sel.options].forEach((o) => {
|
[...sel.options].forEach((o) => {
|
||||||
o.selected = customer ? customer.consultantIds.includes(o.value) : false;
|
o.selected = customer ? customer.consultantIds.includes(o.value) : false;
|
||||||
});
|
});
|
||||||
|
renderIdentities();
|
||||||
document.getElementById('c-test-result').textContent =
|
document.getElementById('c-test-result').textContent =
|
||||||
customer && customer.ticketing.hasCredentials ? 'Stored credentials are kept unless you enter new ones.' : '';
|
customer && customer.ticketing.hasCredentials ? 'Stored credentials are kept unless you enter new ones.' : '';
|
||||||
dlg.showModal();
|
dlg.showModal();
|
||||||
@@ -114,6 +145,11 @@ document.getElementById('customer-form').addEventListener('submit', async (e) =>
|
|||||||
defaultBudget: parseFloat(val('c-budget')) || 0,
|
defaultBudget: parseFloat(val('c-budget')) || 0,
|
||||||
consultantIds: [...document.getElementById('c-consultants').selectedOptions].map((o) => o.value),
|
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);
|
if (!editingCustomer || credsEntered(type)) body.credentials = credsFromForm(type);
|
||||||
try {
|
try {
|
||||||
if (editingCustomer) {
|
if (editingCustomer) {
|
||||||
@@ -326,6 +362,7 @@ async function loadAudit(reset = true) {
|
|||||||
try {
|
try {
|
||||||
const entity = encodeURIComponent(document.getElementById('audit-entity').value.trim());
|
const entity = encodeURIComponent(document.getElementById('audit-entity').value.trim());
|
||||||
if (reset) auditCursor = '';
|
if (reset) auditCursor = '';
|
||||||
|
else if (!auditCursor) return; // already at the last page
|
||||||
const res = await api('GET', `/api/v1/admin/audit-log?entityId=${entity}&cursor=${auditCursor}`);
|
const res = await api('GET', `/api/v1/admin/audit-log?entityId=${entity}&cursor=${auditCursor}`);
|
||||||
auditCursor = res.nextCursor;
|
auditCursor = res.nextCursor;
|
||||||
const rows = res.entries.map((e) => {
|
const rows = res.entries.map((e) => {
|
||||||
@@ -344,6 +381,7 @@ async function loadAudit(reset = true) {
|
|||||||
const tbody = document.querySelector('#audit-table tbody');
|
const tbody = document.querySelector('#audit-table tbody');
|
||||||
if (reset) tbody.replaceChildren(...rows);
|
if (reset) tbody.replaceChildren(...rows);
|
||||||
else tbody.append(...rows);
|
else tbody.append(...rows);
|
||||||
|
document.getElementById('audit-more').hidden = !auditCursor;
|
||||||
} catch (e) { showErr(e); }
|
} catch (e) { showErr(e); }
|
||||||
}
|
}
|
||||||
document.getElementById('audit-apply').addEventListener('click', () => loadAudit(true));
|
document.getElementById('audit-apply').addEventListener('click', () => loadAudit(true));
|
||||||
|
|||||||
@@ -0,0 +1,44 @@
|
|||||||
|
// Archive page: archived tasks scoped by role (server-side), with search.
|
||||||
|
import { api } from '/static/js/api.js';
|
||||||
|
|
||||||
|
const host = document.getElementById('archive-list');
|
||||||
|
const errorBox = document.getElementById('error');
|
||||||
|
const search = document.getElementById('archive-search');
|
||||||
|
let names = {};
|
||||||
|
|
||||||
|
const esc = (s) => String(s ?? '').replace(/[&<>"']/g, (c) => ({
|
||||||
|
'&': '&', '<': '<', '>': '>', '"': '"', "'": ''',
|
||||||
|
}[c]));
|
||||||
|
|
||||||
|
function render(tasks) {
|
||||||
|
host.replaceChildren(...tasks.map((t) => {
|
||||||
|
const card = document.createElement('div');
|
||||||
|
card.className = 'card';
|
||||||
|
const when = t.updatedAt ? new Date(t.updatedAt).toLocaleString() : '';
|
||||||
|
card.innerHTML = `
|
||||||
|
<div class="spread">
|
||||||
|
${names[t.customerId] ? `<span class="card-project">${esc(names[t.customerId])}</span>` : '<span></span>'}
|
||||||
|
<span class="badge accent">◈ ${t.bounty}</span>
|
||||||
|
</div>
|
||||||
|
<h3 class="mt"><a href="/tasks/${t.id}">${esc(t.title)}</a></h3>
|
||||||
|
<p class="muted">${esc((t.description || '').slice(0, 200))}</p>
|
||||||
|
<p class="muted" style="font-size:0.8rem">archived${when ? ' · ' + esc(when) : ''}</p>`;
|
||||||
|
return card;
|
||||||
|
}));
|
||||||
|
if (!tasks.length) {
|
||||||
|
host.innerHTML = '<p class="muted">No archived tasks match.</p>';
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
let timer = null;
|
||||||
|
async function load() {
|
||||||
|
try {
|
||||||
|
const q = search.value.trim();
|
||||||
|
const res = await api('GET', '/api/v1/archive' + (q ? '?q=' + encodeURIComponent(q) : ''));
|
||||||
|
names = res.customerNames || {};
|
||||||
|
render(res.tasks || []);
|
||||||
|
} catch (e) { errorBox.textContent = e.message; }
|
||||||
|
}
|
||||||
|
|
||||||
|
search.addEventListener('input', () => { clearTimeout(timer); timer = setTimeout(load, 250); });
|
||||||
|
load();
|
||||||
@@ -11,6 +11,12 @@ let tasks = [];
|
|||||||
let atomizerUp = true;
|
let atomizerUp = true;
|
||||||
const selected = new Set();
|
const selected = new Set();
|
||||||
|
|
||||||
|
// Hold Ctrl/Cmd while dragging an effort slider to rebalance siblings.
|
||||||
|
let ctrlHeld = false;
|
||||||
|
window.addEventListener('keydown', (e) => { if (e.key === 'Control' || e.key === 'Meta') ctrlHeld = true; });
|
||||||
|
window.addEventListener('keyup', (e) => { if (e.key === 'Control' || e.key === 'Meta') ctrlHeld = false; });
|
||||||
|
window.addEventListener('blur', () => { ctrlHeld = false; });
|
||||||
|
|
||||||
function esc(s) {
|
function esc(s) {
|
||||||
return String(s ?? '').replace(/[&<>"']/g, (c) => ({
|
return String(s ?? '').replace(/[&<>"']/g, (c) => ({
|
||||||
'&': '&', '<': '<', '>': '>', '"': '"', "'": ''',
|
'&': '&', '<': '<', '>': '>', '"': '"', "'": ''',
|
||||||
@@ -82,7 +88,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>
|
||||||
@@ -115,14 +121,20 @@ function renderChildren(parent, kids, depth) {
|
|||||||
wrap.className = 'stack mt';
|
wrap.className = 'stack mt';
|
||||||
|
|
||||||
const subdivided = kids.filter((k) => k.origin === 'subdivided');
|
const subdivided = kids.filter((k) => k.origin === 'subdivided');
|
||||||
const sum = subdivided.reduce((acc, k) => acc + k.effortCoefficient, 0);
|
|
||||||
const hasExtension = kids.some((k) => k.origin === 'extended');
|
const hasExtension = kids.some((k) => k.origin === 'extended');
|
||||||
if (subdivided.length) {
|
const subSiblings = []; // {k, slider, coeffEl, bountyEl} for adjustable subdivided rows
|
||||||
const ind = document.createElement('p');
|
let ind = null;
|
||||||
const bad = !hasExtension && Math.abs(sum - 1) > 0.005;
|
function updateSumIndicator() {
|
||||||
ind.innerHTML = `Coefficient sum: <strong style="color:${bad ? 'var(--err)' : 'var(--ok)'}">${sum.toFixed(2)}</strong>` +
|
if (!ind) return;
|
||||||
|
const s = subSiblings.reduce((a, x) => a + parseFloat(x.slider.value), 0);
|
||||||
|
const bad = !hasExtension && Math.abs(s - 1) > 0.005;
|
||||||
|
ind.innerHTML = `Coefficient sum: <strong style="color:${bad ? 'var(--err)' : 'var(--ok)'}">${s.toFixed(2)}</strong>` +
|
||||||
(hasExtension ? ' <span class="muted">(extensions allow > 1.00)</span>'
|
(hasExtension ? ' <span class="muted">(extensions allow > 1.00)</span>'
|
||||||
: bad ? ' — should be 1.00' : '');
|
: bad ? ' — should be 1.00' : '') +
|
||||||
|
' <span class="muted" style="font-size:0.82rem">· hold Ctrl while dragging to rebalance the others</span>';
|
||||||
|
}
|
||||||
|
if (subdivided.length) {
|
||||||
|
ind = document.createElement('p');
|
||||||
wrap.appendChild(ind);
|
wrap.appendChild(ind);
|
||||||
}
|
}
|
||||||
|
|
||||||
@@ -135,7 +147,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>
|
||||||
@@ -157,25 +169,54 @@ function renderChildren(parent, kids, depth) {
|
|||||||
</span>
|
</span>
|
||||||
</div>`;
|
</div>`;
|
||||||
const slider = row.querySelector('input[type=range]');
|
const slider = row.querySelector('input[type=range]');
|
||||||
|
const coeffEl = row.querySelector('[data-coeff]');
|
||||||
|
const bountyEl = row.querySelector('[data-bounty]');
|
||||||
|
if (k.origin === 'subdivided' && !slider.disabled) {
|
||||||
|
subSiblings.push({ k, slider, coeffEl, bountyEl });
|
||||||
|
}
|
||||||
let sliderTimer = null;
|
let sliderTimer = null;
|
||||||
|
const round2 = (n) => Math.round(n * 100) / 100;
|
||||||
slider.addEventListener('input', () => {
|
slider.addEventListener('input', () => {
|
||||||
row.querySelector('[data-coeff]').textContent = parseFloat(slider.value).toFixed(2);
|
const newVal = parseFloat(slider.value);
|
||||||
row.querySelector('[data-bounty]').textContent = (slider.value * k.budget).toFixed(2);
|
coeffEl.textContent = newVal.toFixed(2);
|
||||||
|
bountyEl.textContent = (newVal * k.budget).toFixed(2);
|
||||||
|
const changed = [{ k, value: newVal }];
|
||||||
|
// Ctrl/Cmd held: rebalance the other subdivided siblings so the sum
|
||||||
|
// stays ~1.00 (increase one → the rest shrink proportionally).
|
||||||
|
if (ctrlHeld && k.origin === 'subdivided') {
|
||||||
|
const others = subSiblings.filter((s) => s.k.id !== k.id);
|
||||||
|
const curOthers = others.reduce((a, s) => a + parseFloat(s.slider.value), 0);
|
||||||
|
const targetOthers = Math.max(0, 1 - newVal);
|
||||||
|
if (others.length && curOthers > 0) {
|
||||||
|
const scale = targetOthers / curOthers;
|
||||||
|
others.forEach((s) => {
|
||||||
|
const max = parseFloat(s.slider.max);
|
||||||
|
const nv = round2(Math.max(0.01, Math.min(max, parseFloat(s.slider.value) * scale)));
|
||||||
|
s.slider.value = nv;
|
||||||
|
s.coeffEl.textContent = nv.toFixed(2);
|
||||||
|
s.bountyEl.textContent = (nv * s.k.budget).toFixed(2);
|
||||||
|
changed.push({ k: s.k, value: nv });
|
||||||
|
});
|
||||||
|
}
|
||||||
|
updateSumIndicator();
|
||||||
|
}
|
||||||
clearTimeout(sliderTimer);
|
clearTimeout(sliderTimer);
|
||||||
sliderTimer = setTimeout(async () => {
|
sliderTimer = setTimeout(async () => {
|
||||||
try {
|
try {
|
||||||
const res = await api('PATCH', `/api/v1/tasks/${k.id}`, {
|
for (const ch of changed) {
|
||||||
effortCoefficient: parseFloat(slider.value), version: k.version,
|
const res = await api('PATCH', `/api/v1/tasks/${ch.k.id}`, {
|
||||||
|
effortCoefficient: ch.value, version: ch.k.version,
|
||||||
});
|
});
|
||||||
k.version = res.task.version;
|
ch.k.version = res.task.version;
|
||||||
k.effortCoefficient = res.task.effortCoefficient;
|
ch.k.effortCoefficient = res.task.effortCoefficient;
|
||||||
k.bounty = res.task.bounty;
|
ch.k.bounty = res.task.bounty;
|
||||||
|
}
|
||||||
render();
|
render();
|
||||||
} catch (e) {
|
} catch (e) {
|
||||||
if (e.status === 409) { toast('Task changed elsewhere — reloading.', 'err'); load(); }
|
if (e.status === 409) { toast('Task changed elsewhere — reloading.', 'err'); load(); }
|
||||||
else toast(e.message, 'err');
|
else toast(e.message, 'err');
|
||||||
}
|
}
|
||||||
}, 400);
|
}, 450);
|
||||||
});
|
});
|
||||||
const sel = row.querySelector('[data-sel]');
|
const sel = row.querySelector('[data-sel]');
|
||||||
if (sel) {
|
if (sel) {
|
||||||
@@ -200,6 +241,7 @@ function renderChildren(parent, kids, depth) {
|
|||||||
const grand = childrenOf(k.id);
|
const grand = childrenOf(k.id);
|
||||||
if (grand.length) wrap.appendChild(renderChildren(k, grand, depth + 1));
|
if (grand.length) wrap.appendChild(renderChildren(k, grand, depth + 1));
|
||||||
});
|
});
|
||||||
|
updateSumIndicator();
|
||||||
return wrap;
|
return wrap;
|
||||||
}
|
}
|
||||||
|
|
||||||
|
|||||||
@@ -4,7 +4,9 @@ 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 esc = (s) => String(s ?? '').replace(/[&<>"']/g, (c) => ({
|
const esc = (s) => String(s ?? '').replace(/[&<>"']/g, (c) => ({
|
||||||
'&': '&', '<': '<', '>': '>', '"': '"', "'": ''',
|
'&': '&', '<': '<', '>': '>', '"': '"', "'": ''',
|
||||||
@@ -25,6 +27,7 @@ async function load() {
|
|||||||
const qs = new URLSearchParams(f).toString();
|
const qs = new URLSearchParams(f).toString();
|
||||||
const res = await api('GET', '/api/v1/board?' + qs);
|
const res = await api('GET', '/api/v1/board?' + qs);
|
||||||
tasks = res.tasks;
|
tasks = res.tasks;
|
||||||
|
(res.customers || []).forEach((c) => customerNames.set(c.id, c.name));
|
||||||
const sel = document.getElementById('f-customer');
|
const sel = document.getElementById('f-customer');
|
||||||
if (sel.options.length === 1) {
|
if (sel.options.length === 1) {
|
||||||
res.customers.forEach((c) => sel.add(new Option(c.name, c.id)));
|
res.customers.forEach((c) => sel.add(new Option(c.name, c.id)));
|
||||||
@@ -58,6 +61,7 @@ function renderCard(t) {
|
|||||||
<span class="badge accent" style="font-size:1rem">◈ ${t.bounty}</span>
|
<span class="badge accent" style="font-size:1rem">◈ ${t.bounty}</span>
|
||||||
<span>${ageBadge(t)}</span>
|
<span>${ageBadge(t)}</span>
|
||||||
</div>
|
</div>
|
||||||
|
${customerNames.has(t.customerId) ? `<p class="muted card-project">${esc(customerNames.get(t.customerId))}</p>` : ''}
|
||||||
<h3 class="mt"><a href="/tasks/${t.id}">${esc(t.title)}</a></h3>
|
<h3 class="mt"><a href="/tasks/${t.id}">${esc(t.title)}</a></h3>
|
||||||
<p class="muted">${esc((t.description || '').slice(0, 180))}</p>
|
<p class="muted">${esc((t.description || '').slice(0, 180))}</p>
|
||||||
<p class="muted">${(t.acceptanceCriteria || []).length} acceptance criteria</p>
|
<p class="muted">${(t.acceptanceCriteria || []).length} acceptance criteria</p>
|
||||||
@@ -66,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>`;
|
||||||
@@ -118,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, mentionHTML } 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 = '';
|
||||||
@@ -112,14 +113,17 @@ if (document.body.dataset.loggedIn === '1' && location.pathname !== '/messages')
|
|||||||
input.value = '';
|
input.value = '';
|
||||||
try {
|
try {
|
||||||
await api('POST', `/api/v1/conversations/${current.id}/messages`, {
|
await api('POST', `/api/v1/conversations/${current.id}/messages`, {
|
||||||
body: '<p>' + esc(text) + '</p>',
|
body: '<p>' + mentionHTML(text, input) + '</p>',
|
||||||
});
|
});
|
||||||
} 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,168 @@
|
|||||||
|
// @-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;
|
||||||
|
el._mentions = el._mentions || new Map(); // display name -> user id
|
||||||
|
|
||||||
|
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) {
|
||||||
|
el._mentions.set(u.name, u.id);
|
||||||
|
if (isCE) {
|
||||||
|
// insert an atomic mention chip + a trailing space in the text node
|
||||||
|
const node = ctx.node;
|
||||||
|
const full = node.textContent;
|
||||||
|
const after = full.slice(ctx.end);
|
||||||
|
node.textContent = full.slice(0, ctx.start);
|
||||||
|
const span = document.createElement('span');
|
||||||
|
span.className = 'mention';
|
||||||
|
span.dataset.userCard = u.id;
|
||||||
|
span.contentEditable = 'false';
|
||||||
|
span.textContent = '@' + u.name;
|
||||||
|
// the chip is an atomic, non-editable element, so the trailing space in
|
||||||
|
// this separate text node stays put as the user keeps typing
|
||||||
|
const tail = document.createTextNode(' ' + after);
|
||||||
|
const parent = node.parentNode;
|
||||||
|
const ref = node.nextSibling;
|
||||||
|
parent.insertBefore(span, ref);
|
||||||
|
parent.insertBefore(tail, ref);
|
||||||
|
const sel = window.getSelection();
|
||||||
|
const range = document.createRange();
|
||||||
|
range.setStart(tail, 1);
|
||||||
|
range.collapse(true);
|
||||||
|
sel.removeAllRanges();
|
||||||
|
sel.addRange(range);
|
||||||
|
} else {
|
||||||
|
const text = '@' + u.name + ' ';
|
||||||
|
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));
|
||||||
|
}
|
||||||
|
|
||||||
|
// mentionHTML turns the plain text of a textarea/input into safe HTML, wrapping
|
||||||
|
// any recorded "@Name" run in a mention span carrying the user id. Names with
|
||||||
|
// spaces are matched exactly (longest first) against what the picker inserted.
|
||||||
|
export function mentionHTML(text, el) {
|
||||||
|
const mentions = (el && el._mentions) || new Map();
|
||||||
|
const names = [...mentions.keys()].sort((a, b) => b.length - a.length);
|
||||||
|
let out = '';
|
||||||
|
let i = 0;
|
||||||
|
while (i < text.length) {
|
||||||
|
if (text[i] === '@') {
|
||||||
|
const name = names.find((n) => text.startsWith('@' + n, i));
|
||||||
|
if (name) {
|
||||||
|
out += `<span class="mention" data-user-card="${escapeHtml(mentions.get(name))}">@${escapeHtml(name)}</span>`;
|
||||||
|
i += 1 + name.length;
|
||||||
|
continue;
|
||||||
|
}
|
||||||
|
}
|
||||||
|
out += escapeHtml(text[i]);
|
||||||
|
i += 1;
|
||||||
|
}
|
||||||
|
return out;
|
||||||
|
}
|
||||||
|
|
||||||
|
function escapeHtml(s) {
|
||||||
|
return String(s ?? '').replace(/[&<>"']/g, (c) => ({
|
||||||
|
'&': '&', '<': '<', '>': '>', '"': '"', "'": ''',
|
||||||
|
}[c]));
|
||||||
|
}
|
||||||
+135
-4
@@ -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');
|
||||||
@@ -61,7 +62,10 @@ function renderConvList() {
|
|||||||
async function openConversation(c) {
|
async function openConversation(c) {
|
||||||
current = c;
|
current = c;
|
||||||
oldestCursor = '';
|
oldestCursor = '';
|
||||||
|
noMoreOlder = false;
|
||||||
document.getElementById('chat-title').textContent = c.title;
|
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;
|
form.hidden = false;
|
||||||
msgHost.replaceChildren();
|
msgHost.replaceChildren();
|
||||||
renderConvList();
|
renderConvList();
|
||||||
@@ -72,19 +76,36 @@ async function openConversation(c) {
|
|||||||
history.replaceState(null, '', '/messages?c=' + c.id);
|
history.replaceState(null, '', '/messages?c=' + c.id);
|
||||||
}
|
}
|
||||||
|
|
||||||
|
const MSG_PAGE = 40;
|
||||||
async function loadMessages(prepend) {
|
async function loadMessages(prepend) {
|
||||||
const res = await api('GET',
|
const res = await api('GET',
|
||||||
`/api/v1/conversations/${current.id}/messages?cursor=${prepend ? oldestCursor : ''}`);
|
`/api/v1/conversations/${current.id}/messages?limit=${MSG_PAGE}&cursor=${prepend ? oldestCursor : ''}`);
|
||||||
const msgs = res.messages;
|
const msgs = res.messages;
|
||||||
|
if (prepend && msgs.length < MSG_PAGE) noMoreOlder = true;
|
||||||
if (msgs.length) oldestCursor = msgs[0].id;
|
if (msgs.length) oldestCursor = msgs[0].id;
|
||||||
const nodes = await Promise.all(msgs.map(renderMessage));
|
const nodes = await Promise.all(msgs.map(renderMessage));
|
||||||
if (prepend) msgHost.prepend(...nodes);
|
if (prepend) {
|
||||||
else {
|
// preserve the scroll position so the view doesn't jump when older
|
||||||
|
// messages are inserted above the current viewport
|
||||||
|
const before = msgHost.scrollHeight;
|
||||||
|
const top = msgHost.scrollTop;
|
||||||
|
msgHost.prepend(...nodes);
|
||||||
|
msgHost.scrollTop = top + (msgHost.scrollHeight - before);
|
||||||
|
} else {
|
||||||
msgHost.append(...nodes);
|
msgHost.append(...nodes);
|
||||||
msgHost.scrollTop = msgHost.scrollHeight;
|
msgHost.scrollTop = msgHost.scrollHeight;
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
|
// load older history when the user scrolls near the top
|
||||||
|
let loadingOlder = false;
|
||||||
|
let noMoreOlder = false;
|
||||||
|
msgHost.addEventListener('scroll', async () => {
|
||||||
|
if (msgHost.scrollTop > 60 || loadingOlder || noMoreOlder || !current) return;
|
||||||
|
loadingOlder = true;
|
||||||
|
try { await loadMessages(true); } finally { loadingOlder = false; }
|
||||||
|
});
|
||||||
|
|
||||||
// ULID ids embed a 48-bit ms timestamp in the first 10 chars.
|
// ULID ids embed a 48-bit ms timestamp in the first 10 chars.
|
||||||
function ulidTime(id) {
|
function ulidTime(id) {
|
||||||
const A = '0123456789ABCDEFGHJKMNPQRSTVWXYZ';
|
const A = '0123456789ABCDEFGHJKMNPQRSTVWXYZ';
|
||||||
@@ -126,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', () => {
|
||||||
@@ -197,8 +220,9 @@ const typingNames = new Map();
|
|||||||
subscribe('chat', async (event, data) => {
|
subscribe('chat', async (event, data) => {
|
||||||
if (event === 'message' && data) {
|
if (event === 'message' && data) {
|
||||||
if (current && data.conversationId === current.id) {
|
if (current && data.conversationId === current.id) {
|
||||||
|
const nearBottom = msgHost.scrollHeight - msgHost.scrollTop - msgHost.clientHeight < 120;
|
||||||
msgHost.appendChild(await renderMessage(data));
|
msgHost.appendChild(await renderMessage(data));
|
||||||
msgHost.scrollTop = msgHost.scrollHeight;
|
if (nearBottom) msgHost.scrollTop = msgHost.scrollHeight;
|
||||||
api('POST', `/api/v1/conversations/${current.id}/read`, {}).catch(() => {});
|
api('POST', `/api/v1/conversations/${current.id}/read`, {}).catch(() => {});
|
||||||
} else {
|
} else {
|
||||||
loadConversations();
|
loadConversations();
|
||||||
@@ -297,6 +321,113 @@ document.getElementById('newconv-form').addEventListener('submit', async (e) =>
|
|||||||
} catch (err) { toast(err.message, 'err'); }
|
} catch (err) { toast(err.message, 'err'); }
|
||||||
});
|
});
|
||||||
|
|
||||||
|
// ---- message search ----
|
||||||
|
const convSearch = document.getElementById('conv-search');
|
||||||
|
let msgSearchTimer = null;
|
||||||
|
async function runMessageSearch(q) {
|
||||||
|
const list = document.getElementById('conv-list');
|
||||||
|
const results = document.getElementById('search-results');
|
||||||
|
if (q.length < 2) { results.hidden = true; results.replaceChildren(); list.hidden = false; return; }
|
||||||
|
try {
|
||||||
|
const res = await api('GET', `/api/v1/messages/search?q=${encodeURIComponent(q)}`);
|
||||||
|
list.hidden = true; results.hidden = false;
|
||||||
|
results.replaceChildren(...res.results.map((r) => {
|
||||||
|
const li = document.createElement('li');
|
||||||
|
const b = document.createElement('button');
|
||||||
|
b.type = 'button';
|
||||||
|
b.className = 'conv-item';
|
||||||
|
b.style.cssText = 'flex-direction:column;align-items:flex-start;gap:2px';
|
||||||
|
b.innerHTML = `<span>${esc(r.conversationTitle)}</span>
|
||||||
|
<span class="muted" style="font-size:0.82rem">${esc(r.snippet)}</span>`;
|
||||||
|
b.addEventListener('click', () => {
|
||||||
|
const c = conversations.find((x) => x.id === r.conversationId);
|
||||||
|
if (c) {
|
||||||
|
convSearch.value = '';
|
||||||
|
results.hidden = true; list.hidden = false;
|
||||||
|
openConversation(c);
|
||||||
|
}
|
||||||
|
});
|
||||||
|
li.appendChild(b);
|
||||||
|
return li;
|
||||||
|
}));
|
||||||
|
if (!res.results.length) {
|
||||||
|
results.innerHTML = '<li class="muted" style="padding:10px">No matching messages.</li>';
|
||||||
|
}
|
||||||
|
} catch (e) { /* ignore transient search errors */ }
|
||||||
|
}
|
||||||
|
convSearch.addEventListener('input', () => {
|
||||||
|
clearTimeout(msgSearchTimer);
|
||||||
|
const q = convSearch.value.trim();
|
||||||
|
msgSearchTimer = setTimeout(() => runMessageSearch(q), 250);
|
||||||
|
});
|
||||||
|
|
||||||
|
// ---- 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 () => {
|
(async () => {
|
||||||
try {
|
try {
|
||||||
const me = await api('GET', '/api/v1/auth/me');
|
const me = await api('GET', '/api/v1/auth/me');
|
||||||
|
|||||||
@@ -16,9 +16,12 @@ const themeBtn = document.getElementById('nav-theme');
|
|||||||
if (themeBtn) {
|
if (themeBtn) {
|
||||||
themeBtn.addEventListener('click', async () => {
|
themeBtn.addEventListener('click', async () => {
|
||||||
const el = document.documentElement;
|
const el = document.documentElement;
|
||||||
const next = el.dataset.theme === 'dark' ? 'light' : 'dark';
|
const themes = (el.dataset.themes || 'light,dark').split(',');
|
||||||
|
const cur = themes.indexOf(el.dataset.theme);
|
||||||
|
const next = themes[(cur + 1) % themes.length];
|
||||||
el.dataset.theme = next;
|
el.dataset.theme = next;
|
||||||
try { localStorage.setItem('theme', next); } catch (e) { /* ignore */ }
|
try { localStorage.setItem('theme', next); } catch (e) { /* ignore */ }
|
||||||
|
toast('Theme: ' + next);
|
||||||
if (document.body.dataset.loggedIn === '1') {
|
if (document.body.dataset.loggedIn === '1') {
|
||||||
try {
|
try {
|
||||||
await api('PATCH', '/api/v1/profile', { settings: { theme: next } });
|
await api('PATCH', '/api/v1/profile', { settings: { theme: next } });
|
||||||
|
|||||||
@@ -56,12 +56,16 @@ form.addEventListener('submit', async (e) => {
|
|||||||
links,
|
links,
|
||||||
},
|
},
|
||||||
extra,
|
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;
|
version = res.user.version;
|
||||||
const theme = res.user.settings.theme;
|
const theme = res.user.settings.theme;
|
||||||
document.documentElement.dataset.theme = theme;
|
document.documentElement.dataset.theme = theme;
|
||||||
try { localStorage.setItem('theme', theme); } catch (err) { /* ignore */ }
|
try { localStorage.setItem('theme', theme); } catch (err) { /* ignore */ }
|
||||||
|
document.body.dataset.nav = res.user.settings.navLayout || 'side';
|
||||||
okBox.textContent = 'Profile saved.';
|
okBox.textContent = 'Profile saved.';
|
||||||
} catch (err) {
|
} catch (err) {
|
||||||
if (err.status === 409) {
|
if (err.status === 409) {
|
||||||
|
|||||||
@@ -0,0 +1,57 @@
|
|||||||
|
// Public profile view (/users/{id}): full bio, contact, links and custom
|
||||||
|
// details from the user's profile card.
|
||||||
|
import { api } from '/static/js/api.js';
|
||||||
|
|
||||||
|
const root = document.getElementById('profile-root');
|
||||||
|
const errorBox = document.getElementById('error');
|
||||||
|
const uid = root.dataset.userId;
|
||||||
|
|
||||||
|
const HIDDEN_EXTRA = new Set(['ticketingIdentities', 'savedBoardFilters']);
|
||||||
|
|
||||||
|
const esc = (s) => String(s ?? '').replace(/[&<>"']/g, (c) => ({
|
||||||
|
'&': '&', '<': '<', '>': '>', '"': '"', "'": ''',
|
||||||
|
}[c]));
|
||||||
|
|
||||||
|
// only allow safe link schemes; bare domains get https://, others are blocked
|
||||||
|
export function safeUrl(u) {
|
||||||
|
const s = String(u ?? '').trim();
|
||||||
|
if (/^(https?:\/\/|mailto:)/i.test(s)) return s;
|
||||||
|
if (/^[a-z][a-z0-9+.-]*:/i.test(s)) return '#';
|
||||||
|
return s ? 'https://' + s : '#';
|
||||||
|
}
|
||||||
|
|
||||||
|
async function load() {
|
||||||
|
try {
|
||||||
|
const res = await api('GET', `/api/v1/users/${uid}/card`);
|
||||||
|
const c = res.card;
|
||||||
|
const roles = ['admin', 'consultant', 'developer'].filter((r) => c.roles && c.roles[r]);
|
||||||
|
const links = ((c.contact && c.contact.links) || []).filter(Boolean);
|
||||||
|
const extra = Object.entries(c.extra || {})
|
||||||
|
.filter(([k, v]) => !HIDDEN_EXTRA.has(k) && v !== null && typeof v !== 'object');
|
||||||
|
const hasContact = (c.contact && (c.contact.phone || c.contact.location)) || links.length;
|
||||||
|
root.innerHTML = `
|
||||||
|
<div class="card">
|
||||||
|
<div style="display:flex;gap:16px;align-items:center">
|
||||||
|
${c.avatarFileId
|
||||||
|
? `<img class="avatar large" src="/files/${c.avatarFileId}" alt="">`
|
||||||
|
: `<span class="avatar large">${esc((c.name || '?').slice(0, 1).toUpperCase())}</span>`}
|
||||||
|
<div>
|
||||||
|
<h1 style="margin:0">${esc(c.name)}</h1>
|
||||||
|
<p style="margin:4px 0">${roles.map((r) => `<span class="badge">${r}</span>`).join(' ')}</p>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
${c.bio ? `<h2 class="mt">Bio</h2><p style="white-space:pre-wrap">${esc(c.bio)}</p>` : ''}
|
||||||
|
${hasContact ? '<h2 class="mt">Contact</h2>' : ''}
|
||||||
|
${c.contact && c.contact.location ? `<p class="muted">📍 ${esc(c.contact.location)}</p>` : ''}
|
||||||
|
${c.contact && c.contact.phone ? `<p class="muted">📞 ${esc(c.contact.phone)}</p>` : ''}
|
||||||
|
${links.length ? `<ul>${links.map((l) =>
|
||||||
|
`<li><a href="${esc(safeUrl(l))}" target="_blank" rel="noopener">${esc(l)}</a></li>`).join('')}</ul>` : ''}
|
||||||
|
${extra.length ? `<h2 class="mt">Details</h2>${extra.map(([k, v]) =>
|
||||||
|
`<p class="muted"><strong>${esc(k)}:</strong> ${esc(String(v))}</p>`).join('')}` : ''}
|
||||||
|
</div>`;
|
||||||
|
} catch (e) {
|
||||||
|
errorBox.textContent = 'Could not load this profile.';
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
load();
|
||||||
@@ -26,7 +26,7 @@ async function load() {
|
|||||||
return card;
|
return card;
|
||||||
}));
|
}));
|
||||||
if (!res.tasks.length) {
|
if (!res.tasks.length) {
|
||||||
host.innerHTML = '<p class="muted">Nothing waiting for review. 🎉</p>';
|
host.innerHTML = '<p class="muted">Nothing waiting for review.</p>';
|
||||||
}
|
}
|
||||||
} catch (e) { errorBox.textContent = e.message; }
|
} catch (e) { errorBox.textContent = e.message; }
|
||||||
}
|
}
|
||||||
|
|||||||
@@ -37,6 +37,14 @@ const esc = (s) => String(s ?? '').replace(/[&<>"']/g, (c) => ({
|
|||||||
'&': '&', '<': '<', '>': '>', '"': '"', "'": ''',
|
'&': '&', '<': '<', '>': '>', '"': '"', "'": ''',
|
||||||
}[c]));
|
}[c]));
|
||||||
|
|
||||||
|
// allow only safe link schemes (block javascript:/data: etc.)
|
||||||
|
const safeUrl = (u) => {
|
||||||
|
const s = String(u ?? '').trim();
|
||||||
|
if (/^(https?:\/\/|mailto:)/i.test(s)) return s;
|
||||||
|
if (/^[a-z][a-z0-9+.-]*:/i.test(s)) return '#';
|
||||||
|
return s ? 'https://' + s : '#';
|
||||||
|
};
|
||||||
|
|
||||||
function hideCard() {
|
function hideCard() {
|
||||||
if (cardEl) { cardEl.remove(); cardEl = null; }
|
if (cardEl) { cardEl.remove(); cardEl = null; }
|
||||||
}
|
}
|
||||||
@@ -49,6 +57,7 @@ async function showCard(target, userId) {
|
|||||||
cardEl = document.createElement('div');
|
cardEl = document.createElement('div');
|
||||||
cardEl.className = 'hovercard card';
|
cardEl.className = 'hovercard card';
|
||||||
const roles = ['admin', 'consultant', 'developer'].filter((r) => c.roles[r]);
|
const roles = ['admin', 'consultant', 'developer'].filter((r) => c.roles[r]);
|
||||||
|
const links = ((c.contact && c.contact.links) || []).filter(Boolean);
|
||||||
cardEl.innerHTML = `
|
cardEl.innerHTML = `
|
||||||
<div class="spread">
|
<div class="spread">
|
||||||
${c.avatarFileId ? `<img class="avatar large" src="/files/${c.avatarFileId}" alt="">`
|
${c.avatarFileId ? `<img class="avatar large" src="/files/${c.avatarFileId}" alt="">`
|
||||||
@@ -58,8 +67,12 @@ async function showCard(target, userId) {
|
|||||||
${roles.map((r) => `<span class="badge">${r}</span>`).join(' ')}
|
${roles.map((r) => `<span class="badge">${r}</span>`).join(' ')}
|
||||||
</div>
|
</div>
|
||||||
</div>
|
</div>
|
||||||
${c.bio ? `<p class="muted">${esc(c.bio.slice(0, 160))}</p>` : ''}
|
${c.bio ? `<p class="muted">${esc(c.bio.slice(0, 220))}${c.bio.length > 220 ? '…' : ''}</p>` : ''}
|
||||||
${c.contact && c.contact.location ? `<p class="muted">📍 ${esc(c.contact.location)}</p>` : ''}`;
|
${c.contact && c.contact.location ? `<p class="muted">📍 ${esc(c.contact.location)}</p>` : ''}
|
||||||
|
${c.contact && c.contact.phone ? `<p class="muted">📞 ${esc(c.contact.phone)}</p>` : ''}
|
||||||
|
${links.length ? `<p class="muted">${links.slice(0, 3).map((l) =>
|
||||||
|
`<a href="${esc(safeUrl(l))}" target="_blank" rel="noopener">🔗 ${esc(l)}</a>`).join('<br>')}</p>` : ''}
|
||||||
|
<a class="btn small mt" href="/users/${encodeURIComponent(userId)}">See full profile →</a>`;
|
||||||
document.body.appendChild(cardEl);
|
document.body.appendChild(cardEl);
|
||||||
const rect = target.getBoundingClientRect();
|
const rect = target.getBoundingClientRect();
|
||||||
cardEl.style.left = Math.min(rect.left, window.innerWidth - 320) + 'px';
|
cardEl.style.left = Math.min(rect.left, window.innerWidth - 320) + 'px';
|
||||||
@@ -81,3 +94,11 @@ document.addEventListener('mouseout', (e) => {
|
|||||||
hideTimer = setTimeout(hideCard, 250);
|
hideTimer = setTimeout(hideCard, 250);
|
||||||
}
|
}
|
||||||
});
|
});
|
||||||
|
// click a mention (or any user-card target) to open its card immediately
|
||||||
|
document.addEventListener('click', (e) => {
|
||||||
|
const target = e.target.closest('[data-user-card]');
|
||||||
|
if (!target) return;
|
||||||
|
e.preventDefault();
|
||||||
|
clearTimeout(hideTimer);
|
||||||
|
showCard(target, target.dataset.userCard);
|
||||||
|
});
|
||||||
|
|||||||
+18
-3
@@ -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, mentionHTML } 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');
|
||||||
@@ -57,7 +58,7 @@ async function render() {
|
|||||||
const comments = await Promise.all((t.comments || []).map(async (c) => `
|
const comments = await Promise.all((t.comments || []).map(async (c) => `
|
||||||
<div class="card" style="padding:12px">
|
<div class="card" style="padding:12px">
|
||||||
<p class="muted" style="margin:0 0 8px">${esc(await userName(c.authorId))} · ${new Date(c.at).toLocaleString()}</p>
|
<p class="muted" style="margin:0 0 8px">${esc(await userName(c.authorId))} · ${new Date(c.at).toLocaleString()}</p>
|
||||||
<div>${c.body}</div>
|
<div class="comment-body">${c.body}</div>
|
||||||
</div>`));
|
</div>`));
|
||||||
|
|
||||||
const timeline = await Promise.all((t.timeline || []).slice().reverse().map(async (e) => `
|
const timeline = await Promise.all((t.timeline || []).slice().reverse().map(async (e) => `
|
||||||
@@ -146,6 +147,18 @@ function renderActions() {
|
|||||||
catch (e) { toast(e.message, 'err'); }
|
catch (e) { toast(e.message, 'err'); }
|
||||||
};
|
};
|
||||||
|
|
||||||
|
// link to the upstream ticket (same affordance as the atomization board)
|
||||||
|
if (t.external && t.external.url) {
|
||||||
|
const a = document.createElement('a');
|
||||||
|
a.className = 'btn';
|
||||||
|
a.style.marginRight = '8px';
|
||||||
|
a.href = t.external.url;
|
||||||
|
a.target = '_blank';
|
||||||
|
a.rel = 'noopener';
|
||||||
|
a.textContent = 'Source ↗';
|
||||||
|
host.appendChild(a);
|
||||||
|
}
|
||||||
|
|
||||||
if (root.dataset.isDeveloper === '1') {
|
if (root.dataset.isDeveloper === '1') {
|
||||||
if (t.status === 'published' && !myClaim()) add('Request assignment', post('claim', { note: '' }), true);
|
if (t.status === 'published' && !myClaim()) add('Request assignment', post('claim', { note: '' }), true);
|
||||||
if (myClaim() && ['published', 'claim_requested'].includes(t.status)) add('Withdraw request', post('claim/withdraw'));
|
if (myClaim() && ['published', 'claim_requested'].includes(t.status)) add('Withdraw request', post('claim/withdraw'));
|
||||||
@@ -182,12 +195,14 @@ function wireHandlers() {
|
|||||||
});
|
});
|
||||||
document.getElementById('comment-form').addEventListener('submit', async (e) => {
|
document.getElementById('comment-form').addEventListener('submit', async (e) => {
|
||||||
e.preventDefault();
|
e.preventDefault();
|
||||||
const body = document.getElementById('comment-body').value;
|
const field = document.getElementById('comment-body');
|
||||||
|
const body = mentionHTML(field.value, field).replace(/\n/g, '<br>');
|
||||||
try {
|
try {
|
||||||
await api('POST', `/api/v1/tasks/${taskId}/comments`, { body: '<p>' + esc(body).replace(/\n/g, '<br>') + '</p>' });
|
await api('POST', `/api/v1/tasks/${taskId}/comments`, { body: '<p>' + body + '</p>' });
|
||||||
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) => {
|
||||||
|
|||||||
@@ -5,7 +5,9 @@
|
|||||||
var el = document.documentElement;
|
var el = document.documentElement;
|
||||||
var saved = null;
|
var saved = null;
|
||||||
try { saved = localStorage.getItem('theme'); } catch (e) { /* private mode */ }
|
try { saved = localStorage.getItem('theme'); } catch (e) { /* private mode */ }
|
||||||
|
var THEMES = ['light', 'dark', 'neon', 'terminal', 'blueprint', 'sunset'];
|
||||||
var theme = saved || el.dataset.defaultTheme || 'light';
|
var theme = saved || el.dataset.defaultTheme || 'light';
|
||||||
if (theme !== 'light' && theme !== 'dark') theme = 'light';
|
if (THEMES.indexOf(theme) === -1) theme = 'light';
|
||||||
el.dataset.theme = theme;
|
el.dataset.theme = theme;
|
||||||
|
el.dataset.themes = THEMES.join(',');
|
||||||
})();
|
})();
|
||||||
|
|||||||
@@ -84,6 +84,11 @@
|
|||||||
<select id="c-consultants" multiple size="4"></select>
|
<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>
|
<p class="hint">Hold Ctrl/Cmd to select multiple. At least one is needed for syncing.</p>
|
||||||
</div>
|
</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">
|
<div class="spread">
|
||||||
<span id="c-test-result" class="hint" aria-live="polite"></span>
|
<span id="c-test-result" class="hint" aria-live="polite"></span>
|
||||||
<span>
|
<span>
|
||||||
|
|||||||
@@ -0,0 +1,9 @@
|
|||||||
|
{{define "content"}}
|
||||||
|
<h1>Archive</h1>
|
||||||
|
<div class="error-box" id="error" role="alert"></div>
|
||||||
|
<input type="search" id="archive-search" class="mt"
|
||||||
|
placeholder="Search archived tasks…" aria-label="Search archived tasks"
|
||||||
|
style="max-width:440px; width:100%">
|
||||||
|
<p class="muted" style="margin-top:6px">Archived tasks you can access.</p>
|
||||||
|
<div class="grid mt" id="archive-list"></div>
|
||||||
|
{{end}}
|
||||||
@@ -3,18 +3,20 @@
|
|||||||
<head>
|
<head>
|
||||||
<meta charset="utf-8">
|
<meta charset="utf-8">
|
||||||
<meta name="viewport" content="width=device-width, initial-scale=1">
|
<meta name="viewport" content="width=device-width, initial-scale=1">
|
||||||
<title>{{.Title}} · Bounty Board</title>
|
<title>{{.Title}} · {{.Brand}}</title>
|
||||||
<link rel="stylesheet" href="/static/css/app.css">
|
<link rel="stylesheet" href="/static/css/app.css">
|
||||||
<script src="/static/js/theme.js"></script>
|
<script src="/static/js/theme.js"></script>
|
||||||
</head>
|
</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">
|
<nav class="topnav">
|
||||||
<a class="brand" href="/">Bounty Board</a>
|
<a class="brand" href="/">{{.Brand}}</a>
|
||||||
<div class="links">
|
<div class="links">
|
||||||
{{if .User}}
|
{{if .User}}
|
||||||
{{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>
|
||||||
@@ -26,6 +28,7 @@
|
|||||||
{{end}}
|
{{end}}
|
||||||
<a href="/messages" {{if eq .Active "messages"}}aria-current="page"{{end}}>Messages</a>
|
<a href="/messages" {{if eq .Active "messages"}}aria-current="page"{{end}}>Messages</a>
|
||||||
<a href="/metrics" {{if eq .Active "metrics"}}aria-current="page"{{end}}>Metrics</a>
|
<a href="/metrics" {{if eq .Active "metrics"}}aria-current="page"{{end}}>Metrics</a>
|
||||||
|
<a href="/archive" {{if eq .Active "archive"}}aria-current="page"{{end}}>Archive</a>
|
||||||
{{end}}
|
{{end}}
|
||||||
</div>
|
</div>
|
||||||
<div class="right">
|
<div class="right">
|
||||||
|
|||||||
@@ -1,7 +1,7 @@
|
|||||||
{{define "content"}}
|
{{define "content"}}
|
||||||
<header class="masthead">
|
<header class="masthead">
|
||||||
<p class="masthead-kicker">Consulting Work Exchange</p>
|
<p class="masthead-kicker">Consulting Work Exchange</p>
|
||||||
<p class="masthead-title">◈ Bounty Board</p>
|
<p class="masthead-title">◈ {{.Brand}}</p>
|
||||||
<p class="masthead-rule">❦</p>
|
<p class="masthead-rule">❦</p>
|
||||||
</header>
|
</header>
|
||||||
<div class="card">
|
<div class="card">
|
||||||
@@ -16,15 +16,13 @@
|
|||||||
<label for="password">Password</label>
|
<label for="password">Password</label>
|
||||||
<input type="password" id="password" name="password" autocomplete="current-password" required>
|
<input type="password" id="password" name="password" autocomplete="current-password" required>
|
||||||
</div>
|
</div>
|
||||||
<div class="spread">
|
<div class="login-actions">
|
||||||
<button class="btn primary" type="submit">Log in</button>
|
<button class="btn primary" type="submit">Log in</button>
|
||||||
<span><a href="/forgot-password">Forgot password?</a> · <a href="/register">Create an account</a></span>
|
{{if .OIDCEnabled}}<a class="btn" href="/api/v1/auth/oidc/login">Continue with SSO</a>{{end}}
|
||||||
</div>
|
</div>
|
||||||
</form>
|
</form>
|
||||||
{{if .OIDCEnabled}}
|
<p class="muted mt" style="margin-bottom:0">
|
||||||
<div class="mt">
|
<a href="/forgot-password">Forgot password?</a> · <a href="/register">Create an account</a>
|
||||||
<a class="btn" href="/api/v1/auth/oidc/login">Continue with SSO</a>
|
</p>
|
||||||
</div>
|
|
||||||
{{end}}
|
|
||||||
</div>
|
</div>
|
||||||
{{end}}
|
{{end}}
|
||||||
|
|||||||
@@ -6,12 +6,17 @@
|
|||||||
<h2>Messages</h2>
|
<h2>Messages</h2>
|
||||||
<button class="btn small primary" id="conv-new">+ New</button>
|
<button class="btn small primary" id="conv-new">+ New</button>
|
||||||
</div>
|
</div>
|
||||||
|
<input type="search" id="conv-search" class="mt" placeholder="Search messages…" aria-label="Search messages">
|
||||||
<ul id="conv-list" class="conv-list"></ul>
|
<ul id="conv-list" class="conv-list"></ul>
|
||||||
|
<ul id="search-results" class="conv-list" hidden></ul>
|
||||||
</aside>
|
</aside>
|
||||||
<section class="chat-main card">
|
<section class="chat-main card">
|
||||||
<div class="spread">
|
<div class="spread">
|
||||||
<h2 id="chat-title">Select a conversation</h2>
|
<h2 id="chat-title">Select a conversation</h2>
|
||||||
|
<span style="display:flex;align-items:center;gap:10px">
|
||||||
<span class="muted" id="typing-indicator" aria-live="polite"></span>
|
<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>
|
||||||
<div id="chat-messages" class="chat-messages" aria-live="polite"></div>
|
<div id="chat-messages" class="chat-messages" aria-live="polite"></div>
|
||||||
<form id="chat-form" hidden>
|
<form id="chat-form" hidden>
|
||||||
@@ -66,6 +71,21 @@
|
|||||||
</form>
|
</form>
|
||||||
</dialog>
|
</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">
|
<dialog id="lightbox" class="lightbox">
|
||||||
<img id="lightbox-img" alt="">
|
<img id="lightbox-img" alt="">
|
||||||
</dialog>
|
</dialog>
|
||||||
|
|||||||
@@ -58,7 +58,18 @@
|
|||||||
<label for="p-theme">Theme</label>
|
<label for="p-theme">Theme</label>
|
||||||
<select id="p-theme">
|
<select id="p-theme">
|
||||||
<option value="light" {{if eq .User.Settings.Theme "light"}}selected{{end}}>Light (Y2K paper)</option>
|
<option value="light" {{if eq .User.Settings.Theme "light"}}selected{{end}}>Light (Y2K paper)</option>
|
||||||
<option value="dark" {{if eq .User.Settings.Theme "dark"}}selected{{end}}>Dark</option>
|
<option value="dark" {{if eq .User.Settings.Theme "dark"}}selected{{end}}>Dark (Y2K ink)</option>
|
||||||
|
<option value="neon" {{if eq .User.Settings.Theme "neon"}}selected{{end}}>Neon (cyber gradient)</option>
|
||||||
|
<option value="terminal" {{if eq .User.Settings.Theme "terminal"}}selected{{end}}>Terminal (green CRT)</option>
|
||||||
|
<option value="blueprint" {{if eq .User.Settings.Theme "blueprint"}}selected{{end}}>Blueprint (graph paper)</option>
|
||||||
|
<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>
|
</select>
|
||||||
</div>
|
</div>
|
||||||
|
|
||||||
|
|||||||
@@ -0,0 +1,4 @@
|
|||||||
|
{{define "content"}}
|
||||||
|
<div class="error-box" id="error" role="alert"></div>
|
||||||
|
<div id="profile-root" data-user-id="{{.Data.UserID}}"></div>
|
||||||
|
{{end}}
|
||||||
@@ -1,7 +1,7 @@
|
|||||||
{{define "content"}}
|
{{define "content"}}
|
||||||
<header class="masthead">
|
<header class="masthead">
|
||||||
<p class="masthead-kicker">Consulting Work Exchange</p>
|
<p class="masthead-kicker">Consulting Work Exchange</p>
|
||||||
<p class="masthead-title">◈ Bounty Board</p>
|
<p class="masthead-title">◈ {{.Brand}}</p>
|
||||||
<p class="masthead-rule">❦</p>
|
<p class="masthead-rule">❦</p>
|
||||||
</header>
|
</header>
|
||||||
<div class="card">
|
<div class="card">
|
||||||
|
|||||||
Reference in New Issue
Block a user