fix(web): admin users list reworked to the library-management card pattern
The md:-flipped table/card split still broke at tablet widths and with the nav sidebar open — a viewport breakpoint cannot see the sidebar eating content width. Replaced the table entirely with the same fluid card list admin_library uses (flex + min-w-0/truncate identity block, fixed action cluster, flex-wrap): it reflows continuously at every width instead of stepping between two layouts. The duplicate-DOM-id suffixing from the previous two-rendering approach is gone with it.
This commit is contained in:
1 parent
300866ca67
commit
f3c9d4b4e8
2 files changed
+270
-533
No files matched your search
+65
-147
@@ -51,65 +51,6 @@ templ adminCreateUserForm() {
|
||||
</div>
|
||||
}
|
||||
|
||||
// userRoleControl renders the role select (with last-admin protection).
|
||||
// resultSuffix keeps DOM ids unique when the control is rendered twice
|
||||
// (desktop table row + mobile card).
|
||||
templ userRoleControl(user User, adminCount int, resultSuffix string) {
|
||||
if user.Role == "admin" && adminCount == 1 {
|
||||
<!-- Last admin - disabled -->
|
||||
<div class="relative">
|
||||
<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>
|
||||
</div>
|
||||
} else {
|
||||
<select
|
||||
hx-put={ "/api/auth/profile/" + user.ID }
|
||||
hx-target={ "#role-result-" + resultSuffix + user.ID }
|
||||
hx-swap="innerHTML"
|
||||
hx-trigger="change"
|
||||
name="role"
|
||||
class="input w-auto py-1 pr-7 text-xs"
|
||||
>
|
||||
<option value="user" selected?={ user.Role == "user" }>User</option>
|
||||
<option value="admin" selected?={ user.Role == "admin" }>Admin</option>
|
||||
</select>
|
||||
<div id={ "role-result-" + resultSuffix + user.ID } class="text-xs mt-1"></div>
|
||||
}
|
||||
}
|
||||
|
||||
// userDeleteButton renders the delete button (with last-admin protection).
|
||||
// idSuffix keeps the swap-target ids unique when the control is rendered
|
||||
// twice (desktop table row + mobile card).
|
||||
templ userDeleteButton(user User, adminCount int, idSuffix string) {
|
||||
if user.Role == "admin" && adminCount == 1 {
|
||||
<button
|
||||
disabled
|
||||
class="btn btn-danger text-xs px-2.5 py-1"
|
||||
title="Cannot delete the last admin"
|
||||
>
|
||||
@Icon("trash", "h-4 w-4")
|
||||
Delete
|
||||
</button>
|
||||
} else {
|
||||
<button
|
||||
hx-delete={ "/api/auth/profile/" + user.ID }
|
||||
hx-target={ "#user-" + idSuffix + user.ID }
|
||||
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"
|
||||
>
|
||||
@Icon("trash", "h-4 w-4")
|
||||
Delete
|
||||
</button>
|
||||
}
|
||||
}
|
||||
|
||||
templ AdminUsers(currentUser User, users []User, adminCount int) {
|
||||
<!DOCTYPE html>
|
||||
<html lang="en">
|
||||
@@ -144,96 +85,53 @@ templ AdminUsers(currentUser User, users []User, adminCount int) {
|
||||
|
||||
@adminCreateUserForm()
|
||||
|
||||
<!-- Users: table on md+, stacked cards below -->
|
||||
<div class="card overflow-hidden hidden md:block">
|
||||
<table class="w-full">
|
||||
<thead style="background-color: var(--bg-primary)">
|
||||
<tr>
|
||||
<th class="px-6 py-3 text-left text-xs font-semibold uppercase tracking-wide" style="color: var(--text-secondary)">Username</th>
|
||||
<th class="px-6 py-3 text-left text-xs font-semibold uppercase tracking-wide" style="color: var(--text-secondary)">Email</th>
|
||||
<th class="px-6 py-3 text-left text-xs font-semibold uppercase tracking-wide" style="color: var(--text-secondary)">Role</th>
|
||||
<th class="px-6 py-3 text-left text-xs font-semibold uppercase tracking-wide" style="color: var(--text-secondary)">Created</th>
|
||||
<th class="px-6 py-3 text-right text-xs font-semibold uppercase tracking-wide" style="color: var(--text-secondary)">Actions</th>
|
||||
</tr>
|
||||
</thead>
|
||||
<tbody class="divide-y" style="divide-color: var(--border)">
|
||||
for _, user := range users {
|
||||
<tr id={ "user-" + user.ID } class="transition-colors hover:bg-surface-hover">
|
||||
<!-- Username -->
|
||||
<td class="px-6 py-4 whitespace-nowrap">
|
||||
<div class="flex items-center gap-2">
|
||||
<span class="grid place-items-center h-8 w-8 rounded-full shrink-0" style="background-color: var(--accent-muted); color: var(--accent);">
|
||||
@Icon("user", "h-4 w-4")
|
||||
</span>
|
||||
<div>
|
||||
<div class="text-sm font-medium" style="color: var(--text-primary)">{ user.Username }</div>
|
||||
if user.ID == currentUser.ID {
|
||||
<span class="badge" style="background-color: var(--accent-muted); color: var(--accent);">You</span>
|
||||
}
|
||||
</div>
|
||||
</div>
|
||||
</td>
|
||||
<!-- Email -->
|
||||
<td class="px-6 py-4 whitespace-nowrap">
|
||||
<div class="text-sm" style="color: var(--text-primary)">{ user.Email }</div>
|
||||
</td>
|
||||
<!-- Role Toggle (with last-admin protection) -->
|
||||
<td class="px-6 py-4 whitespace-nowrap">
|
||||
@userRoleControl(user, adminCount, "")
|
||||
</td>
|
||||
<!-- Created -->
|
||||
<td class="px-6 py-4 whitespace-nowrap">
|
||||
<div class="text-sm" style="color: var(--text-secondary)">{ FormatInTimezone(user.CreatedAt, currentUser.Timezone) }</div>
|
||||
</td>
|
||||
<!-- Actions -->
|
||||
<td class="px-6 py-4 whitespace-nowrap text-right">
|
||||
<div class="inline-flex items-center gap-1">
|
||||
<button
|
||||
hx-get={ "/admin/users/" + user.ID + "/profile-modal" }
|
||||
hx-target="#modal-container"
|
||||
hx-swap="innerHTML"
|
||||
class="btn btn-secondary text-xs px-2.5 py-1"
|
||||
>
|
||||
@Icon("edit", "h-4 w-4")
|
||||
Edit
|
||||
</button>
|
||||
@userDeleteButton(user, adminCount, "")
|
||||
</div>
|
||||
</td>
|
||||
</tr>
|
||||
}
|
||||
</tbody>
|
||||
</table>
|
||||
</div>
|
||||
|
||||
<!-- Mobile: one card per user -->
|
||||
<div class="space-y-3 md:hidden">
|
||||
<!-- Users: fluid card list (same pattern as library management —
|
||||
no table, reflows at every width including with the nav
|
||||
sidebar open). -->
|
||||
<div class="space-y-4">
|
||||
for _, user := range users {
|
||||
<div id={ "user-mobile-" + user.ID } class="card p-4">
|
||||
<div class="flex items-center gap-3">
|
||||
<span class="grid place-items-center h-9 w-9 rounded-full shrink-0" style="background-color: var(--accent-muted); color: var(--accent);">
|
||||
@Icon("user", "h-4 w-4")
|
||||
</span>
|
||||
<div class="min-w-0">
|
||||
<div class="flex items-center gap-2 flex-wrap">
|
||||
<span class="text-sm font-medium truncate" style="color: var(--text-primary)">{ user.Username }</span>
|
||||
if user.ID == currentUser.ID {
|
||||
<span class="badge" style="background-color: var(--accent-muted); color: var(--accent);">You</span>
|
||||
}
|
||||
<div id={ "user-" + user.ID } 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);">
|
||||
@Icon("user", "h-5 w-5")
|
||||
</span>
|
||||
<div class="min-w-0">
|
||||
<div class="flex items-center gap-2">
|
||||
<h3 class="font-semibold truncate" style="color: var(--text-primary)">{ user.Username }</h3>
|
||||
if user.ID == currentUser.ID {
|
||||
<span class="badge shrink-0" style="background-color: var(--accent-muted); color: var(--accent);">You</span>
|
||||
}
|
||||
</div>
|
||||
<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 class="text-xs truncate" style="color: var(--text-secondary)">{ user.Email }</div>
|
||||
</div>
|
||||
</div>
|
||||
<div class="flex items-center justify-between gap-3 mt-3 pt-3 border-t" style="border-color: var(--border)">
|
||||
<div>
|
||||
<div class="text-xs mb-1" style="color: var(--text-secondary)">Role</div>
|
||||
@userRoleControl(user, adminCount, "mobile-")
|
||||
</div>
|
||||
<div class="text-right">
|
||||
<div class="text-xs mb-1" style="color: var(--text-secondary)">Created</div>
|
||||
<div class="text-xs" style="color: var(--text-secondary)">{ FormatInTimezone(user.CreatedAt, currentUser.Timezone) }</div>
|
||||
</div>
|
||||
<div class="flex items-center gap-1 shrink-0">
|
||||
<div class="flex flex-wrap items-center justify-end gap-2 shrink-0">
|
||||
if user.Role == "admin" && adminCount == 1 {
|
||||
<!-- 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>
|
||||
} else {
|
||||
<select
|
||||
hx-put={ "/api/auth/profile/" + user.ID }
|
||||
hx-target={ "#role-result-" + user.ID }
|
||||
hx-swap="innerHTML"
|
||||
hx-trigger="change"
|
||||
name="role"
|
||||
class="input w-auto py-1 pr-7 text-xs"
|
||||
>
|
||||
<option value="user" selected?={ user.Role == "user" }>User</option>
|
||||
<option value="admin" selected?={ user.Role == "admin" }>Admin</option>
|
||||
</select>
|
||||
<div id={ "role-result-" + user.ID } class="text-xs w-full text-right"></div>
|
||||
}
|
||||
<button
|
||||
hx-get={ "/admin/users/" + user.ID + "/profile-modal" }
|
||||
hx-target="#modal-container"
|
||||
@@ -243,7 +141,27 @@ templ AdminUsers(currentUser User, users []User, adminCount int) {
|
||||
@Icon("edit", "h-4 w-4")
|
||||
Edit
|
||||
</button>
|
||||
@userDeleteButton(user, adminCount, "mobile-")
|
||||
if user.Role == "admin" && adminCount == 1 {
|
||||
<button
|
||||
disabled
|
||||
class="btn btn-danger text-xs px-2.5 py-1"
|
||||
title="Cannot delete the last admin"
|
||||
>
|
||||
@Icon("trash", "h-4 w-4")
|
||||
Delete
|
||||
</button>
|
||||
} else {
|
||||
<button
|
||||
hx-delete={ "/api/auth/profile/" + user.ID }
|
||||
hx-target={ "#user-" + user.ID }
|
||||
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"
|
||||
>
|
||||
@Icon("trash", "h-4 w-4")
|
||||
Delete
|
||||
</button>
|
||||
}
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
Reference in new issue
Block a user