admin: add admin panel for user management
This commit is contained in:
32
web/shared/admin/admin.go
Normal file
32
web/shared/admin/admin.go
Normal file
@@ -0,0 +1,32 @@
|
||||
package admin
|
||||
|
||||
import (
|
||||
"context"
|
||||
|
||||
"mal/internal/db"
|
||||
)
|
||||
|
||||
type contextKey string
|
||||
|
||||
const userContextKey contextKey = "user"
|
||||
|
||||
const AdminEmail = "mikkelelvers@outlook.com"
|
||||
|
||||
func IsAdmin(user *database.User) bool {
|
||||
if user == nil {
|
||||
return false
|
||||
}
|
||||
return user.Username == AdminEmail
|
||||
}
|
||||
|
||||
func GetUser(ctx context.Context) *database.User {
|
||||
user, ok := ctx.Value(userContextKey).(*database.User)
|
||||
if !ok {
|
||||
return nil
|
||||
}
|
||||
return user
|
||||
}
|
||||
|
||||
func IsAdminFromContext(ctx context.Context) bool {
|
||||
return IsAdmin(GetUser(ctx))
|
||||
}
|
||||
@@ -1,6 +1,9 @@
|
||||
package layout
|
||||
|
||||
import "mal/web/components/icons"
|
||||
import (
|
||||
"mal/web/components/icons"
|
||||
"mal/web/shared/admin"
|
||||
)
|
||||
|
||||
templ Layout(title string, showHeader bool) {
|
||||
<!DOCTYPE html>
|
||||
@@ -60,6 +63,14 @@ templ Layout(title string, showHeader bool) {
|
||||
>
|
||||
Watchlist
|
||||
</a>
|
||||
if admin.IsAdminFromContext(ctx) {
|
||||
<a
|
||||
class="text-(--accent) no-underline hover:text-(--accent) hover:no-underline"
|
||||
href="/admin"
|
||||
>
|
||||
Admin
|
||||
</a>
|
||||
}
|
||||
</div>
|
||||
</div>
|
||||
<div
|
||||
|
||||
221
web/templates/admin.templ
Normal file
221
web/templates/admin.templ
Normal file
@@ -0,0 +1,221 @@
|
||||
package templates
|
||||
|
||||
import (
|
||||
"fmt"
|
||||
|
||||
"mal/internal/db"
|
||||
"mal/web/shared/layout"
|
||||
)
|
||||
|
||||
templ AdminPage(users []database.User) {
|
||||
@layout.Layout("mal - admin", true) {
|
||||
<div class="grid gap-6">
|
||||
<h1 class="text-xl font-semibold">Admin Panel</h1>
|
||||
|
||||
<!-- Add User Section -->
|
||||
<div class="rounded bg-(--surface-search) p-4">
|
||||
<h2 class="mb-4 text-lg">Add New User</h2>
|
||||
<form
|
||||
hx-post="/admin/users"
|
||||
hx-target="#users-list"
|
||||
hx-swap="outerHTML"
|
||||
class="flex flex-wrap gap-3"
|
||||
>
|
||||
<input
|
||||
type="email"
|
||||
name="username"
|
||||
placeholder="Email"
|
||||
required
|
||||
class="h-9 rounded bg-(--bg) px-3 text-(--text) placeholder:text-(--text-faint) focus:outline-none"
|
||||
/>
|
||||
<input
|
||||
type="password"
|
||||
name="password"
|
||||
placeholder="Password"
|
||||
required
|
||||
class="h-9 rounded bg-(--bg) px-3 text-(--text) placeholder:text-(--text-faint) focus:outline-none"
|
||||
/>
|
||||
<button
|
||||
type="submit"
|
||||
class="h-9 cursor-pointer rounded bg-(--surface-button) px-4 text-sm text-(--text) transition-opacity duration-150 hover:opacity-80"
|
||||
>
|
||||
Add User
|
||||
</button>
|
||||
</form>
|
||||
</div>
|
||||
|
||||
<!-- Users List -->
|
||||
<div>
|
||||
<h2 class="mb-4 text-lg">Users</h2>
|
||||
@AdminUsersList(users)
|
||||
</div>
|
||||
</div>
|
||||
}
|
||||
}
|
||||
|
||||
templ AdminUsersList(users []database.User) {
|
||||
<div id="users-list" class="grid gap-2">
|
||||
for _, user := range users {
|
||||
<div class="flex items-center justify-between rounded bg-(--surface-search) p-3">
|
||||
<div>
|
||||
<div class="text-sm font-medium">{ user.Username }</div>
|
||||
<div class="text-xs text-(--text-muted)">ID: { user.ID }</div>
|
||||
<div class="text-xs text-(--text-faint)">Created: { user.CreatedAt.Format("2006-01-02") }</div>
|
||||
</div>
|
||||
<div class="flex gap-2">
|
||||
<a
|
||||
href={ templ.URL(fmt.Sprintf("/admin/users/%s", user.ID)) }
|
||||
class="rounded bg-(--panel-soft) px-3 py-1 text-xs text-(--text) hover:bg-(--panel)"
|
||||
>
|
||||
View
|
||||
</a>
|
||||
</div>
|
||||
</div>
|
||||
}
|
||||
</div>
|
||||
}
|
||||
|
||||
templ AdminImpersonatePage(user database.User) {
|
||||
@layout.Layout("mal - admin - user view", true) {
|
||||
<div class="grid gap-6">
|
||||
<div class="flex items-center justify-between">
|
||||
<div>
|
||||
<h1 class="text-xl font-semibold">Viewing User</h1>
|
||||
<p class="text-sm text-(--text-muted)">{ user.Username }</p>
|
||||
</div>
|
||||
<a
|
||||
href="/admin"
|
||||
class="rounded bg-(--surface-search) px-4 py-2 text-sm text-(--text) hover:bg-(--panel-soft)"
|
||||
>
|
||||
← Back to Admin
|
||||
</a>
|
||||
</div>
|
||||
|
||||
<div class="grid gap-4 md:grid-cols-2">
|
||||
<!-- Watchlist Card -->
|
||||
<a
|
||||
href={ templ.URL(fmt.Sprintf("/admin/users/%s/watchlist", user.ID)) }
|
||||
class="block rounded bg-(--surface-search) p-4 hover:bg-(--panel-soft)"
|
||||
>
|
||||
<h2 class="mb-2 text-lg">Watchlist</h2>
|
||||
<p class="text-sm text-(--text-muted)">View user's anime watchlist</p>
|
||||
</a>
|
||||
|
||||
<!-- Continue Watching Card -->
|
||||
<a
|
||||
href={ templ.URL(fmt.Sprintf("/admin/users/%s/continue-watching", user.ID)) }
|
||||
class="block rounded bg-(--surface-search) p-4 hover:bg-(--panel-soft)"
|
||||
>
|
||||
<h2 class="mb-2 text-lg">Continue Watching</h2>
|
||||
<p class="text-sm text-(--text-muted)">View user's watch progress</p>
|
||||
</a>
|
||||
</div>
|
||||
|
||||
<div class="mt-4 rounded border border-(--danger) p-4 text-sm text-(--danger)">
|
||||
<strong>Read-only mode:</strong> You are viewing this user's data. Changes cannot be made from this view.
|
||||
</div>
|
||||
</div>
|
||||
}
|
||||
}
|
||||
|
||||
templ AdminUserWatchlist(entries []database.GetUserWatchListRow) {
|
||||
@layout.Layout("mal - admin - watchlist", true) {
|
||||
<div class="grid gap-6">
|
||||
<div class="flex items-center justify-between">
|
||||
<h1 class="text-xl font-semibold">User Watchlist</h1>
|
||||
<a
|
||||
href="/admin"
|
||||
class="rounded bg-(--surface-search) px-4 py-2 text-sm text-(--text) hover:bg-(--panel-soft)"
|
||||
>
|
||||
← Back to Admin
|
||||
</a>
|
||||
</div>
|
||||
|
||||
if len(entries) == 0 {
|
||||
<div class="rounded bg-(--surface-search) p-8 text-center text-(--text-muted)">
|
||||
No watchlist entries
|
||||
</div>
|
||||
} else {
|
||||
<div class="grid grid-cols-2 gap-3 sm:grid-cols-3 md:grid-cols-4 lg:grid-cols-5">
|
||||
for _, entry := range entries {
|
||||
<div class="rounded bg-(--surface-search) p-3">
|
||||
<div class="mb-2 aspect-2/3 overflow-hidden rounded bg-(--bg)">
|
||||
if entry.ImageUrl != "" {
|
||||
<img
|
||||
src={ entry.ImageUrl }
|
||||
alt={ entry.TitleOriginal }
|
||||
class="h-full w-full object-cover"
|
||||
loading="lazy"
|
||||
/>
|
||||
} else {
|
||||
<div class="flex h-full items-center justify-center text-xs text-(--text-faint)">No image</div>
|
||||
}
|
||||
</div>
|
||||
<div class="text-sm line-clamp-2">{ entry.TitleOriginal }</div>
|
||||
<div class="mt-1 text-xs text-(--text-muted)">Status: { entry.Status }</div>
|
||||
if entry.CurrentEpisode.Valid {
|
||||
<div class="text-xs text-(--text-faint)">Episode: { fmt.Sprintf("%d", entry.CurrentEpisode.Int64) }</div>
|
||||
}
|
||||
</div>
|
||||
}
|
||||
</div>
|
||||
}
|
||||
|
||||
<div class="mt-4 rounded border border-(--danger) p-4 text-sm text-(--danger)">
|
||||
<strong>Read-only mode:</strong> You are viewing this user's data. Changes cannot be made from this view.
|
||||
</div>
|
||||
</div>
|
||||
}
|
||||
}
|
||||
|
||||
templ AdminUserContinueWatching(entries []database.GetContinueWatchingEntriesRow) {
|
||||
@layout.Layout("mal - admin - continue watching", true) {
|
||||
<div class="grid gap-6">
|
||||
<div class="flex items-center justify-between">
|
||||
<h1 class="text-xl font-semibold">Continue Watching</h1>
|
||||
<a
|
||||
href="/admin"
|
||||
class="rounded bg-(--surface-search) px-4 py-2 text-sm text-(--text) hover:bg-(--panel-soft)"
|
||||
>
|
||||
← Back to Admin
|
||||
</a>
|
||||
</div>
|
||||
|
||||
if len(entries) == 0 {
|
||||
<div class="rounded bg-(--surface-search) p-8 text-center text-(--text-muted)">
|
||||
No continue watching entries
|
||||
</div>
|
||||
} else {
|
||||
<div class="grid grid-cols-2 gap-3 sm:grid-cols-3 md:grid-cols-4 lg:grid-cols-5">
|
||||
for _, entry := range entries {
|
||||
<div class="rounded bg-(--surface-search) p-3">
|
||||
<div class="mb-2 aspect-2/3 overflow-hidden rounded bg-(--bg)">
|
||||
if entry.ImageUrl != "" {
|
||||
<img
|
||||
src={ entry.ImageUrl }
|
||||
alt={ entry.TitleOriginal }
|
||||
class="h-full w-full object-cover"
|
||||
loading="lazy"
|
||||
/>
|
||||
} else {
|
||||
<div class="flex h-full items-center justify-center text-xs text-(--text-faint)">No image</div>
|
||||
}
|
||||
</div>
|
||||
<div class="text-sm line-clamp-2">{ entry.TitleOriginal }</div>
|
||||
if entry.CurrentEpisode.Valid {
|
||||
<div class="mt-1 text-xs text-(--text-muted)">Episode: { fmt.Sprintf("%d", entry.CurrentEpisode.Int64) }</div>
|
||||
}
|
||||
if entry.CurrentTimeSeconds > 0 {
|
||||
<div class="text-xs text-(--text-faint)">Time: { fmt.Sprintf("%.0fs", entry.CurrentTimeSeconds) }</div>
|
||||
}
|
||||
</div>
|
||||
}
|
||||
</div>
|
||||
}
|
||||
|
||||
<div class="mt-4 rounded border border-(--danger) p-4 text-sm text-(--danger)">
|
||||
<strong>Read-only mode:</strong> You are viewing this user's data. Changes cannot be made from this view.
|
||||
</div>
|
||||
</div>
|
||||
}
|
||||
}
|
||||
Reference in New Issue
Block a user