fix(web): admin users cards redesigned for mobile readability

The single-line card squeezed identity, created date, role select, and
two buttons into one row — on a phone the username truncated to t…,
the email to te…, and the created date wrapped vertically one fragment
per line (10- / 13- / 2026 / …). Restructured into stacked full-width
rows: identity (avatar + username + email), created on its own line
(a full date never wraps), a labeled Role row under a divider, and
Edit/Delete as side-by-side flex-1 buttons (thumb-sized on mobile,
right-aligned sm+). Last-admin protections render identically in both
controls. Verified on the emulator (Pixel 9, Brave): full names/emails
visible, no wrapping, modal + role controls functional.
This commit is contained in:
John O'Keefe committed 2026-10-13 15:00:59 -04:00
1 parent 505fc48b23
commit 2a6e4b9ae5
2 files changed
+44 -35

No files matched your search

+20 -11
View File
@@ -112,14 +112,15 @@ templ AdminUsers(currentUser User, users []User, adminCount int) {
@adminCreateUserForm() @adminCreateUserForm()
<!-- Users: fluid card list (same pattern as library management — <!-- Users: card list. Stacked rows on purpose — identity, created,
no table, reflows at every width including with the nav role, and actions each get their own full-width row so nothing
sidebar open). --> truncates into confusion on a phone. -->
<div class="space-y-4"> <div class="space-y-4">
for _, user := range users { for _, user := range users {
<div id={ "user-" + user.ID } class="card overflow-hidden"> <div id={ "user-" + user.ID } class="card overflow-hidden">
<div class="flex items-center justify-between gap-4 p-5"> <div class="p-4 sm:p-5">
<div class="flex items-center gap-3 min-w-0 flex-1"> <!-- Identity -->
<div class="flex items-center gap-3">
<span class="grid place-items-center h-10 w-10 rounded-xl shrink-0" style="background-color: var(--accent-muted); color: var(--accent);"> <span class="grid place-items-center h-10 w-10 rounded-xl shrink-0" style="background-color: var(--accent-muted); color: var(--accent);">
@Icon("user", "h-5 w-5") @Icon("user", "h-5 w-5")
</span> </span>
@@ -131,10 +132,15 @@ templ AdminUsers(currentUser User, users []User, adminCount int) {
} }
</div> </div>
<p class="text-sm truncate" style="color: var(--text-secondary)">{ user.Email }</p> <p class="text-sm truncate" style="color: var(--text-secondary)">{ user.Email }</p>
<p class="text-xs" style="color: var(--text-secondary)">Created { FormatInTimezone(user.CreatedAt, currentUser.Timezone) }</p>
</div> </div>
</div> </div>
<div class="flex flex-wrap items-center justify-end gap-2 shrink-0"> <!-- Created: its own line — a full date never wraps -->
<p class="text-xs mt-2" style="color: var(--text-secondary)">
Created { FormatInTimezone(user.CreatedAt, currentUser.Timezone) }
</p>
<!-- Role -->
<div class="mt-3 pt-3 border-t flex items-center justify-between gap-3" style="border-color: var(--border)">
<span class="text-xs font-semibold uppercase tracking-wide" style="color: var(--text-secondary)">Role</span>
if user.Role == "admin" && adminCount == 1 { if user.Role == "admin" && adminCount == 1 {
<!-- Last admin - disabled --> <!-- Last admin - disabled -->
<select <select
@@ -157,13 +163,16 @@ templ AdminUsers(currentUser User, users []User, adminCount int) {
<option value="user" selected?={ user.Role == "user" }>User</option> <option value="user" selected?={ user.Role == "user" }>User</option>
<option value="admin" selected?={ user.Role == "admin" }>Admin</option> <option value="admin" selected?={ user.Role == "admin" }>Admin</option>
</select> </select>
<div id={ "role-result-" + user.ID } class="text-xs w-full text-right"></div>
} }
</div>
<div id={ "role-result-" + user.ID } class="text-xs mt-1"></div>
<!-- Actions -->
<div class="mt-3 flex gap-2">
<button <button
hx-get={ "/admin/users/" + user.ID + "/profile-modal" } hx-get={ "/admin/users/" + user.ID + "/profile-modal" }
hx-target="#modal-container" hx-target="#modal-container"
hx-swap="innerHTML" hx-swap="innerHTML"
class="btn btn-secondary text-xs px-2.5 py-1" class="btn btn-secondary text-sm px-3 py-1.5 flex-1 sm:flex-none sm:ml-auto"
> >
@Icon("edit", "h-4 w-4") @Icon("edit", "h-4 w-4")
Edit Edit
@@ -171,7 +180,7 @@ templ AdminUsers(currentUser User, users []User, adminCount int) {
if user.Role == "admin" && adminCount == 1 { if user.Role == "admin" && adminCount == 1 {
<button <button
disabled disabled
class="btn btn-danger text-xs px-2.5 py-1" class="btn btn-danger text-sm px-3 py-1.5 flex-1 sm:flex-none"
title="Cannot delete the last admin" title="Cannot delete the last admin"
> >
@Icon("trash", "h-4 w-4") @Icon("trash", "h-4 w-4")
@@ -183,7 +192,7 @@ templ AdminUsers(currentUser User, users []User, adminCount int) {
hx-target={ "#user-" + user.ID } hx-target={ "#user-" + user.ID }
hx-swap="outerHTML swap:0.5s" hx-swap="outerHTML swap:0.5s"
hx-confirm="Are you sure you want to delete this user? This action cannot be undone." hx-confirm="Are you sure you want to delete this user? This action cannot be undone."
class="btn btn-danger text-xs px-2.5 py-1" class="btn btn-danger text-sm px-3 py-1.5 flex-1 sm:flex-none"
> >
@Icon("trash", "h-4 w-4") @Icon("trash", "h-4 w-4")
Delete Delete
+24 -24
View File
@@ -94,7 +94,7 @@ func AdminUsers(currentUser User, users []User, adminCount int) templ.Component
if templ_7745c5c3_Err != nil { if templ_7745c5c3_Err != nil {
return templ_7745c5c3_Err return templ_7745c5c3_Err
} }
templ_7745c5c3_Err = templruntime.WriteString(templ_7745c5c3_Buffer, 6, "<!-- Users: fluid card list (same pattern as library management —\n\t\t\t\t no table, reflows at every width including with the nav\n\t\t\t\t sidebar open). --><div class=\"space-y-4\">") templ_7745c5c3_Err = templruntime.WriteString(templ_7745c5c3_Buffer, 6, "<!-- Users: card list. Stacked rows on purpose — identity, created,\n\t\t\t\t role, and actions each get their own full-width row so nothing\n\t\t\t\t truncates into confusion on a phone. --><div class=\"space-y-4\">")
if templ_7745c5c3_Err != nil { if templ_7745c5c3_Err != nil {
return templ_7745c5c3_Err return templ_7745c5c3_Err
} }
@@ -112,7 +112,7 @@ func AdminUsers(currentUser User, users []User, adminCount int) templ.Component
if templ_7745c5c3_Err != nil { if templ_7745c5c3_Err != nil {
return templ_7745c5c3_Err return templ_7745c5c3_Err
} }
templ_7745c5c3_Err = templruntime.WriteString(templ_7745c5c3_Buffer, 8, "\" class=\"card overflow-hidden\"><div class=\"flex items-center justify-between gap-4 p-5\"><div class=\"flex items-center gap-3 min-w-0 flex-1\"><span class=\"grid place-items-center h-10 w-10 rounded-xl shrink-0\" style=\"background-color: var(--accent-muted); color: var(--accent);\">") templ_7745c5c3_Err = templruntime.WriteString(templ_7745c5c3_Buffer, 8, "\" class=\"card overflow-hidden\"><div class=\"p-4 sm:p-5\"><!-- Identity --><div class=\"flex items-center gap-3\"><span class=\"grid place-items-center h-10 w-10 rounded-xl shrink-0\" style=\"background-color: var(--accent-muted); color: var(--accent);\">")
if templ_7745c5c3_Err != nil { if templ_7745c5c3_Err != nil {
return templ_7745c5c3_Err return templ_7745c5c3_Err
} }
@@ -127,7 +127,7 @@ func AdminUsers(currentUser User, users []User, adminCount int) templ.Component
var templ_7745c5c3_Var4 string var templ_7745c5c3_Var4 string
templ_7745c5c3_Var4, templ_7745c5c3_Err = templ.JoinStringErrs(user.Username) templ_7745c5c3_Var4, templ_7745c5c3_Err = templ.JoinStringErrs(user.Username)
if templ_7745c5c3_Err != nil { if templ_7745c5c3_Err != nil {
return templ.Error{Err: templ_7745c5c3_Err, FileName: `admin_users.templ`, Line: 128, Col: 96} return templ.Error{Err: templ_7745c5c3_Err, FileName: `admin_users.templ`, Line: 129, Col: 96}
} }
_, templ_7745c5c3_Err = templ_7745c5c3_Buffer.WriteString(templ.EscapeString(templ_7745c5c3_Var4)) _, templ_7745c5c3_Err = templ_7745c5c3_Buffer.WriteString(templ.EscapeString(templ_7745c5c3_Var4))
if templ_7745c5c3_Err != nil { if templ_7745c5c3_Err != nil {
@@ -150,31 +150,31 @@ func AdminUsers(currentUser User, users []User, adminCount int) templ.Component
var templ_7745c5c3_Var5 string var templ_7745c5c3_Var5 string
templ_7745c5c3_Var5, templ_7745c5c3_Err = templ.JoinStringErrs(user.Email) templ_7745c5c3_Var5, templ_7745c5c3_Err = templ.JoinStringErrs(user.Email)
if templ_7745c5c3_Err != nil { if templ_7745c5c3_Err != nil {
return templ.Error{Err: templ_7745c5c3_Err, FileName: `admin_users.templ`, Line: 133, Col: 87} return templ.Error{Err: templ_7745c5c3_Err, FileName: `admin_users.templ`, Line: 134, Col: 87}
} }
_, templ_7745c5c3_Err = templ_7745c5c3_Buffer.WriteString(templ.EscapeString(templ_7745c5c3_Var5)) _, templ_7745c5c3_Err = templ_7745c5c3_Buffer.WriteString(templ.EscapeString(templ_7745c5c3_Var5))
if templ_7745c5c3_Err != nil { if templ_7745c5c3_Err != nil {
return templ_7745c5c3_Err return templ_7745c5c3_Err
} }
templ_7745c5c3_Err = templruntime.WriteString(templ_7745c5c3_Buffer, 13, "</p><p class=\"text-xs\" style=\"color: var(--text-secondary)\">Created ") templ_7745c5c3_Err = templruntime.WriteString(templ_7745c5c3_Buffer, 13, "</p></div></div><!-- Created: its own line — a full date never wraps --><p class=\"text-xs mt-2\" style=\"color: var(--text-secondary)\">Created ")
if templ_7745c5c3_Err != nil { if templ_7745c5c3_Err != nil {
return templ_7745c5c3_Err return templ_7745c5c3_Err
} }
var templ_7745c5c3_Var6 string var templ_7745c5c3_Var6 string
templ_7745c5c3_Var6, templ_7745c5c3_Err = templ.JoinStringErrs(FormatInTimezone(user.CreatedAt, currentUser.Timezone)) templ_7745c5c3_Var6, templ_7745c5c3_Err = templ.JoinStringErrs(FormatInTimezone(user.CreatedAt, currentUser.Timezone))
if templ_7745c5c3_Err != nil { if templ_7745c5c3_Err != nil {
return templ.Error{Err: templ_7745c5c3_Err, FileName: `admin_users.templ`, Line: 134, Col: 130} return templ.Error{Err: templ_7745c5c3_Err, FileName: `admin_users.templ`, Line: 139, Col: 73}
} }
_, templ_7745c5c3_Err = templ_7745c5c3_Buffer.WriteString(templ.EscapeString(templ_7745c5c3_Var6)) _, templ_7745c5c3_Err = templ_7745c5c3_Buffer.WriteString(templ.EscapeString(templ_7745c5c3_Var6))
if templ_7745c5c3_Err != nil { if templ_7745c5c3_Err != nil {
return templ_7745c5c3_Err return templ_7745c5c3_Err
} }
templ_7745c5c3_Err = templruntime.WriteString(templ_7745c5c3_Buffer, 14, "</p></div></div><div class=\"flex flex-wrap items-center justify-end gap-2 shrink-0\">") templ_7745c5c3_Err = templruntime.WriteString(templ_7745c5c3_Buffer, 14, "</p><!-- Role --><div class=\"mt-3 pt-3 border-t flex items-center justify-between gap-3\" style=\"border-color: var(--border)\"><span class=\"text-xs font-semibold uppercase tracking-wide\" style=\"color: var(--text-secondary)\">Role</span> ")
if templ_7745c5c3_Err != nil { if templ_7745c5c3_Err != nil {
return templ_7745c5c3_Err return templ_7745c5c3_Err
} }
if user.Role == "admin" && adminCount == 1 { if user.Role == "admin" && adminCount == 1 {
templ_7745c5c3_Err = templruntime.WriteString(templ_7745c5c3_Buffer, 15, "<!-- Last admin - disabled --> <select disabled class=\"input w-auto py-1 pr-7 text-xs opacity-50 cursor-not-allowed\" title=\"Cannot demote the last admin\"><option value=\"user\">User</option> <option value=\"admin\" selected>Admin</option></select> ") templ_7745c5c3_Err = templruntime.WriteString(templ_7745c5c3_Buffer, 15, "<!-- Last admin - disabled --> <select disabled class=\"input w-auto py-1 pr-7 text-xs opacity-50 cursor-not-allowed\" title=\"Cannot demote the last admin\"><option value=\"user\">User</option> <option value=\"admin\" selected>Admin</option></select>")
if templ_7745c5c3_Err != nil { if templ_7745c5c3_Err != nil {
return templ_7745c5c3_Err return templ_7745c5c3_Err
} }
@@ -186,7 +186,7 @@ func AdminUsers(currentUser User, users []User, adminCount int) templ.Component
var templ_7745c5c3_Var7 string var templ_7745c5c3_Var7 string
templ_7745c5c3_Var7, templ_7745c5c3_Err = templ.ResolveAttributeValue("/api/auth/profile/" + user.ID) templ_7745c5c3_Var7, templ_7745c5c3_Err = templ.ResolveAttributeValue("/api/auth/profile/" + user.ID)
if templ_7745c5c3_Err != nil { if templ_7745c5c3_Err != nil {
return templ.Error{Err: templ_7745c5c3_Err, FileName: `admin_users.templ`, Line: 150, Col: 50} return templ.Error{Err: templ_7745c5c3_Err, FileName: `admin_users.templ`, Line: 156, Col: 50}
} }
_, templ_7745c5c3_Err = templ_7745c5c3_Buffer.WriteString(templ_7745c5c3_Var7) _, templ_7745c5c3_Err = templ_7745c5c3_Buffer.WriteString(templ_7745c5c3_Var7)
if templ_7745c5c3_Err != nil { if templ_7745c5c3_Err != nil {
@@ -199,7 +199,7 @@ func AdminUsers(currentUser User, users []User, adminCount int) templ.Component
var templ_7745c5c3_Var8 string var templ_7745c5c3_Var8 string
templ_7745c5c3_Var8, templ_7745c5c3_Err = templ.ResolveAttributeValue("#role-result-" + user.ID) templ_7745c5c3_Var8, templ_7745c5c3_Err = templ.ResolveAttributeValue("#role-result-" + user.ID)
if templ_7745c5c3_Err != nil { if templ_7745c5c3_Err != nil {
return templ.Error{Err: templ_7745c5c3_Err, FileName: `admin_users.templ`, Line: 151, Col: 48} return templ.Error{Err: templ_7745c5c3_Err, FileName: `admin_users.templ`, Line: 157, Col: 48}
} }
_, templ_7745c5c3_Err = templ_7745c5c3_Buffer.WriteString(templ_7745c5c3_Var8) _, templ_7745c5c3_Err = templ_7745c5c3_Buffer.WriteString(templ_7745c5c3_Var8)
if templ_7745c5c3_Err != nil { if templ_7745c5c3_Err != nil {
@@ -225,38 +225,38 @@ func AdminUsers(currentUser User, users []User, adminCount int) templ.Component
return templ_7745c5c3_Err return templ_7745c5c3_Err
} }
} }
templ_7745c5c3_Err = templruntime.WriteString(templ_7745c5c3_Buffer, 22, ">Admin</option></select><div id=\"") templ_7745c5c3_Err = templruntime.WriteString(templ_7745c5c3_Buffer, 22, ">Admin</option></select>")
if templ_7745c5c3_Err != nil {
return templ_7745c5c3_Err
}
}
templ_7745c5c3_Err = templruntime.WriteString(templ_7745c5c3_Buffer, 23, "</div><div id=\"")
if templ_7745c5c3_Err != nil { if templ_7745c5c3_Err != nil {
return templ_7745c5c3_Err return templ_7745c5c3_Err
} }
var templ_7745c5c3_Var9 string var templ_7745c5c3_Var9 string
templ_7745c5c3_Var9, templ_7745c5c3_Err = templ.ResolveAttributeValue("role-result-" + user.ID) templ_7745c5c3_Var9, templ_7745c5c3_Err = templ.ResolveAttributeValue("role-result-" + user.ID)
if templ_7745c5c3_Err != nil { if templ_7745c5c3_Err != nil {
return templ.Error{Err: templ_7745c5c3_Err, FileName: `admin_users.templ`, Line: 160, Col: 44} return templ.Error{Err: templ_7745c5c3_Err, FileName: `admin_users.templ`, Line: 168, Col: 42}
} }
_, templ_7745c5c3_Err = templ_7745c5c3_Buffer.WriteString(templ_7745c5c3_Var9) _, templ_7745c5c3_Err = templ_7745c5c3_Buffer.WriteString(templ_7745c5c3_Var9)
if templ_7745c5c3_Err != nil { if templ_7745c5c3_Err != nil {
return templ_7745c5c3_Err return templ_7745c5c3_Err
} }
templ_7745c5c3_Err = templruntime.WriteString(templ_7745c5c3_Buffer, 23, "\" class=\"text-xs w-full text-right\"></div>") templ_7745c5c3_Err = templruntime.WriteString(templ_7745c5c3_Buffer, 24, "\" class=\"text-xs mt-1\"></div><!-- Actions --><div class=\"mt-3 flex gap-2\"><button hx-get=\"")
if templ_7745c5c3_Err != nil {
return templ_7745c5c3_Err
}
}
templ_7745c5c3_Err = templruntime.WriteString(templ_7745c5c3_Buffer, 24, "<button hx-get=\"")
if templ_7745c5c3_Err != nil { if templ_7745c5c3_Err != nil {
return templ_7745c5c3_Err return templ_7745c5c3_Err
} }
var templ_7745c5c3_Var10 string var templ_7745c5c3_Var10 string
templ_7745c5c3_Var10, templ_7745c5c3_Err = templ.ResolveAttributeValue("/admin/users/" + user.ID + "/profile-modal") templ_7745c5c3_Var10, templ_7745c5c3_Err = templ.ResolveAttributeValue("/admin/users/" + user.ID + "/profile-modal")
if templ_7745c5c3_Err != nil { if templ_7745c5c3_Err != nil {
return templ.Error{Err: templ_7745c5c3_Err, FileName: `admin_users.templ`, Line: 163, Col: 63} return templ.Error{Err: templ_7745c5c3_Err, FileName: `admin_users.templ`, Line: 172, Col: 63}
} }
_, templ_7745c5c3_Err = templ_7745c5c3_Buffer.WriteString(templ_7745c5c3_Var10) _, templ_7745c5c3_Err = templ_7745c5c3_Buffer.WriteString(templ_7745c5c3_Var10)
if templ_7745c5c3_Err != nil { if templ_7745c5c3_Err != nil {
return templ_7745c5c3_Err return templ_7745c5c3_Err
} }
templ_7745c5c3_Err = templruntime.WriteString(templ_7745c5c3_Buffer, 25, "\" hx-target=\"#modal-container\" hx-swap=\"innerHTML\" class=\"btn btn-secondary text-xs px-2.5 py-1\">") templ_7745c5c3_Err = templruntime.WriteString(templ_7745c5c3_Buffer, 25, "\" hx-target=\"#modal-container\" hx-swap=\"innerHTML\" class=\"btn btn-secondary text-sm px-3 py-1.5 flex-1 sm:flex-none sm:ml-auto\">")
if templ_7745c5c3_Err != nil { if templ_7745c5c3_Err != nil {
return templ_7745c5c3_Err return templ_7745c5c3_Err
} }
@@ -269,7 +269,7 @@ func AdminUsers(currentUser User, users []User, adminCount int) templ.Component
return templ_7745c5c3_Err return templ_7745c5c3_Err
} }
if user.Role == "admin" && adminCount == 1 { if user.Role == "admin" && adminCount == 1 {
templ_7745c5c3_Err = templruntime.WriteString(templ_7745c5c3_Buffer, 27, "<button disabled class=\"btn btn-danger text-xs px-2.5 py-1\" title=\"Cannot delete the last admin\">") templ_7745c5c3_Err = templruntime.WriteString(templ_7745c5c3_Buffer, 27, "<button disabled class=\"btn btn-danger text-sm px-3 py-1.5 flex-1 sm:flex-none\" title=\"Cannot delete the last admin\">")
if templ_7745c5c3_Err != nil { if templ_7745c5c3_Err != nil {
return templ_7745c5c3_Err return templ_7745c5c3_Err
} }
@@ -289,7 +289,7 @@ func AdminUsers(currentUser User, users []User, adminCount int) templ.Component
var templ_7745c5c3_Var11 string var templ_7745c5c3_Var11 string
templ_7745c5c3_Var11, templ_7745c5c3_Err = templ.ResolveAttributeValue("/api/auth/profile/" + user.ID) templ_7745c5c3_Var11, templ_7745c5c3_Err = templ.ResolveAttributeValue("/api/auth/profile/" + user.ID)
if templ_7745c5c3_Err != nil { if templ_7745c5c3_Err != nil {
return templ.Error{Err: templ_7745c5c3_Err, FileName: `admin_users.templ`, Line: 182, Col: 53} return templ.Error{Err: templ_7745c5c3_Err, FileName: `admin_users.templ`, Line: 191, Col: 53}
} }
_, templ_7745c5c3_Err = templ_7745c5c3_Buffer.WriteString(templ_7745c5c3_Var11) _, templ_7745c5c3_Err = templ_7745c5c3_Buffer.WriteString(templ_7745c5c3_Var11)
if templ_7745c5c3_Err != nil { if templ_7745c5c3_Err != nil {
@@ -302,13 +302,13 @@ func AdminUsers(currentUser User, users []User, adminCount int) templ.Component
var templ_7745c5c3_Var12 string var templ_7745c5c3_Var12 string
templ_7745c5c3_Var12, templ_7745c5c3_Err = templ.ResolveAttributeValue("#user-" + user.ID) templ_7745c5c3_Var12, templ_7745c5c3_Err = templ.ResolveAttributeValue("#user-" + user.ID)
if templ_7745c5c3_Err != nil { if templ_7745c5c3_Err != nil {
return templ.Error{Err: templ_7745c5c3_Err, FileName: `admin_users.templ`, Line: 183, Col: 41} return templ.Error{Err: templ_7745c5c3_Err, FileName: `admin_users.templ`, Line: 192, Col: 41}
} }
_, templ_7745c5c3_Err = templ_7745c5c3_Buffer.WriteString(templ_7745c5c3_Var12) _, templ_7745c5c3_Err = templ_7745c5c3_Buffer.WriteString(templ_7745c5c3_Var12)
if templ_7745c5c3_Err != nil { if templ_7745c5c3_Err != nil {
return templ_7745c5c3_Err return templ_7745c5c3_Err
} }
templ_7745c5c3_Err = templruntime.WriteString(templ_7745c5c3_Buffer, 31, "\" hx-swap=\"outerHTML swap:0.5s\" hx-confirm=\"Are you sure you want to delete this user? This action cannot be undone.\" class=\"btn btn-danger text-xs px-2.5 py-1\">") templ_7745c5c3_Err = templruntime.WriteString(templ_7745c5c3_Buffer, 31, "\" hx-swap=\"outerHTML swap:0.5s\" hx-confirm=\"Are you sure you want to delete this user? This action cannot be undone.\" class=\"btn btn-danger text-sm px-3 py-1.5 flex-1 sm:flex-none\">")
if templ_7745c5c3_Err != nil { if templ_7745c5c3_Err != nil {
return templ_7745c5c3_Err return templ_7745c5c3_Err
} }