admin: add admin panel for user management
This commit is contained in:
175
api/admin/handler.go
Normal file
175
api/admin/handler.go
Normal file
@@ -0,0 +1,175 @@
|
|||||||
|
package admin
|
||||||
|
|
||||||
|
import (
|
||||||
|
"database/sql"
|
||||||
|
"html"
|
||||||
|
"log"
|
||||||
|
"net/http"
|
||||||
|
|
||||||
|
"golang.org/x/crypto/bcrypt"
|
||||||
|
|
||||||
|
"mal/api/auth"
|
||||||
|
"mal/internal/db"
|
||||||
|
"mal/internal/middleware"
|
||||||
|
"mal/web/templates"
|
||||||
|
)
|
||||||
|
|
||||||
|
type Handler struct {
|
||||||
|
db database.Querier
|
||||||
|
authService *auth.Service
|
||||||
|
}
|
||||||
|
|
||||||
|
func NewHandler(db database.Querier, authService *auth.Service) *Handler {
|
||||||
|
return &Handler{db: db, authService: authService}
|
||||||
|
}
|
||||||
|
|
||||||
|
func (h *Handler) HandleAdminPage(w http.ResponseWriter, r *http.Request) {
|
||||||
|
users, err := h.db.ListUsers(r.Context())
|
||||||
|
if err != nil {
|
||||||
|
log.Printf("list users error: %v", err)
|
||||||
|
http.Error(w, "Failed to load users", http.StatusInternalServerError)
|
||||||
|
return
|
||||||
|
}
|
||||||
|
|
||||||
|
if err := templates.AdminPage(users).Render(r.Context(), w); err != nil {
|
||||||
|
log.Printf("render error: %v", err)
|
||||||
|
http.Error(w, "Internal Server Error", http.StatusInternalServerError)
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
func (h *Handler) HandleImpersonateUser(w http.ResponseWriter, r *http.Request) {
|
||||||
|
userID := r.URL.Path[len("/admin/users/"):]
|
||||||
|
if userID == "" {
|
||||||
|
http.Error(w, "Invalid user ID", http.StatusBadRequest)
|
||||||
|
return
|
||||||
|
}
|
||||||
|
|
||||||
|
targetUser, err := h.db.GetUser(r.Context(), userID)
|
||||||
|
if err != nil {
|
||||||
|
if err == sql.ErrNoRows {
|
||||||
|
http.Error(w, "User not found", http.StatusNotFound)
|
||||||
|
return
|
||||||
|
}
|
||||||
|
log.Printf("get user error: %v", err)
|
||||||
|
http.Error(w, "Failed to load user", http.StatusInternalServerError)
|
||||||
|
return
|
||||||
|
}
|
||||||
|
|
||||||
|
if err := templates.AdminImpersonatePage(targetUser).Render(r.Context(), w); err != nil {
|
||||||
|
log.Printf("render error: %v", err)
|
||||||
|
http.Error(w, "Internal Server Error", http.StatusInternalServerError)
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
func (h *Handler) HandleUserWatchlist(w http.ResponseWriter, r *http.Request) {
|
||||||
|
userID := r.URL.Path[len("/admin/users/"):]
|
||||||
|
userID = userID[:len(userID)-len("/watchlist")]
|
||||||
|
|
||||||
|
entries, err := h.db.GetUserWatchList(r.Context(), userID)
|
||||||
|
if err != nil {
|
||||||
|
log.Printf("get user watchlist error: %v", err)
|
||||||
|
http.Error(w, "Failed to load watchlist", http.StatusInternalServerError)
|
||||||
|
return
|
||||||
|
}
|
||||||
|
|
||||||
|
if err := templates.AdminUserWatchlist(entries).Render(r.Context(), w); err != nil {
|
||||||
|
log.Printf("render error: %v", err)
|
||||||
|
http.Error(w, "Internal Server Error", http.StatusInternalServerError)
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
func (h *Handler) HandleUserContinueWatching(w http.ResponseWriter, r *http.Request) {
|
||||||
|
userID := r.URL.Path[len("/admin/users/"):]
|
||||||
|
userID = userID[:len(userID)-len("/continue-watching")]
|
||||||
|
|
||||||
|
entries, err := h.db.GetContinueWatchingEntries(r.Context(), userID)
|
||||||
|
if err != nil {
|
||||||
|
log.Printf("get continue watching error: %v", err)
|
||||||
|
http.Error(w, "Failed to load continue watching", http.StatusInternalServerError)
|
||||||
|
return
|
||||||
|
}
|
||||||
|
|
||||||
|
if err := templates.AdminUserContinueWatching(entries).Render(r.Context(), w); err != nil {
|
||||||
|
log.Printf("render error: %v", err)
|
||||||
|
http.Error(w, "Internal Server Error", http.StatusInternalServerError)
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
func (h *Handler) HandleAddUserForm(w http.ResponseWriter, r *http.Request) {
|
||||||
|
if r.Method != http.MethodPost {
|
||||||
|
http.Error(w, "Method not allowed", http.StatusMethodNotAllowed)
|
||||||
|
return
|
||||||
|
}
|
||||||
|
|
||||||
|
username := r.FormValue("username")
|
||||||
|
password := r.FormValue("password")
|
||||||
|
|
||||||
|
if username == "" || password == "" {
|
||||||
|
writeInlineError(w, "Username and password are required")
|
||||||
|
return
|
||||||
|
}
|
||||||
|
|
||||||
|
hash, err := bcrypt.GenerateFromPassword([]byte(password), bcryptCost)
|
||||||
|
if err != nil {
|
||||||
|
log.Printf("bcrypt error: %v", err)
|
||||||
|
writeInlineError(w, "Failed to create user")
|
||||||
|
return
|
||||||
|
}
|
||||||
|
|
||||||
|
_, err = h.db.CreateUser(r.Context(), database.CreateUserParams{
|
||||||
|
ID: generateUserID(),
|
||||||
|
Username: username,
|
||||||
|
PasswordHash: string(hash),
|
||||||
|
})
|
||||||
|
if err != nil {
|
||||||
|
log.Printf("create user error: %v", err)
|
||||||
|
writeInlineError(w, "Failed to create user (may already exist)")
|
||||||
|
return
|
||||||
|
}
|
||||||
|
|
||||||
|
// Return success - reload the users list
|
||||||
|
users, err := h.db.ListUsers(r.Context())
|
||||||
|
if err != nil {
|
||||||
|
log.Printf("list users error: %v", err)
|
||||||
|
writeInlineError(w, "User created but failed to refresh list")
|
||||||
|
return
|
||||||
|
}
|
||||||
|
|
||||||
|
if err := templates.AdminUsersList(users).Render(r.Context(), w); err != nil {
|
||||||
|
log.Printf("render error: %v", err)
|
||||||
|
writeInlineError(w, "User created but failed to render list")
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
func writeInlineError(w http.ResponseWriter, message string) {
|
||||||
|
w.Header().Set("Content-Type", "text/html")
|
||||||
|
w.WriteHeader(http.StatusBadRequest)
|
||||||
|
_, _ = w.Write([]byte(`<p style="color: var(--danger); font-size: var(--text-sm);">` + html.EscapeString(message) + `</p>`))
|
||||||
|
}
|
||||||
|
|
||||||
|
func generateUserID() string {
|
||||||
|
// Simple UUID-like generation - in production use proper UUID
|
||||||
|
b := make([]byte, 16)
|
||||||
|
for i := range b {
|
||||||
|
b[i] = byte('a' + (i % 26))
|
||||||
|
}
|
||||||
|
return string(b)
|
||||||
|
}
|
||||||
|
|
||||||
|
const bcryptCost = 12
|
||||||
|
|
||||||
|
func GetImpersonatedUserID(r *http.Request) string {
|
||||||
|
// Check for impersonation parameter
|
||||||
|
impersonateID := r.URL.Query().Get("as_user")
|
||||||
|
if impersonateID == "" {
|
||||||
|
return ""
|
||||||
|
}
|
||||||
|
|
||||||
|
// Verify the current user is admin
|
||||||
|
user, ok := r.Context().Value(middleware.UserContextKey).(*database.User)
|
||||||
|
if !ok || user == nil || !middleware.IsAdmin(user) {
|
||||||
|
return ""
|
||||||
|
}
|
||||||
|
|
||||||
|
return impersonateID
|
||||||
|
}
|
||||||
@@ -10,10 +10,10 @@ import (
|
|||||||
)
|
)
|
||||||
|
|
||||||
type DBTX interface {
|
type DBTX interface {
|
||||||
ExecContext(context.Context, string, ...any) (sql.Result, error)
|
ExecContext(context.Context, string, ...interface{}) (sql.Result, error)
|
||||||
PrepareContext(context.Context, string) (*sql.Stmt, error)
|
PrepareContext(context.Context, string) (*sql.Stmt, error)
|
||||||
QueryContext(context.Context, string, ...any) (*sql.Rows, error)
|
QueryContext(context.Context, string, ...interface{}) (*sql.Rows, error)
|
||||||
QueryRowContext(context.Context, string, ...any) *sql.Row
|
QueryRowContext(context.Context, string, ...interface{}) *sql.Row
|
||||||
}
|
}
|
||||||
|
|
||||||
func New(db DBTX) *Queries {
|
func New(db DBTX) *Queries {
|
||||||
|
|||||||
@@ -33,6 +33,7 @@ type Querier interface {
|
|||||||
GetUserWatchList(ctx context.Context, userID string) ([]GetUserWatchListRow, error)
|
GetUserWatchList(ctx context.Context, userID string) ([]GetUserWatchListRow, error)
|
||||||
GetWatchListEntry(ctx context.Context, arg GetWatchListEntryParams) (WatchListEntry, error)
|
GetWatchListEntry(ctx context.Context, arg GetWatchListEntryParams) (WatchListEntry, error)
|
||||||
GetWatchingAnime(ctx context.Context, userID string) ([]GetWatchingAnimeRow, error)
|
GetWatchingAnime(ctx context.Context, userID string) ([]GetWatchingAnimeRow, error)
|
||||||
|
ListUsers(ctx context.Context) ([]User, error)
|
||||||
MarkAnimeFetchRetryFailed(ctx context.Context, arg MarkAnimeFetchRetryFailedParams) error
|
MarkAnimeFetchRetryFailed(ctx context.Context, arg MarkAnimeFetchRetryFailedParams) error
|
||||||
MarkRelationsSynced(ctx context.Context, id int64) error
|
MarkRelationsSynced(ctx context.Context, id int64) error
|
||||||
SaveWatchProgress(ctx context.Context, arg SaveWatchProgressParams) error
|
SaveWatchProgress(ctx context.Context, arg SaveWatchProgressParams) error
|
||||||
|
|||||||
@@ -4,6 +4,9 @@ SELECT * FROM user WHERE id = ? LIMIT 1;
|
|||||||
-- name: GetUserByUsername :one
|
-- name: GetUserByUsername :one
|
||||||
SELECT * FROM user WHERE username = ? LIMIT 1;
|
SELECT * FROM user WHERE username = ? LIMIT 1;
|
||||||
|
|
||||||
|
-- name: ListUsers :many
|
||||||
|
SELECT * FROM user ORDER BY created_at DESC;
|
||||||
|
|
||||||
-- name: CreateUser :one
|
-- name: CreateUser :one
|
||||||
INSERT INTO user (id, username, password_hash)
|
INSERT INTO user (id, username, password_hash)
|
||||||
VALUES (?, ?, ?)
|
VALUES (?, ?, ?)
|
||||||
|
|||||||
@@ -688,6 +688,38 @@ func (q *Queries) GetWatchingAnime(ctx context.Context, userID string) ([]GetWat
|
|||||||
return items, nil
|
return items, nil
|
||||||
}
|
}
|
||||||
|
|
||||||
|
const listUsers = `-- name: ListUsers :many
|
||||||
|
SELECT id, username, password_hash, created_at FROM user ORDER BY created_at DESC
|
||||||
|
`
|
||||||
|
|
||||||
|
func (q *Queries) ListUsers(ctx context.Context) ([]User, error) {
|
||||||
|
rows, err := q.db.QueryContext(ctx, listUsers)
|
||||||
|
if err != nil {
|
||||||
|
return nil, err
|
||||||
|
}
|
||||||
|
defer rows.Close()
|
||||||
|
var items []User
|
||||||
|
for rows.Next() {
|
||||||
|
var i User
|
||||||
|
if err := rows.Scan(
|
||||||
|
&i.ID,
|
||||||
|
&i.Username,
|
||||||
|
&i.PasswordHash,
|
||||||
|
&i.CreatedAt,
|
||||||
|
); err != nil {
|
||||||
|
return nil, err
|
||||||
|
}
|
||||||
|
items = append(items, i)
|
||||||
|
}
|
||||||
|
if err := rows.Close(); err != nil {
|
||||||
|
return nil, err
|
||||||
|
}
|
||||||
|
if err := rows.Err(); err != nil {
|
||||||
|
return nil, err
|
||||||
|
}
|
||||||
|
return items, nil
|
||||||
|
}
|
||||||
|
|
||||||
const markAnimeFetchRetryFailed = `-- name: MarkAnimeFetchRetryFailed :exec
|
const markAnimeFetchRetryFailed = `-- name: MarkAnimeFetchRetryFailed :exec
|
||||||
UPDATE anime_fetch_retry
|
UPDATE anime_fetch_retry
|
||||||
SET attempts = attempts + 1,
|
SET attempts = attempts + 1,
|
||||||
@@ -698,9 +730,9 @@ WHERE anime_id = ?
|
|||||||
`
|
`
|
||||||
|
|
||||||
type MarkAnimeFetchRetryFailedParams struct {
|
type MarkAnimeFetchRetryFailedParams struct {
|
||||||
Datetime string `json:"datetime"`
|
Datetime interface{} `json:"datetime"`
|
||||||
LastError string `json:"last_error"`
|
LastError string `json:"last_error"`
|
||||||
AnimeID int64 `json:"anime_id"`
|
AnimeID int64 `json:"anime_id"`
|
||||||
}
|
}
|
||||||
|
|
||||||
func (q *Queries) MarkAnimeFetchRetryFailed(ctx context.Context, arg MarkAnimeFetchRetryFailedParams) error {
|
func (q *Queries) MarkAnimeFetchRetryFailed(ctx context.Context, arg MarkAnimeFetchRetryFailedParams) error {
|
||||||
|
|||||||
29
internal/middleware/admin.go
Normal file
29
internal/middleware/admin.go
Normal file
@@ -0,0 +1,29 @@
|
|||||||
|
package middleware
|
||||||
|
|
||||||
|
import (
|
||||||
|
"net/http"
|
||||||
|
|
||||||
|
"mal/internal/db"
|
||||||
|
"mal/web/shared/admin"
|
||||||
|
)
|
||||||
|
|
||||||
|
func IsAdmin(user *database.User) bool {
|
||||||
|
return admin.IsAdmin(user)
|
||||||
|
}
|
||||||
|
|
||||||
|
func RequireAdmin(next http.Handler) http.Handler {
|
||||||
|
return http.HandlerFunc(func(w http.ResponseWriter, r *http.Request) {
|
||||||
|
user, ok := r.Context().Value(UserContextKey).(*database.User)
|
||||||
|
if !ok || user == nil {
|
||||||
|
http.Redirect(w, r, "/login", http.StatusFound)
|
||||||
|
return
|
||||||
|
}
|
||||||
|
|
||||||
|
if !admin.IsAdmin(user) {
|
||||||
|
http.Error(w, "Forbidden", http.StatusForbidden)
|
||||||
|
return
|
||||||
|
}
|
||||||
|
|
||||||
|
next.ServeHTTP(w, r)
|
||||||
|
})
|
||||||
|
}
|
||||||
@@ -6,6 +6,7 @@ import (
|
|||||||
"path/filepath"
|
"path/filepath"
|
||||||
"strings"
|
"strings"
|
||||||
|
|
||||||
|
"mal/api/admin"
|
||||||
"mal/api/anime"
|
"mal/api/anime"
|
||||||
"mal/api/auth"
|
"mal/api/auth"
|
||||||
"mal/api/playback"
|
"mal/api/playback"
|
||||||
@@ -52,6 +53,7 @@ func NewRouter(cfg Config) http.Handler {
|
|||||||
animeHandler := anime.NewHandler(cfg.JikanClient, cfg.DB)
|
animeHandler := anime.NewHandler(cfg.JikanClient, cfg.DB)
|
||||||
playbackSvc := playback.NewService(cfg.DB, cfg.SQLDB, playback.Config{ProxyTokenSecret: cfg.PlaybackProxySecret})
|
playbackSvc := playback.NewService(cfg.DB, cfg.SQLDB, playback.Config{ProxyTokenSecret: cfg.PlaybackProxySecret})
|
||||||
playbackHandler := playback.NewHandler(playbackSvc, cfg.JikanClient)
|
playbackHandler := playback.NewHandler(playbackSvc, cfg.JikanClient)
|
||||||
|
adminHandler := admin.NewHandler(cfg.DB, cfg.AuthService)
|
||||||
|
|
||||||
// Serve static files
|
// Serve static files
|
||||||
fs := http.FileServer(http.Dir("./static"))
|
fs := http.FileServer(http.Dir("./static"))
|
||||||
@@ -103,6 +105,21 @@ func NewRouter(cfg Config) http.Handler {
|
|||||||
mux.HandleFunc("/api/continue-watching/", watchlistHandler.HandleDeleteContinueWatching)
|
mux.HandleFunc("/api/continue-watching/", watchlistHandler.HandleDeleteContinueWatching)
|
||||||
mux.HandleFunc("/watchlist", watchlistHandler.HandleGetWatchlist)
|
mux.HandleFunc("/watchlist", watchlistHandler.HandleGetWatchlist)
|
||||||
|
|
||||||
|
// Admin Endpoints (protected by admin middleware in route handlers)
|
||||||
|
mux.Handle("/admin", middleware.RequireAdmin(http.HandlerFunc(adminHandler.HandleAdminPage)))
|
||||||
|
mux.Handle("/admin/users", middleware.RequireAdmin(http.HandlerFunc(adminHandler.HandleAddUserForm)))
|
||||||
|
mux.Handle("/admin/users/", middleware.RequireAdmin(http.HandlerFunc(func(w http.ResponseWriter, r *http.Request) {
|
||||||
|
path := r.URL.Path
|
||||||
|
switch {
|
||||||
|
case strings.HasSuffix(path, "/watchlist"):
|
||||||
|
adminHandler.HandleUserWatchlist(w, r)
|
||||||
|
case strings.HasSuffix(path, "/continue-watching"):
|
||||||
|
adminHandler.HandleUserContinueWatching(w, r)
|
||||||
|
default:
|
||||||
|
adminHandler.HandleImpersonateUser(w, r)
|
||||||
|
}
|
||||||
|
})))
|
||||||
|
|
||||||
// Wrap mux with global CSRF origin verification and auth checking,
|
// Wrap mux with global CSRF origin verification and auth checking,
|
||||||
// THEN auth context parsing.
|
// THEN auth context parsing.
|
||||||
protectedHandler := middleware.RequireGlobalAuthWithPolicy(middleware.NewAccessPolicy())(pkgmiddleware.VerifyOrigin(mux))
|
protectedHandler := middleware.RequireGlobalAuthWithPolicy(middleware.NewAccessPolicy())(pkgmiddleware.VerifyOrigin(mux))
|
||||||
|
|||||||
@@ -1,12 +1,12 @@
|
|||||||
version: "2"
|
version: "2"
|
||||||
sql:
|
sql:
|
||||||
- engine: "sqlite"
|
- engine: "sqlite"
|
||||||
queries: "internal/database/queries.sql"
|
queries: "internal/db/queries.sql"
|
||||||
schema: "migrations/"
|
schema: "migrations/"
|
||||||
gen:
|
gen:
|
||||||
go:
|
go:
|
||||||
package: "database"
|
package: "database"
|
||||||
out: "internal/database"
|
out: "internal/db"
|
||||||
emit_json_tags: true
|
emit_json_tags: true
|
||||||
emit_prepared_queries: false
|
emit_prepared_queries: false
|
||||||
emit_interface: true
|
emit_interface: true
|
||||||
|
|||||||
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
|
package layout
|
||||||
|
|
||||||
import "mal/web/components/icons"
|
import (
|
||||||
|
"mal/web/components/icons"
|
||||||
|
"mal/web/shared/admin"
|
||||||
|
)
|
||||||
|
|
||||||
templ Layout(title string, showHeader bool) {
|
templ Layout(title string, showHeader bool) {
|
||||||
<!DOCTYPE html>
|
<!DOCTYPE html>
|
||||||
@@ -60,6 +63,14 @@ templ Layout(title string, showHeader bool) {
|
|||||||
>
|
>
|
||||||
Watchlist
|
Watchlist
|
||||||
</a>
|
</a>
|
||||||
|
if admin.IsAdminFromContext(ctx) {
|
||||||
|
<a
|
||||||
|
class="text-(--accent) no-underline hover:text-(--accent) hover:no-underline"
|
||||||
|
href="/admin"
|
||||||
|
>
|
||||||
|
Admin
|
||||||
|
</a>
|
||||||
|
}
|
||||||
</div>
|
</div>
|
||||||
</div>
|
</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