refactor: remove admin panel and multi-user features
This commit is contained in:
@@ -1,229 +0,0 @@
|
|||||||
package admin
|
|
||||||
|
|
||||||
import (
|
|
||||||
"database/sql"
|
|
||||||
"html"
|
|
||||||
"log"
|
|
||||||
"net/http"
|
|
||||||
"strings"
|
|
||||||
|
|
||||||
"golang.org/x/crypto/bcrypt"
|
|
||||||
|
|
||||||
"mal/api/auth"
|
|
||||||
"mal/internal/db"
|
|
||||||
"mal/internal/middleware"
|
|
||||||
webcontext "mal/web/context"
|
|
||||||
"mal/web/templates"
|
|
||||||
)
|
|
||||||
|
|
||||||
type Handler struct {
|
|
||||||
db database.Querier
|
|
||||||
authService *auth.Service
|
|
||||||
}
|
|
||||||
|
|
||||||
type contextKey string
|
|
||||||
|
|
||||||
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) 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
|
|
||||||
}
|
|
||||||
|
|
||||||
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 (h *Handler) HandleDeleteUserRouter(w http.ResponseWriter, r *http.Request) {
|
|
||||||
path := r.URL.Path
|
|
||||||
parts := strings.Split(strings.TrimSuffix(path, "/delete"), "/")
|
|
||||||
userID := parts[len(parts)-1]
|
|
||||||
|
|
||||||
if userID == "" {
|
|
||||||
writeInlineError(w, "Invalid user ID")
|
|
||||||
return
|
|
||||||
}
|
|
||||||
|
|
||||||
currentUser, ok := r.Context().Value(webcontext.UserKey).(*database.User)
|
|
||||||
if !ok || currentUser == nil {
|
|
||||||
writeInlineError(w, "Not authenticated")
|
|
||||||
return
|
|
||||||
}
|
|
||||||
if userID == currentUser.ID {
|
|
||||||
writeInlineError(w, "Cannot delete your own account")
|
|
||||||
return
|
|
||||||
}
|
|
||||||
|
|
||||||
err := h.db.DeleteUser(r.Context(), userID)
|
|
||||||
if err != nil {
|
|
||||||
log.Printf("delete user error: %v", err)
|
|
||||||
writeInlineError(w, "Failed to delete user")
|
|
||||||
return
|
|
||||||
}
|
|
||||||
|
|
||||||
users, err := h.db.ListUsers(r.Context())
|
|
||||||
if err != nil {
|
|
||||||
log.Printf("list users error: %v", err)
|
|
||||||
writeInlineError(w, "User deleted 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 deleted but failed to render list")
|
|
||||||
}
|
|
||||||
}
|
|
||||||
|
|
||||||
func (h *Handler) HandleUserRouter(w http.ResponseWriter, r *http.Request) {
|
|
||||||
path := r.URL.Path
|
|
||||||
switch {
|
|
||||||
case strings.HasSuffix(path, "/delete"):
|
|
||||||
h.HandleDeleteUserRouter(w, r)
|
|
||||||
case strings.HasSuffix(path, "/watchlist"):
|
|
||||||
h.HandleUserWatchlist(w, r)
|
|
||||||
case strings.HasSuffix(path, "/continue-watching"):
|
|
||||||
h.HandleUserContinueWatching(w, r)
|
|
||||||
default:
|
|
||||||
h.HandleImpersonateUser(w, r)
|
|
||||||
}
|
|
||||||
}
|
|
||||||
|
|
||||||
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 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 {
|
|
||||||
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 {
|
|
||||||
impersonateID := r.URL.Query().Get("as_user")
|
|
||||||
if impersonateID == "" {
|
|
||||||
return ""
|
|
||||||
}
|
|
||||||
|
|
||||||
user, ok := r.Context().Value(webcontext.UserKey).(*database.User)
|
|
||||||
if !ok || user == nil || !middleware.IsAdmin(user) {
|
|
||||||
return ""
|
|
||||||
}
|
|
||||||
|
|
||||||
return impersonateID
|
|
||||||
}
|
|
||||||
@@ -1,30 +0,0 @@
|
|||||||
package middleware
|
|
||||||
|
|
||||||
import (
|
|
||||||
"net/http"
|
|
||||||
|
|
||||||
"mal/internal/db"
|
|
||||||
webcontext "mal/web/context"
|
|
||||||
"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(webcontext.UserKey).(*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)
|
|
||||||
})
|
|
||||||
}
|
|
||||||
@@ -1,22 +0,0 @@
|
|||||||
package admin
|
|
||||||
|
|
||||||
import (
|
|
||||||
"mal/internal/db"
|
|
||||||
)
|
|
||||||
|
|
||||||
const AdminEmail = "mikkelelvers@outlook.com"
|
|
||||||
|
|
||||||
func IsAdmin(user *database.User) bool {
|
|
||||||
if user == nil {
|
|
||||||
return false
|
|
||||||
}
|
|
||||||
return user.Username == AdminEmail
|
|
||||||
}
|
|
||||||
|
|
||||||
func IsAdminFromContext(ctx interface {
|
|
||||||
Value(key interface{}) interface{}
|
|
||||||
}) bool {
|
|
||||||
const userKey = "mal:user"
|
|
||||||
user, _ := ctx.Value(userKey).(*database.User)
|
|
||||||
return IsAdmin(user)
|
|
||||||
}
|
|
||||||
@@ -2,7 +2,6 @@ package layout
|
|||||||
|
|
||||||
import (
|
import (
|
||||||
"mal/web/components/icons"
|
"mal/web/components/icons"
|
||||||
"mal/web/shared/admin"
|
|
||||||
)
|
)
|
||||||
|
|
||||||
templ Layout(title string, showHeader bool) {
|
templ Layout(title string, showHeader bool) {
|
||||||
@@ -63,14 +62,6 @@ 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
|
||||||
|
|||||||
@@ -1,230 +0,0 @@
|
|||||||
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>
|
|
||||||
<button
|
|
||||||
hx-delete={ templ.URL(fmt.Sprintf("/admin/users/%s/delete", user.ID)) }
|
|
||||||
hx-target="#users-list"
|
|
||||||
hx-swap="outerHTML"
|
|
||||||
hx-confirm={ fmt.Sprintf("Are you sure you want to delete %s? This cannot be undone.", user.Username) }
|
|
||||||
class="rounded bg-(--surface-button) px-3 py-1 text-xs text-(--danger) opacity-80 transition-opacity duration-150 hover:opacity-100"
|
|
||||||
>
|
|
||||||
Delete
|
|
||||||
</button>
|
|
||||||
</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