admin page updates
This commit is contained in:
47
internal/view/adminview/confirm_dialog.templ
Normal file
47
internal/view/adminview/confirm_dialog.templ
Normal file
@@ -0,0 +1,47 @@
|
||||
package adminview
|
||||
|
||||
import "fmt"
|
||||
|
||||
templ ConfirmDeleteRole(roleID int, roleName string) {
|
||||
<!-- Modal Overlay -->
|
||||
<div
|
||||
class="fixed inset-0 bg-crust/80 flex items-center justify-center z-50"
|
||||
@click.self="document.getElementById('confirm-dialog').innerHTML = ''"
|
||||
>
|
||||
<div class="bg-surface0 border border-surface1 rounded-lg w-full max-w-md p-6">
|
||||
<div class="flex items-start space-x-4">
|
||||
<div class="flex-shrink-0">
|
||||
<svg class="w-12 h-12 text-red" fill="none" stroke="currentColor" viewBox="0 0 24 24">
|
||||
<path stroke-linecap="round" stroke-linejoin="round" stroke-width="2" d="M12 9v2m0 4h.01m-6.938 4h13.856c1.54 0 2.502-1.667 1.732-3L13.732 4c-.77-1.333-2.694-1.333-3.464 0L3.34 16c-.77 1.333.192 3 1.732 3z"></path>
|
||||
</svg>
|
||||
</div>
|
||||
<div class="flex-1">
|
||||
<h3 class="text-lg font-bold text-text mb-2">Delete Role</h3>
|
||||
<p class="text-sm text-subtext0 mb-4">
|
||||
Are you sure you want to delete the role <span class="font-semibold text-text">{ roleName }</span>?
|
||||
This action cannot be undone and will remove this role from all users who have it assigned.
|
||||
</p>
|
||||
<div class="flex justify-end space-x-2">
|
||||
<button
|
||||
type="button"
|
||||
class="px-4 py-2 bg-surface1 text-text rounded hover:bg-surface2 transition"
|
||||
@click="document.getElementById('confirm-dialog').innerHTML = ''"
|
||||
>
|
||||
Cancel
|
||||
</button>
|
||||
<button
|
||||
type="button"
|
||||
class="px-4 py-2 bg-red text-mantle rounded font-semibold hover:bg-maroon transition"
|
||||
hx-delete={ fmt.Sprintf("/admin/roles/%d", roleID) }
|
||||
hx-target="#admin-content"
|
||||
hx-swap="innerHTML"
|
||||
hx-on::before-request="document.getElementById('confirm-dialog').innerHTML = ''; document.getElementById('role-modal').innerHTML = ''"
|
||||
>
|
||||
Delete Role
|
||||
</button>
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
}
|
||||
@@ -5,48 +5,25 @@ import "git.haelnorr.com/h/oslstats/internal/view/baseview"
|
||||
templ DashboardLayout(activeSection string) {
|
||||
@baseview.Layout("Admin Dashboard") {
|
||||
<div class="max-w-screen-2xl mx-auto px-2">
|
||||
<div class="flex flex-col md:flex-row gap-4">
|
||||
<!-- Sidebar Navigation -->
|
||||
<aside
|
||||
class="w-full md:w-64 flex-shrink-0"
|
||||
x-data="{ mobileOpen: false }"
|
||||
>
|
||||
<!-- Mobile toggle button -->
|
||||
<button
|
||||
@click="mobileOpen = !mobileOpen"
|
||||
class="md:hidden w-full bg-surface0 border border-surface1 rounded-lg px-4 py-3 mb-2 flex items-center justify-between hover:bg-surface1 transition"
|
||||
>
|
||||
<span class="font-semibold text-text">Admin Menu</span>
|
||||
<svg
|
||||
class="w-5 h-5 transition-transform"
|
||||
:class="mobileOpen ? 'rotate-180' : ''"
|
||||
fill="none"
|
||||
stroke="currentColor"
|
||||
viewBox="0 0 24 24"
|
||||
>
|
||||
<path stroke-linecap="round" stroke-linejoin="round" stroke-width="2" d="M19 9l-7 7-7-7"></path>
|
||||
</svg>
|
||||
</button>
|
||||
<!-- Navigation links -->
|
||||
<nav
|
||||
class="bg-surface0 border border-surface1 rounded-lg p-4"
|
||||
:class="mobileOpen ? 'block' : 'hidden md:block'"
|
||||
@click.away="mobileOpen = false"
|
||||
>
|
||||
<h2 class="text-lg font-bold text-text mb-4 px-2">Admin Dashboard</h2>
|
||||
<ul class="space-y-1">
|
||||
@navItem("users", "Users", activeSection)
|
||||
@navItem("roles", "Roles", activeSection)
|
||||
@navItem("permissions", "Permissions", activeSection)
|
||||
@navItem("audit", "Audit Logs", activeSection)
|
||||
</ul>
|
||||
</nav>
|
||||
</aside>
|
||||
<!-- Main content area -->
|
||||
<main class="flex-1 min-w-0" id="admin-content">
|
||||
{ children... }
|
||||
</main>
|
||||
</div>
|
||||
<!-- Page Title -->
|
||||
<h1 class="text-2xl font-bold text-text mb-4">Admin Dashboard</h1>
|
||||
|
||||
<!-- Single cohesive panel with tabs and content -->
|
||||
<div class="border border-surface1 rounded-lg overflow-hidden">
|
||||
<!-- Tab Navigation -->
|
||||
<nav class="bg-surface0 border-b border-surface1">
|
||||
<ul class="flex flex-wrap">
|
||||
@navItem("users", "Users", activeSection)
|
||||
@navItem("roles", "Roles", activeSection)
|
||||
@navItem("audit", "Audit Logs", activeSection)
|
||||
</ul>
|
||||
</nav>
|
||||
|
||||
<!-- Content Area -->
|
||||
<main class="bg-crust p-6" id="admin-content">
|
||||
{ children... }
|
||||
</main>
|
||||
</div>
|
||||
</div>
|
||||
<script src="/static/js/admin.js"></script>
|
||||
}
|
||||
@@ -55,11 +32,11 @@ templ DashboardLayout(activeSection string) {
|
||||
templ navItem(section string, label string, activeSection string) {
|
||||
{{
|
||||
isActive := section == activeSection
|
||||
baseClasses := "block px-4 py-2 rounded-lg transition-colors cursor-pointer"
|
||||
activeClasses := "bg-blue text-mantle font-semibold"
|
||||
inactiveClasses := "text-subtext0 hover:bg-surface1 hover:text-text"
|
||||
baseClasses := "inline-block px-6 py-3 transition-colors cursor-pointer border-b-2"
|
||||
activeClasses := "border-blue text-blue font-semibold"
|
||||
inactiveClasses := "border-transparent text-subtext0 hover:text-text hover:border-surface2"
|
||||
}}
|
||||
<li>
|
||||
<li class="inline-block">
|
||||
<a
|
||||
href={ templ.SafeURL("/admin/" + section) }
|
||||
hx-post={ "/admin/" + section }
|
||||
@@ -67,7 +44,6 @@ templ navItem(section string, label string, activeSection string) {
|
||||
hx-swap="innerHTML"
|
||||
hx-push-url={ "/admin/" + section }
|
||||
class={ baseClasses, templ.KV(activeClasses, isActive), templ.KV(inactiveClasses, !isActive) }
|
||||
@click="if (window.innerWidth < 768) mobileOpen = false"
|
||||
>
|
||||
{ label }
|
||||
</a>
|
||||
|
||||
91
internal/view/adminview/role_form.templ
Normal file
91
internal/view/adminview/role_form.templ
Normal file
@@ -0,0 +1,91 @@
|
||||
package adminview
|
||||
|
||||
templ RoleCreateForm() {
|
||||
<!-- Modal Overlay -->
|
||||
<div
|
||||
class="fixed inset-0 bg-crust/80 flex items-center justify-center z-50"
|
||||
@click.self="document.getElementById('role-modal').innerHTML = ''"
|
||||
>
|
||||
<div class="bg-surface0 border border-surface1 rounded-lg w-full max-w-md p-6">
|
||||
<div class="flex justify-between items-center mb-4">
|
||||
<h2 class="text-xl font-bold text-text">Create New Role</h2>
|
||||
<button
|
||||
class="text-subtext0 hover:text-text transition"
|
||||
@click="document.getElementById('role-modal').innerHTML = ''"
|
||||
>
|
||||
<svg class="w-6 h-6" fill="none" stroke="currentColor" viewBox="0 0 24 24">
|
||||
<path stroke-linecap="round" stroke-linejoin="round" stroke-width="2" d="M6 18L18 6M6 6l12 12"></path>
|
||||
</svg>
|
||||
</button>
|
||||
</div>
|
||||
<form
|
||||
hx-post="/admin/roles/create"
|
||||
hx-target="#admin-content"
|
||||
hx-swap="innerHTML"
|
||||
>
|
||||
<div class="space-y-4">
|
||||
<!-- Name Field -->
|
||||
<div>
|
||||
<label class="block text-sm font-semibold text-text mb-1" for="name">
|
||||
Name (lowercase, no spaces)
|
||||
</label>
|
||||
<input
|
||||
type="text"
|
||||
id="name"
|
||||
name="name"
|
||||
required
|
||||
pattern="[a-z0-9_-]+"
|
||||
class="w-full px-3 py-2 bg-mantle border border-surface1 rounded text-text focus:outline-none focus:border-blue"
|
||||
placeholder="e.g., moderator"
|
||||
/>
|
||||
<p class="text-xs text-subtext0 mt-1">Used internally, must be unique</p>
|
||||
</div>
|
||||
<!-- Display Name Field -->
|
||||
<div>
|
||||
<label class="block text-sm font-semibold text-text mb-1" for="display_name">
|
||||
Display Name
|
||||
</label>
|
||||
<input
|
||||
type="text"
|
||||
id="display_name"
|
||||
name="display_name"
|
||||
required
|
||||
class="w-full px-3 py-2 bg-mantle border border-surface1 rounded text-text focus:outline-none focus:border-blue"
|
||||
placeholder="e.g., Moderator"
|
||||
/>
|
||||
<p class="text-xs text-subtext0 mt-1">Human-readable name shown in UI</p>
|
||||
</div>
|
||||
<!-- Description Field -->
|
||||
<div>
|
||||
<label class="block text-sm font-semibold text-text mb-1" for="description">
|
||||
Description
|
||||
</label>
|
||||
<textarea
|
||||
id="description"
|
||||
name="description"
|
||||
rows="3"
|
||||
class="w-full px-3 py-2 bg-mantle border border-surface1 rounded text-text focus:outline-none focus:border-blue resize-none"
|
||||
placeholder="Brief description of this role's purpose"
|
||||
></textarea>
|
||||
</div>
|
||||
<!-- Action Buttons -->
|
||||
<div class="flex justify-end space-x-2 pt-2">
|
||||
<button
|
||||
type="button"
|
||||
class="px-4 py-2 bg-surface1 text-text rounded hover:bg-surface2 transition"
|
||||
@click="document.getElementById('role-modal').innerHTML = ''"
|
||||
>
|
||||
Cancel
|
||||
</button>
|
||||
<button
|
||||
type="submit"
|
||||
class="px-4 py-2 bg-blue text-mantle rounded font-semibold hover:bg-sky transition"
|
||||
>
|
||||
Create Role
|
||||
</button>
|
||||
</div>
|
||||
</div>
|
||||
</form>
|
||||
</div>
|
||||
</div>
|
||||
}
|
||||
127
internal/view/adminview/role_manage.templ
Normal file
127
internal/view/adminview/role_manage.templ
Normal file
@@ -0,0 +1,127 @@
|
||||
package adminview
|
||||
|
||||
import (
|
||||
"fmt"
|
||||
"git.haelnorr.com/h/oslstats/internal/db"
|
||||
"git.haelnorr.com/h/oslstats/internal/roles"
|
||||
)
|
||||
|
||||
templ RoleManageModal(role *db.Role) {
|
||||
<!-- Modal Overlay -->
|
||||
<div
|
||||
class="fixed inset-0 bg-crust/80 flex items-center justify-center z-50"
|
||||
@click.self="document.getElementById('role-modal').innerHTML = ''"
|
||||
>
|
||||
<div class="bg-surface0 border border-surface1 rounded-lg w-full max-w-2xl p-6">
|
||||
<div class="flex justify-between items-center mb-6">
|
||||
<h2 class="text-2xl font-bold text-text">{ role.DisplayName }</h2>
|
||||
<button
|
||||
class="text-subtext0 hover:text-text transition"
|
||||
@click="document.getElementById('role-modal').innerHTML = ''"
|
||||
>
|
||||
<svg class="w-6 h-6" fill="none" stroke="currentColor" viewBox="0 0 24 24">
|
||||
<path stroke-linecap="round" stroke-linejoin="round" stroke-width="2" d="M6 18L18 6M6 6l12 12"></path>
|
||||
</svg>
|
||||
</button>
|
||||
</div>
|
||||
|
||||
<!-- Role Details -->
|
||||
<div class="space-y-4 mb-6">
|
||||
<div class="bg-mantle border border-surface1 rounded-lg p-4">
|
||||
<div class="grid grid-cols-2 gap-4">
|
||||
<div>
|
||||
<label class="text-xs font-semibold text-subtext0 uppercase tracking-wider">Internal Name</label>
|
||||
<p class="text-sm text-text font-mono mt-1">{ string(role.Name) }</p>
|
||||
</div>
|
||||
<div>
|
||||
<label class="text-xs font-semibold text-subtext0 uppercase tracking-wider">Type</label>
|
||||
<div class="mt-1">
|
||||
if role.IsSystem {
|
||||
<span class="px-2 py-1 bg-yellow/20 text-yellow rounded text-xs font-semibold">SYSTEM</span>
|
||||
} else {
|
||||
<span class="px-2 py-1 bg-surface2 text-subtext0 rounded text-xs">Custom</span>
|
||||
}
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
<div class="mt-4">
|
||||
<label class="text-xs font-semibold text-subtext0 uppercase tracking-wider">Description</label>
|
||||
<p class="text-sm text-text mt-1">
|
||||
if role.Description != "" {
|
||||
{ role.Description }
|
||||
} else {
|
||||
<span class="text-subtext0 italic">No description provided</span>
|
||||
}
|
||||
</p>
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
<!-- Action Buttons - Only show for non-admin roles -->
|
||||
if role.Name != roles.Admin {
|
||||
<div class="border-t border-surface1 pt-6">
|
||||
<h3 class="text-sm font-semibold text-subtext0 uppercase tracking-wider mb-4">Actions</h3>
|
||||
<div class="flex flex-wrap gap-3">
|
||||
<!-- Permissions Button -->
|
||||
<button
|
||||
class="px-4 py-2 bg-blue text-mantle rounded-lg hover:bg-sky transition font-semibold flex items-center gap-2"
|
||||
hx-get={ fmt.Sprintf("/admin/roles/%d/permissions", role.ID) }
|
||||
hx-target="#role-modal"
|
||||
hx-swap="innerHTML"
|
||||
>
|
||||
<svg class="w-5 h-5" fill="none" stroke="currentColor" viewBox="0 0 24 24">
|
||||
<path stroke-linecap="round" stroke-linejoin="round" stroke-width="2" d="M12 15v2m-6 4h12a2 2 0 002-2v-6a2 2 0 00-2-2H6a2 2 0 00-2 2v6a2 2 0 002 2zm10-10V7a4 4 0 00-8 0v4h8z"></path>
|
||||
</svg>
|
||||
Manage Permissions
|
||||
</button>
|
||||
|
||||
<!-- View as Role Button -->
|
||||
<form method="POST" action={ templ.SafeURL(fmt.Sprintf("/admin/roles/%d/preview-start", role.ID)) }>
|
||||
<button
|
||||
type="submit"
|
||||
class="px-4 py-2 bg-green text-mantle rounded-lg hover:bg-teal transition font-semibold flex items-center gap-2"
|
||||
hx-indicator="#loading-indicator"
|
||||
>
|
||||
<svg class="w-5 h-5" fill="none" stroke="currentColor" viewBox="0 0 24 24">
|
||||
<path stroke-linecap="round" stroke-linejoin="round" stroke-width="2" d="M15 12a3 3 0 11-6 0 3 3 0 016 0z"></path>
|
||||
<path stroke-linecap="round" stroke-linejoin="round" stroke-width="2" d="M2.458 12C3.732 7.943 7.523 5 12 5c4.478 0 8.268 2.943 9.542 7-1.274 4.057-5.064 7-9.542 7-4.477 0-8.268-2.943-9.542-7z"></path>
|
||||
</svg>
|
||||
View as Role
|
||||
</button>
|
||||
</form>
|
||||
|
||||
<!-- Delete Button - Only for custom roles -->
|
||||
if !role.IsSystem {
|
||||
<button
|
||||
class="px-4 py-2 bg-red text-mantle rounded-lg hover:bg-maroon transition font-semibold flex items-center gap-2"
|
||||
hx-get={ fmt.Sprintf("/admin/roles/%d/delete-confirm", role.ID) }
|
||||
hx-target="#confirm-dialog"
|
||||
hx-swap="innerHTML"
|
||||
>
|
||||
<svg class="w-5 h-5" fill="none" stroke="currentColor" viewBox="0 0 24 24">
|
||||
<path stroke-linecap="round" stroke-linejoin="round" stroke-width="2" d="M19 7l-.867 12.142A2 2 0 0116.138 21H7.862a2 2 0 01-1.995-1.858L5 7m5 4v6m4-6v6m1-10V4a1 1 0 00-1-1h-4a1 1 0 00-1 1v3M4 7h16"></path>
|
||||
</svg>
|
||||
Delete Role
|
||||
</button>
|
||||
}
|
||||
</div>
|
||||
</div>
|
||||
} else {
|
||||
<!-- Admin role message -->
|
||||
<div class="border-t border-surface1 pt-6">
|
||||
<div class="bg-yellow/10 border border-yellow/30 rounded-lg p-4 flex items-start gap-3">
|
||||
<svg class="w-5 h-5 text-yellow flex-shrink-0 mt-0.5" fill="none" stroke="currentColor" viewBox="0 0 24 24">
|
||||
<path stroke-linecap="round" stroke-linejoin="round" stroke-width="2" d="M12 9v2m0 4h.01m-6.938 4h13.856c1.54 0 2.502-1.667 1.732-3L13.732 4c-.77-1.333-2.694-1.333-3.464 0L3.34 16c-.77 1.333.192 3 1.732 3z"></path>
|
||||
</svg>
|
||||
<div>
|
||||
<p class="text-sm font-semibold text-yellow">Administrator Role</p>
|
||||
<p class="text-xs text-yellow/80 mt-1">
|
||||
The administrator role is protected and cannot be modified or deleted. This role has full access to all system features.
|
||||
</p>
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
}
|
||||
</div>
|
||||
</div>
|
||||
}
|
||||
86
internal/view/adminview/role_permissions.templ
Normal file
86
internal/view/adminview/role_permissions.templ
Normal file
@@ -0,0 +1,86 @@
|
||||
package adminview
|
||||
|
||||
import (
|
||||
"fmt"
|
||||
"git.haelnorr.com/h/oslstats/internal/db"
|
||||
)
|
||||
|
||||
type PermissionsByResource struct {
|
||||
Resource string
|
||||
Permissions []*db.Permission
|
||||
}
|
||||
|
||||
templ RolePermissionsModal(role *db.Role, permissionsByResource []PermissionsByResource, rolePermissionIDs map[int]bool) {
|
||||
<!-- Modal Overlay -->
|
||||
<div
|
||||
class="fixed inset-0 bg-crust/80 flex items-center justify-center z-50"
|
||||
@click.self="document.getElementById('role-modal').innerHTML = ''"
|
||||
>
|
||||
<div class="bg-surface0 border border-surface1 rounded-lg w-full max-w-3xl p-6 max-h-[90vh] overflow-y-auto">
|
||||
<div class="flex justify-between items-center mb-4">
|
||||
<div>
|
||||
<h2 class="text-xl font-bold text-text">Manage Permissions</h2>
|
||||
<p class="text-sm text-subtext0">Role: <span class="font-semibold text-text">{ role.DisplayName }</span></p>
|
||||
</div>
|
||||
<button
|
||||
class="text-subtext0 hover:text-text transition"
|
||||
@click="document.getElementById('role-modal').innerHTML = ''"
|
||||
>
|
||||
<svg class="w-6 h-6" fill="none" stroke="currentColor" viewBox="0 0 24 24">
|
||||
<path stroke-linecap="round" stroke-linejoin="round" stroke-width="2" d="M6 18L18 6M6 6l12 12"></path>
|
||||
</svg>
|
||||
</button>
|
||||
</div>
|
||||
<form
|
||||
hx-post={ fmt.Sprintf("/admin/roles/%d/permissions", role.ID) }
|
||||
hx-target="#admin-content"
|
||||
hx-swap="innerHTML"
|
||||
>
|
||||
<div class="space-y-6">
|
||||
<!-- Permissions Grouped by Resource -->
|
||||
for _, group := range permissionsByResource {
|
||||
<div class="bg-mantle border border-surface1 rounded-lg p-4">
|
||||
<h3 class="text-lg font-semibold text-text mb-3 capitalize">{ group.Resource }</h3>
|
||||
<div class="grid grid-cols-1 md:grid-cols-2 gap-3">
|
||||
for _, perm := range group.Permissions {
|
||||
<label class="flex items-start space-x-3 p-2 rounded hover:bg-surface0 cursor-pointer transition">
|
||||
<input
|
||||
type="checkbox"
|
||||
name="permission_ids"
|
||||
value={ fmt.Sprintf("%d", perm.ID) }
|
||||
checked?={ rolePermissionIDs[perm.ID] }
|
||||
class="mt-1 w-4 h-4 text-blue bg-surface1 border-surface2 rounded focus:ring-blue focus:ring-2"
|
||||
/>
|
||||
<div class="flex-1">
|
||||
<div class="text-sm font-semibold text-text">{ perm.DisplayName }</div>
|
||||
if perm.Description != "" {
|
||||
<div class="text-xs text-subtext0 mt-0.5">{ perm.Description }</div>
|
||||
}
|
||||
<div class="text-xs text-subtext1 font-mono mt-0.5">{ string(perm.Name) }</div>
|
||||
</div>
|
||||
</label>
|
||||
}
|
||||
</div>
|
||||
</div>
|
||||
}
|
||||
<!-- Action Buttons -->
|
||||
<div class="flex justify-end space-x-2 pt-2 border-t border-surface1">
|
||||
<button
|
||||
type="button"
|
||||
class="px-4 py-2 bg-surface1 text-text rounded hover:bg-surface2 transition"
|
||||
@click="document.getElementById('role-modal').innerHTML = ''"
|
||||
>
|
||||
Cancel
|
||||
</button>
|
||||
<button
|
||||
type="submit"
|
||||
class="px-4 py-2 bg-blue text-mantle rounded font-semibold hover:bg-sky transition"
|
||||
>
|
||||
Save Permissions
|
||||
</button>
|
||||
</div>
|
||||
</div>
|
||||
</form>
|
||||
</div>
|
||||
</div>
|
||||
}
|
||||
@@ -1,14 +1,82 @@
|
||||
package adminview
|
||||
|
||||
templ RolesList() {
|
||||
import (
|
||||
"fmt"
|
||||
"git.haelnorr.com/h/oslstats/internal/db"
|
||||
)
|
||||
|
||||
templ RolesList(roles []*db.Role) {
|
||||
<div class="space-y-4">
|
||||
<!-- Header -->
|
||||
<!-- Header with Create Button -->
|
||||
<div class="flex justify-between items-center">
|
||||
<h1 class="text-2xl font-bold text-text">Role Management</h1>
|
||||
<button
|
||||
class="px-4 py-2 bg-blue text-mantle rounded-lg font-semibold hover:bg-sky transition"
|
||||
hx-get="/admin/roles/create"
|
||||
hx-target="#role-modal"
|
||||
hx-swap="innerHTML"
|
||||
>
|
||||
+ Create Role
|
||||
</button>
|
||||
</div>
|
||||
<!-- Placeholder content -->
|
||||
<div class="bg-mantle border border-surface1 rounded-lg p-8 text-center">
|
||||
<p class="text-subtext0 text-lg">Roles management coming soon...</p>
|
||||
<!-- Roles Table -->
|
||||
<div class="bg-surface0 border border-surface1 rounded-lg overflow-hidden">
|
||||
<table class="w-full">
|
||||
<thead class="bg-surface1">
|
||||
<tr>
|
||||
<th class="px-6 py-3 text-left text-sm font-semibold text-text">Name</th>
|
||||
<th class="px-6 py-3 text-left text-sm font-semibold text-text">Description</th>
|
||||
<th class="px-6 py-3 text-left text-sm font-semibold text-text">Type</th>
|
||||
<th class="px-6 py-3 text-right text-sm font-semibold text-text">Actions</th>
|
||||
</tr>
|
||||
</thead>
|
||||
<tbody class="divide-y divide-surface1">
|
||||
if len(roles) == 0 {
|
||||
<tr>
|
||||
<td colspan="4" class="px-6 py-8 text-center text-subtext0">No roles found</td>
|
||||
</tr>
|
||||
} else {
|
||||
for _, role := range roles {
|
||||
@roleRow(role)
|
||||
}
|
||||
}
|
||||
</tbody>
|
||||
</table>
|
||||
</div>
|
||||
</div>
|
||||
<!-- Modal Container -->
|
||||
<div id="role-modal"></div>
|
||||
<!-- Confirmation Dialog Container -->
|
||||
<div id="confirm-dialog"></div>
|
||||
<!-- Loading Indicator -->
|
||||
<div id="loading-indicator" class="htmx-indicator fixed inset-0 bg-crust/80 flex items-center justify-center z-50">
|
||||
<div class="bg-surface0 border border-surface1 rounded-lg p-6 text-center">
|
||||
<div class="animate-spin rounded-full h-12 w-12 border-b-2 border-blue mx-auto mb-4"></div>
|
||||
<p class="text-text font-semibold">Loading preview...</p>
|
||||
</div>
|
||||
</div>
|
||||
}
|
||||
|
||||
templ roleRow(role *db.Role) {
|
||||
<tr class="hover:bg-surface1/50 transition">
|
||||
<td class="px-6 py-4 text-sm text-text font-semibold">{ role.DisplayName }</td>
|
||||
<td class="px-6 py-4 text-sm text-subtext0">{ role.Description }</td>
|
||||
<td class="px-6 py-4 text-sm">
|
||||
if role.IsSystem {
|
||||
<span class="px-2 py-1 bg-yellow/20 text-yellow rounded text-xs font-semibold">SYSTEM</span>
|
||||
} else {
|
||||
<span class="px-2 py-1 bg-surface2 text-subtext0 rounded text-xs">Custom</span>
|
||||
}
|
||||
</td>
|
||||
<td class="px-6 py-4 text-sm text-right">
|
||||
<button
|
||||
class="px-4 py-2 bg-blue text-mantle rounded-lg hover:bg-sky transition text-sm font-semibold"
|
||||
hx-get={ fmt.Sprintf("/admin/roles/%d/manage", role.ID) }
|
||||
hx-target="#role-modal"
|
||||
hx-swap="innerHTML"
|
||||
>
|
||||
Manage
|
||||
</button>
|
||||
</td>
|
||||
</tr>
|
||||
}
|
||||
|
||||
@@ -1,7 +1,9 @@
|
||||
package adminview
|
||||
|
||||
templ RolesPage() {
|
||||
import "git.haelnorr.com/h/oslstats/internal/db"
|
||||
|
||||
templ RolesPage(roles []*db.Role) {
|
||||
@DashboardLayout("roles") {
|
||||
@RolesList()
|
||||
@RolesList(roles)
|
||||
}
|
||||
}
|
||||
|
||||
@@ -2,10 +2,12 @@ package baseview
|
||||
|
||||
import "git.haelnorr.com/h/oslstats/internal/view/popup"
|
||||
import "git.haelnorr.com/h/oslstats/internal/contexts"
|
||||
import "git.haelnorr.com/h/oslstats/internal/db"
|
||||
|
||||
// Global base layout for all pages
|
||||
templ Layout(title string) {
|
||||
{{ devInfo := contexts.DevMode(ctx) }}
|
||||
{{ previewRole := contexts.GetPreviewRole(ctx) }}
|
||||
<!DOCTYPE html>
|
||||
<html
|
||||
lang="en"
|
||||
@@ -43,6 +45,9 @@ templ Layout(title string) {
|
||||
class="flex flex-col h-screen justify-between"
|
||||
>
|
||||
@Navbar()
|
||||
if previewRole != nil {
|
||||
@previewModeBanner(previewRole)
|
||||
}
|
||||
<div id="page-content" class="mb-auto md:px-5 md:pt-5">
|
||||
{ children... }
|
||||
</div>
|
||||
@@ -51,3 +56,38 @@ templ Layout(title string) {
|
||||
</body>
|
||||
</html>
|
||||
}
|
||||
|
||||
// Preview mode banner (private helper)
|
||||
templ previewModeBanner(previewRole *db.Role) {
|
||||
<div class="bg-yellow/20 border-b border-yellow/40 px-4 py-3">
|
||||
<div class="max-w-7xl mx-auto flex items-center justify-between">
|
||||
<div class="flex items-center gap-3">
|
||||
<svg class="w-5 h-5 text-yellow" fill="none" stroke="currentColor" viewBox="0 0 24 24">
|
||||
<path stroke-linecap="round" stroke-linejoin="round" stroke-width="2" d="M15 12a3 3 0 11-6 0 3 3 0 016 0z"></path>
|
||||
<path stroke-linecap="round" stroke-linejoin="round" stroke-width="2" d="M2.458 12C3.732 7.943 7.523 5 12 5c4.478 0 8.268 2.943 9.542 7-1.274 4.057-5.064 7-9.542 7-4.477 0-8.268-2.943-9.542-7z"></path>
|
||||
</svg>
|
||||
<span class="text-yellow font-semibold">
|
||||
Preview Mode: Viewing as <span class="font-bold">{ previewRole.DisplayName }</span>
|
||||
</span>
|
||||
</div>
|
||||
<div class="flex items-center gap-2">
|
||||
<form method="POST" action="/admin/roles/preview-stop?stay=true">
|
||||
<button
|
||||
type="submit"
|
||||
class="px-4 py-1 bg-green text-mantle rounded-lg font-semibold hover:bg-teal transition text-sm"
|
||||
>
|
||||
Stop Preview
|
||||
</button>
|
||||
</form>
|
||||
<form method="POST" action="/admin/roles/preview-stop">
|
||||
<button
|
||||
type="submit"
|
||||
class="px-4 py-1 bg-blue text-mantle rounded-lg font-semibold hover:bg-sky transition text-sm"
|
||||
>
|
||||
Return to Admin Panel
|
||||
</button>
|
||||
</form>
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
}
|
||||
|
||||
@@ -25,13 +25,21 @@ func getNavItems() []NavItem {
|
||||
}
|
||||
}
|
||||
|
||||
// Profile dropdown items (context-aware for admin)
|
||||
// Profile dropdown items (context-aware for admin and preview mode)
|
||||
func getProfileItems(ctx context.Context) []ProfileItem {
|
||||
items := []ProfileItem{
|
||||
{Name: "Profile", Href: "/profile"},
|
||||
{Name: "Account", Href: "/account"},
|
||||
}
|
||||
|
||||
// Check if we're in preview mode
|
||||
previewRole := contexts.GetPreviewRole(ctx)
|
||||
if previewRole != nil {
|
||||
// In preview mode: show stop viewing button instead of admin panel
|
||||
return items
|
||||
}
|
||||
|
||||
// Not in preview mode: show admin panel if user is admin
|
||||
cache := contexts.Permissions(ctx)
|
||||
if cache != nil && cache.Roles["admin"] {
|
||||
items = append(items, ProfileItem{
|
||||
@@ -104,6 +112,7 @@ templ userMenu(user *db.User, profileItems []ProfileItem) {
|
||||
|
||||
// Profile dropdown (private helper)
|
||||
templ profileDropdown(user *db.User, items []ProfileItem) {
|
||||
{{ previewRole := contexts.GetPreviewRole(ctx) }}
|
||||
<div x-data="{ isActive: false }" class="relative">
|
||||
<div
|
||||
class="inline-flex items-center overflow-hidden rounded-lg
|
||||
@@ -118,7 +127,7 @@ templ profileDropdown(user *db.User, items []ProfileItem) {
|
||||
</button>
|
||||
</div>
|
||||
<div
|
||||
class="absolute end-0 z-10 mt-2 w-36 divide-y divide-surface2
|
||||
class="absolute end-0 z-10 mt-2 w-48 divide-y divide-surface2
|
||||
rounded-lg border border-surface1 bg-surface0 shadow-lg"
|
||||
role="menu"
|
||||
x-cloak
|
||||
@@ -127,6 +136,38 @@ templ profileDropdown(user *db.User, items []ProfileItem) {
|
||||
x-on:click.away="isActive = false"
|
||||
x-on:keydown.escape.window="isActive = false"
|
||||
>
|
||||
<!-- Preview Mode Stop Buttons -->
|
||||
if previewRole != nil {
|
||||
<div class="p-2 bg-yellow/10 border-b border-yellow/30 space-y-2">
|
||||
<p class="text-xs text-yellow/80 px-2 font-semibold">
|
||||
Viewing as: { previewRole.DisplayName }
|
||||
</p>
|
||||
<div class="flex gap-2">
|
||||
<form method="POST" action="/admin/roles/preview-stop?stay=true" class="flex-1">
|
||||
<button
|
||||
type="submit"
|
||||
class="w-full rounded-lg px-3 py-2
|
||||
text-sm text-mantle bg-green font-semibold hover:bg-teal hover:cursor-pointer transition"
|
||||
role="menuitem"
|
||||
@click="isActive=false"
|
||||
>
|
||||
Stop Preview
|
||||
</button>
|
||||
</form>
|
||||
<form method="POST" action="/admin/roles/preview-stop" class="flex-1">
|
||||
<button
|
||||
type="submit"
|
||||
class="w-full rounded-lg px-3 py-2
|
||||
text-sm text-mantle bg-blue font-semibold hover:bg-sky hover:cursor-pointer transition"
|
||||
role="menuitem"
|
||||
@click="isActive=false"
|
||||
>
|
||||
Return to Admin
|
||||
</button>
|
||||
</form>
|
||||
</div>
|
||||
</div>
|
||||
}
|
||||
<!-- Profile links -->
|
||||
<div class="p-2">
|
||||
for _, item := range items {
|
||||
|
||||
Reference in New Issue
Block a user