style: global dark form inputs + team emoji avatars (#23)

Form inputs:
- Add a catch-all CSS rule targeting all text-type inputs, selects, and
  textareas not already covered by .form-group or .form-input — sets
  background:--bg2, color:--text, focus ring. Fixes white-box appearance
  in transactions, goals, settings, portfolio, import, people, and tax
  templates without touching any HTML.

Team avatars:
- OrgTeam.Avatar string (emoji) — persisted in MongoDB, defaults to 👥
- OrgTeamCreate handler reads "avatar" form field
- org_teams.html: emoji picker (30 options) in new-team modal; preview
  updates live; selected emoji highlighted with accent border
- avatarEmojis() and teamAvatar() template functions registered in FuncMap
- Team badges in org_members, org_events, org_event_detail, org_report
  all show the emoji inline with the team name

Co-authored-by: Gonçalo Rodrigues <guga@Goncalos-MacBook-Pro.local>
Co-authored-by: Claude Sonnet 4.6 <noreply@anthropic.com>
This commit is contained in:
Gonçalo Rodrigues 2026-06-14 16:08:29 +01:00 committed by GitHub
parent ceeee2a46a
commit 26a7236494
9 changed files with 119 additions and 11 deletions

View File

@ -118,6 +118,19 @@ func parseTmpl(files ...string) *template.Template {
return "var(--bg3); color:var(--text3)" return "var(--bg3); color:var(--text3)"
} }
}, },
"avatarEmojis": func() []string {
return []string{
"👥", "🚀", "⚡", "🎯", "🏆", "💡", "🔥", "🌊", "🎨", "🛠️",
"📊", "🎪", "🌿", "🦋", "🏄", "🎸", "🔬", "📡", "🏗️", "🎭",
"🌍", "🦁", "🐉", "🦅", "🐋", "🌙", "⭐", "🍀", "🔮", "🎲",
}
},
"teamAvatar": func(t OrgTeam) string {
if t.Avatar != "" {
return t.Avatar
}
return "👥"
},
"varColor": func(planned, actual int64) string { "varColor": func(planned, actual int64) string {
if planned == 0 { if planned == 0 {
return "var(--text2)" return "var(--text2)"

View File

@ -229,11 +229,16 @@ func (h *Handler) OrgTeamCreate(w http.ResponseWriter, r *http.Request) {
http.Error(w, "name required", http.StatusBadRequest) http.Error(w, "name required", http.StatusBadRequest)
return return
} }
avatar := r.FormValue("avatar")
if avatar == "" {
avatar = "👥"
}
team := &OrgTeam{ team := &OrgTeam{
ID: bson.NewObjectID().Hex(), ID: bson.NewObjectID().Hex(),
OrgID: org.ID, OrgID: org.ID,
Name: name, Name: name,
Type: teamType, Type: teamType,
Avatar: avatar,
CreatedAt: time.Now(), CreatedAt: time.Now(),
} }
if err := h.store.createTeam(ctx, team); err != nil { if err := h.store.createTeam(ctx, team); err != nil {

View File

@ -35,6 +35,7 @@ type OrgTeam struct {
OrgID string `bson:"org_id" json:"org_id"` OrgID string `bson:"org_id" json:"org_id"`
Name string `bson:"name" json:"name"` Name string `bson:"name" json:"name"`
Type TeamType `bson:"type" json:"type"` Type TeamType `bson:"type" json:"type"`
Avatar string `bson:"avatar" json:"avatar"` // single emoji
CreatedAt time.Time `bson:"created_at" json:"created_at"` CreatedAt time.Time `bson:"created_at" json:"created_at"`
} }

View File

@ -396,11 +396,67 @@
cursor: pointer; cursor: pointer;
font-family: inherit; font-family: inherit;
} }
/* Global catch-all: any bare input/select/textarea gets dark theme.
.form-group and .form-input rules above still win for their elements
due to specificity; this only fixes uncovered stragglers. */
input:not([type=checkbox]):not([type=radio]):not([type=range]):not([type=submit]):not([type=button]):not([type=reset]):not([type=hidden]):not([type=color]),
select,
textarea {
background: var(--bg2);
color: var(--text);
border-color: var(--border2);
font-family: inherit;
}
input:not([type=checkbox]):not([type=radio]):not([type=range]):not([type=submit]):not([type=button]):not([type=reset]):not([type=hidden]):not([type=color]):focus,
select:focus,
textarea:focus {
outline: none;
border-color: var(--accent);
box-shadow: 0 0 0 3px var(--accent-glow);
}
input::placeholder, textarea::placeholder { color: var(--text3); opacity: 0.7; }
select option { background: var(--bg2); color: var(--text); }
input[type="color"] { padding: 4px; height: 38px; cursor: pointer; } input[type="color"] { padding: 4px; height: 38px; cursor: pointer; }
select option,
.form-input option { background: var(--bg2); color: var(--text); } .form-input option { background: var(--bg2); color: var(--text); }
textarea.form-input { resize: vertical; min-height: 72px; line-height: 1.5; } textarea.form-input { resize: vertical; min-height: 72px; line-height: 1.5; }
/* Team / org avatars */
.team-avatar {
display: inline-flex; align-items: center; justify-content: center;
width: 32px; height: 32px;
border-radius: 8px;
font-size: 17px;
line-height: 1;
flex-shrink: 0;
background: var(--bg3);
border: 1px solid var(--border2);
user-select: none;
}
.team-avatar-sm {
width: 22px; height: 22px;
border-radius: 5px;
font-size: 12px;
}
.team-avatar-lg {
width: 48px; height: 48px;
border-radius: 12px;
font-size: 26px;
}
/* Emoji picker row */
.emoji-picker { display: flex; flex-wrap: wrap; gap: 6px; }
.emoji-opt {
width: 36px; height: 36px;
border-radius: 8px;
border: 2px solid transparent;
background: var(--bg3);
font-size: 18px;
cursor: pointer;
display: flex; align-items: center; justify-content: center;
transition: border-color 0.12s, background 0.12s;
}
.emoji-opt:hover { background: var(--surface2); }
.emoji-opt.selected { border-color: var(--accent); background: var(--accent-glow); }
/* ── Badges ──────────────────────────────────────────────────────── */ /* ── Badges ──────────────────────────────────────────────────────── */
.badge { .badge {
display: inline-flex; align-items: center; gap: 5px; display: inline-flex; align-items: center; gap: 5px;

View File

@ -118,7 +118,7 @@
</div> </div>
<div> <div>
<div style="color:var(--text3); font-size:11px; font-weight:700; letter-spacing:.06em; margin-bottom:4px;">TEAMS</div> <div style="color:var(--text3); font-size:11px; font-weight:700; letter-spacing:.06em; margin-bottom:4px;">TEAMS</div>
<div>{{if $d.EventTeams}}{{range $d.EventTeams}}<span style="display:inline-block; padding:1px 6px; border-radius:4px; background:var(--bg3); margin-right:3px; font-size:11px;">{{.Name}}</span>{{end}}{{else}}—{{end}}</div> <div style="display:flex; flex-wrap:wrap; gap:4px;">{{if $d.EventTeams}}{{range $d.EventTeams}}<span style="display:inline-flex; align-items:center; gap:3px; padding:2px 8px; border-radius:4px; background:var(--bg3); font-size:11px;"><span style="font-size:13px;">{{if .Avatar}}{{.Avatar}}{{else}}👥{{end}}</span>{{.Name}}</span>{{end}}{{else}}<span style="color:var(--text3);"></span>{{end}}</div>
</div> </div>
</div> </div>
{{if $d.Event.Description}} {{if $d.Event.Description}}

View File

@ -50,7 +50,7 @@
<tr> <tr>
<td style="font-weight:600;">{{.Event.Name}}</td> <td style="font-weight:600;">{{.Event.Name}}</td>
<td style="font-size:12px; color:var(--text3);"> <td style="font-size:12px; color:var(--text3);">
{{if .Teams}}{{range .Teams}}<span style="display:inline-block; padding:1px 6px; border-radius:4px; background:var(--bg3); margin-right:3px; font-size:11px;">{{.Name}}</span>{{end}}{{else}}—{{end}} {{if .Teams}}{{range .Teams}}<span style="display:inline-flex; align-items:center; gap:3px; padding:2px 7px; border-radius:4px; background:var(--bg3); margin-right:3px; font-size:11px;"><span style="font-size:13px;">{{if .Avatar}}{{.Avatar}}{{else}}👥{{end}}</span>{{.Name}}</span>{{end}}{{else}}—{{end}}
</td> </td>
<td style="font-size:12px; color:var(--text2);">{{dateShort .Event.DateStart}} — {{dateShort .Event.DateEnd}}</td> <td style="font-size:12px; color:var(--text2);">{{dateShort .Event.DateStart}} — {{dateShort .Event.DateEnd}}</td>
<td style="text-align:right; color:var(--green); font-size:13px;">{{cents .TotalIncome}}</td> <td style="text-align:right; color:var(--green); font-size:13px;">{{cents .TotalIncome}}</td>

View File

@ -43,11 +43,17 @@
<span style="font-size:11px; font-weight:600; padding:3px 9px; border-radius:20px; background:var(--accent-glow); color:var(--accent2);">{{$m.Role}}</span> <span style="font-size:11px; font-weight:600; padding:3px 9px; border-radius:20px; background:var(--accent-glow); color:var(--accent2);">{{$m.Role}}</span>
{{end}} {{end}}
</td> </td>
<td style="font-size:12px; color:var(--text2);"> <td style="font-size:12px;">
{{if $m.TeamIDs}} {{if $m.TeamIDs}}
{{range $i, $tid := $m.TeamIDs}} <div style="display:flex; flex-wrap:wrap; gap:4px;">
{{range $d.Teams}}{{if eq .ID $tid}}{{if $i}}, {{end}}{{.Name}}{{end}}{{end}} {{range $tid := $m.TeamIDs}}
{{range $d.Teams}}{{if eq .ID $tid}}
<span style="display:inline-flex; align-items:center; gap:3px; padding:2px 7px; border-radius:4px; background:var(--bg3); color:var(--text2);">
<span style="font-size:13px; line-height:1;">{{if .Avatar}}{{.Avatar}}{{else}}👥{{end}}</span>{{.Name}}
</span>
{{end}}{{end}}
{{end}} {{end}}
</div>
{{else}} {{else}}
<span style="color:var(--text3);">all teams</span> <span style="color:var(--text3);">all teams</span>
{{end}} {{end}}

View File

@ -78,7 +78,7 @@
<div style="font-size:12px; color:var(--text3);">{{dateShort $ev.DateStart}} — {{dateShort $ev.DateEnd}}</div> <div style="font-size:12px; color:var(--text3);">{{dateShort $ev.DateStart}} — {{dateShort $ev.DateEnd}}</div>
{{if .Teams}} {{if .Teams}}
<div style="margin-top:6px;"> <div style="margin-top:6px;">
{{range .Teams}}<span style="display:inline-block; padding:1px 6px; border-radius:4px; background:var(--bg3); margin-right:3px; font-size:11px; color:var(--text2);">{{.Name}}</span>{{end}} {{range .Teams}}<span style="display:inline-flex; align-items:center; gap:3px; padding:2px 7px; border-radius:4px; background:var(--bg3); margin-right:3px; font-size:11px; color:var(--text2);"><span style="font-size:13px;">{{if .Avatar}}{{.Avatar}}{{else}}👥{{end}}</span>{{.Name}}</span>{{end}}
</div> </div>
{{end}} {{end}}
</div> </div>

View File

@ -19,7 +19,7 @@
<table> <table>
<thead> <thead>
<tr> <tr>
<th>Name</th> <th>Team</th>
<th>Type</th> <th>Type</th>
<th>Members</th> <th>Members</th>
{{if eq $d.MyRole "admin"}}<th></th>{{end}} {{if eq $d.MyRole "admin"}}<th></th>{{end}}
@ -30,7 +30,12 @@
{{$count := 0}} {{$count := 0}}
{{range $d.Members}}{{range .TeamIDs}}{{if eq . $t.ID}}{{$count = add $count 1}}{{end}}{{end}}{{end}} {{range $d.Members}}{{range .TeamIDs}}{{if eq . $t.ID}}{{$count = add $count 1}}{{end}}{{end}}{{end}}
<tr> <tr>
<td style="font-weight:600;">{{$t.Name}}</td> <td>
<div style="display:flex; align-items:center; gap:10px;">
<span class="team-avatar">{{if $t.Avatar}}{{$t.Avatar}}{{else}}👥{{end}}</span>
<span style="font-weight:600;">{{$t.Name}}</span>
</div>
</td>
<td> <td>
{{if eq (print $t.Type) "guest"}} {{if eq (print $t.Type) "guest"}}
<span style="font-size:11px; font-weight:600; padding:3px 9px; border-radius:20px; background:rgba(251,191,36,0.1); color:#fbbf24;">guest</span> <span style="font-size:11px; font-weight:600; padding:3px 9px; border-radius:20px; background:rgba(251,191,36,0.1); color:#fbbf24;">guest</span>
@ -64,13 +69,27 @@
{{end}} {{end}}
{{if eq $d.MyRole "admin"}} {{if eq $d.MyRole "admin"}}
<div id="new-team-modal" style="display:none; position:fixed; inset:0; background:rgba(0,0,0,0.6); z-index:500; align-items:center; justify-content:center;"> <div id="new-team-modal" style="display:none; position:fixed; inset:0; background:rgba(0,0,0,0.6); z-index:500; align-items:center; justify-content:center; padding:16px;">
<div class="card" style="width:100%; max-width:400px; margin:16px;"> <div class="card" style="width:100%; max-width:420px;">
<div style="display:flex; justify-content:space-between; align-items:center; margin-bottom:20px;"> <div style="display:flex; justify-content:space-between; align-items:center; margin-bottom:20px;">
<h2>New team</h2> <h2>New team</h2>
<button onclick="document.getElementById('new-team-modal').style.display='none'" style="background:none; border:none; color:var(--text3); font-size:20px; cursor:pointer;">×</button> <button onclick="document.getElementById('new-team-modal').style.display='none'" style="background:none; border:none; color:var(--text3); font-size:20px; cursor:pointer;">×</button>
</div> </div>
<form method="post" action="/orgs/{{$d.Org.Slug}}/teams"> <form method="post" action="/orgs/{{$d.Org.Slug}}/teams">
<!-- Avatar picker -->
<div style="margin-bottom:16px;">
<label class="form-label">Avatar</label>
<input type="hidden" name="avatar" id="avatar-val" value="👥">
<div style="display:flex; align-items:center; gap:12px; margin-bottom:10px;">
<span class="team-avatar team-avatar-lg" id="avatar-preview">👥</span>
<span style="font-size:12px; color:var(--text3);">Pick an emoji below</span>
</div>
<div class="emoji-picker">
{{range (avatarEmojis)}}
<button type="button" class="emoji-opt" onclick="pickEmoji(this, '{{.}}')">{{.}}</button>
{{end}}
</div>
</div>
<div style="margin-bottom:14px;"> <div style="margin-bottom:14px;">
<label class="form-label">Team name</label> <label class="form-label">Team name</label>
<input class="form-input" type="text" name="name" placeholder="Marketing" required autofocus> <input class="form-input" type="text" name="name" placeholder="Marketing" required autofocus>
@ -86,5 +105,13 @@
</form> </form>
</div> </div>
</div> </div>
<script>
function pickEmoji(btn, emoji) {
document.querySelectorAll('.emoji-opt').forEach(b => b.classList.remove('selected'));
btn.classList.add('selected');
document.getElementById('avatar-val').value = emoji;
document.getElementById('avatar-preview').textContent = emoji;
}
</script>
{{end}} {{end}}
{{end}} {{end}}