refactor(ui): complete ui template migration and fix playback

This commit is contained in:
2026-05-01 17:28:09 +02:00
committed by Mikkel Elvers
parent 33a939ca81
commit 4f3a61e143
23 changed files with 1298 additions and 68 deletions

View File

@@ -6,8 +6,10 @@ import (
"log"
"net/http"
"strconv"
"strings"
"mal/integrations/jikan"
ctxpkg "mal/internal/context"
"mal/internal/db"
"mal/templates"
)
@@ -26,7 +28,9 @@ type quickSearchResult struct {
func renderNotFoundPage(r *http.Request, w http.ResponseWriter) {
w.WriteHeader(http.StatusNotFound)
if err := templates.GetRenderer().ExecuteTemplate(w, "not_found.gohtml", nil); err != nil {
if err := templates.GetRenderer().ExecuteTemplate(w, "not_found.gohtml", map[string]any{
"CurrentPath": r.URL.Path,
}); err != nil {
log.Printf("render error: %v", err)
http.Error(w, "Internal Server Error", http.StatusInternalServerError)
}
@@ -62,12 +66,63 @@ func (h *Handler) HandleCatalog(w http.ResponseWriter, r *http.Request) {
return
}
if len(animes.Animes) > 4 {
animes.Animes = animes.Animes[:4]
currentlyAiring, err := h.jikanClient.GetSeasonsNow(r.Context(), 1)
if err != nil {
log.Printf("seasons now error: %v", err)
// non-fatal
}
if len(animes.Animes) > 6 {
animes.Animes = animes.Animes[:6]
}
if len(currentlyAiring.Animes) > 6 {
currentlyAiring.Animes = currentlyAiring.Animes[:6]
}
// Fetch continue watching if logged in (user ID in context, handle this safely)
// We'll skip DB fetch for continue watching for now if it requires complex session parsing
// Actually we should try to fetch it if we can.
var cw []database.GetContinueWatchingEntriesRow
user, userOk := r.Context().Value(ctxpkg.UserKey).(*database.User)
if userOk && user != nil {
cw, _ = h.db.GetContinueWatchingEntries(r.Context(), user.ID)
}
if err := templates.GetRenderer().ExecuteTemplate(w, "index.gohtml", map[string]any{
"Animes": animes.Animes,
"MostPopular": animes.Animes,
"CurrentlyAiring": currentlyAiring.Animes,
"ContinueWatching": cw,
"User": user,
"CurrentPath": r.URL.Path,
}); err != nil {
log.Printf("render error: %v", err)
http.Error(w, "Internal Server Error", http.StatusInternalServerError)
}
}
func (h *Handler) HandleBrowse(w http.ResponseWriter, r *http.Request) {
user, _ := r.Context().Value(ctxpkg.UserKey).(*database.User)
q := r.URL.Query().Get("q")
animeType := r.URL.Query().Get("type")
status := r.URL.Query().Get("status")
orderBy := r.URL.Query().Get("order_by")
sort := r.URL.Query().Get("sort")
res, err := h.jikanClient.SearchAdvanced(r.Context(), q, animeType, status, orderBy, sort, 1, 24)
if err != nil {
log.Printf("browse error: %v", err)
}
if err := templates.GetRenderer().ExecuteTemplate(w, "browse.gohtml", map[string]any{
"User": user,
"CurrentPath": r.URL.Path,
"Query": q,
"Type": animeType,
"Status": status,
"OrderBy": orderBy,
"Sort": sort,
"Animes": res.Animes,
}); err != nil {
log.Printf("render error: %v", err)
http.Error(w, "Internal Server Error", http.StatusInternalServerError)
@@ -87,11 +142,65 @@ func (h *Handler) HandleAPICatalog(w http.ResponseWriter, r *http.Request) {
}
func (h *Handler) HandleAnimeDetails(w http.ResponseWriter, r *http.Request) {
renderNotFoundPage(r, w)
idStr := strings.TrimPrefix(r.URL.Path, "/anime/")
idStr = strings.TrimSuffix(idStr, "/")
id, err := strconv.Atoi(idStr)
if err != nil {
renderNotFoundPage(r, w)
return
}
anime, err := h.jikanClient.GetAnimeByID(r.Context(), id)
if err != nil {
renderNotFoundPage(r, w)
return
}
user, _ := r.Context().Value(ctxpkg.UserKey).(*database.User)
var status string
if user != nil {
entry, err := h.db.GetWatchListEntry(r.Context(), database.GetWatchListEntryParams{
UserID: user.ID,
AnimeID: int64(id),
})
if err == nil {
status = entry.Status
}
}
if err := templates.GetRenderer().ExecuteTemplate(w, "anime.gohtml", map[string]any{
"Anime": anime,
"User": user,
"Status": status,
"CurrentPath": r.URL.Path,
}); err != nil {
log.Printf("render error: %v", err)
http.Error(w, "Internal Server Error", http.StatusInternalServerError)
}
}
func (h *Handler) HandleAPIAnime(w http.ResponseWriter, r *http.Request) {
renderNotFoundPage(r, w)
func (h *Handler) HandleHTMLWatchOrder(w http.ResponseWriter, r *http.Request) {
animeIdStr := r.URL.Query().Get("animeId")
id, err := strconv.Atoi(animeIdStr)
if err != nil {
http.Error(w, `<div class="mt-8 text-sm text-red-400">Invalid anime ID.</div>`, http.StatusBadRequest)
return
}
relations, err := h.jikanClient.GetFullRelations(r.Context(), id)
if err != nil {
log.Printf("watch order error: %v", err)
http.Error(w, `<div class="mt-8 text-sm text-red-400">Failed to load watch order.</div>`, http.StatusInternalServerError)
return
}
if err := templates.GetRenderer().ExecuteFragment(w, "anime.gohtml", "watch_order", map[string]any{
"Relations": relations,
"AnimeID": id,
}); err != nil {
log.Printf("render error: %v", err)
}
}
func (h *Handler) HandleAPIEpisodes(w http.ResponseWriter, r *http.Request) {
@@ -106,7 +215,7 @@ func (h *Handler) HandleQuickSearch(w http.ResponseWriter, r *http.Request) {
json.NewEncoder(w).Encode([]quickSearchResult{})
return
}
res, err := h.jikanClient.SearchWithLimit(r.Context(), query, 1, 5)
res, err := h.jikanClient.SearchAdvanced(r.Context(), query, "", "", "", "", 1, 5)
if err != nil {
log.Printf("quick search error: %v", err)
w.WriteHeader(http.StatusOK)

View File

@@ -25,21 +25,19 @@ func rateLimitErrorFromQuery(r *http.Request) string {
}
func (h *Handler) HandleLoginPage(w http.ResponseWriter, r *http.Request) {
err := templates.GetRenderer().ExecuteTemplate(w, "login.gohtml", map[string]any{
"Error": rateLimitErrorFromQuery(r),
"Username": "",
})
if err != nil {
if err := templates.GetRenderer().ExecuteTemplate(w, "login.gohtml", map[string]any{
"CurrentPath": r.URL.Path,
}); err != nil {
log.Printf("render error: %v", err)
http.Error(w, "Internal Server Error", http.StatusInternalServerError)
}
}
func (h *Handler) HandleLogin(w http.ResponseWriter, r *http.Request) {
if err := r.ParseForm(); err != nil {
templates.GetRenderer().ExecuteTemplate(w, "login.gohtml", map[string]any{
"Error": "Something went wrong. Please try again.",
"Username": "",
"Error": "Something went wrong. Please try again.",
"Username": "",
"CurrentPath": r.URL.Path,
})
return
}
@@ -49,8 +47,9 @@ func (h *Handler) HandleLogin(w http.ResponseWriter, r *http.Request) {
if username == "" || password == "" {
templates.GetRenderer().ExecuteTemplate(w, "login.gohtml", map[string]any{
"Error": "The email or password is wrong.",
"Username": username,
"Error": "The email or password is wrong.",
"Username": username,
"CurrentPath": r.URL.Path,
})
return
}
@@ -58,8 +57,9 @@ func (h *Handler) HandleLogin(w http.ResponseWriter, r *http.Request) {
session, err := h.authService.Login(r.Context(), username, password)
if err != nil {
templates.GetRenderer().ExecuteTemplate(w, "login.gohtml", map[string]any{
"Error": "The email or password is wrong.",
"Username": username,
"Error": "The email or password is wrong.",
"Username": username,
"CurrentPath": r.URL.Path,
})
return
}

View File

@@ -1,10 +1,16 @@
package playback
import (
"encoding/json"
"io"
"log"
"net/http"
"strconv"
"strings"
"mal/integrations/jikan"
ctxpkg "mal/internal/context"
"mal/internal/db"
"mal/templates"
)
@@ -17,14 +23,123 @@ func NewHandler(svc *Service, jikanClient *jikan.Client) *Handler {
return &Handler{svc: svc, jikanClient: jikanClient}
}
func renderNotFoundPage(r *http.Request, w http.ResponseWriter) {
w.WriteHeader(http.StatusNotFound)
if err := templates.GetRenderer().ExecuteTemplate(w, "not_found.gohtml", map[string]any{
"CurrentPath": r.URL.Path,
}); err != nil {
log.Printf("render error: %v", err)
}
}
func (h *Handler) HandleWatchPage(w http.ResponseWriter, r *http.Request) {
if err := templates.GetRenderer().ExecuteTemplate(w, "not_found.gohtml", nil); err != nil {
// Path is like /anime/123/watch
parts := strings.Split(r.URL.Path, "/")
if len(parts) < 4 {
renderNotFoundPage(r, w)
return
}
idStr := parts[2]
id, err := strconv.Atoi(idStr)
if err != nil {
renderNotFoundPage(r, w)
return
}
anime, err := h.jikanClient.GetAnimeByID(r.Context(), id)
if err != nil {
renderNotFoundPage(r, w)
return
}
// Try to get video episodes first (for thumbnails)
episodes, err := h.jikanClient.GetVideoEpisodes(r.Context(), id, 1)
if err != nil || len(episodes.Data) == 0 {
// Fallback to standard episodes if no video episodes
episodes, err = h.jikanClient.GetEpisodes(r.Context(), id, 1)
if err != nil {
log.Printf("watch error: %v", err)
}
}
user, _ := r.Context().Value(ctxpkg.UserKey).(*database.User)
currentEpID := r.URL.Query().Get("ep")
if currentEpID == "" {
currentEpID = "1"
}
mode := r.URL.Query().Get("mode")
userID := ""
if user != nil {
userID = user.ID
}
titleCandidates := []string{anime.Title}
if anime.TitleEnglish != "" && anime.TitleEnglish != anime.Title {
titleCandidates = append(titleCandidates, anime.TitleEnglish)
}
if anime.TitleJapanese != "" {
titleCandidates = append(titleCandidates, anime.TitleJapanese)
}
watchData, err := h.svc.BuildWatchPageData(r.Context(), id, titleCandidates, currentEpID, mode, userID)
if err != nil {
log.Printf("watch data error: %v", err)
}
if err := templates.GetRenderer().ExecuteTemplate(w, "watch.gohtml", map[string]any{
"Anime": anime,
"Episodes": episodes.Data,
"WatchData": watchData,
"User": user,
"CurrentPath": r.URL.Path,
"CurrentEpID": currentEpID,
}); err != nil {
log.Printf("render error: %v", err)
}
}
func (h *Handler) HandleProxy(w http.ResponseWriter, r *http.Request) {
http.Error(w, "Not implemented", http.StatusNotImplemented)
token := r.URL.Query().Get("token")
if token == "" {
http.Error(w, "missing token", http.StatusBadRequest)
return
}
scope := proxyScopeStream
if strings.HasSuffix(r.URL.Path, "/segment") {
scope = proxyScopeSegment
} else if strings.HasSuffix(r.URL.Path, "/subtitle") {
scope = proxyScopeSubtitle
}
targetURL, referer, err := h.svc.resolveProxyToken(r.Context(), token, scope)
if err != nil {
http.Error(w, "invalid token", http.StatusForbidden)
return
}
rangeHeader := r.Header.Get("Range")
statusCode, headers, content, bodyReader, err := h.svc.ProxyStream(r.Context(), targetURL, referer, rangeHeader)
if err != nil {
log.Printf("proxy error for %s: %v", targetURL, err)
http.Error(w, "proxy failed", http.StatusBadGateway)
return
}
for k, v := range headers {
w.Header()[k] = v
}
w.WriteHeader(statusCode)
if bodyReader != nil {
defer bodyReader.Close()
_, _ = io.Copy(w, bodyReader)
} else {
_, _ = w.Write(content)
}
}
func (h *Handler) HandleSaveProgress(w http.ResponseWriter, r *http.Request) {
@@ -36,5 +151,58 @@ func (h *Handler) HandleCompleteAnime(w http.ResponseWriter, r *http.Request) {
}
func (h *Handler) HandleEpisodeData(w http.ResponseWriter, r *http.Request) {
http.Error(w, "Not implemented", http.StatusNotImplemented)
parts := strings.Split(r.URL.Path, "/")
// /api/watch/episode/{animeId}/{episodeId}
if len(parts) < 6 {
http.Error(w, "invalid path", http.StatusBadRequest)
return
}
animeID, err := strconv.Atoi(parts[4])
if err != nil {
http.Error(w, "invalid animeId", http.StatusBadRequest)
return
}
episodeID := parts[5]
user, _ := r.Context().Value(ctxpkg.UserKey).(*database.User)
userID := ""
if user != nil {
userID = user.ID
}
anime, err := h.jikanClient.GetAnimeByID(r.Context(), animeID)
if err != nil {
http.Error(w, "anime not found", http.StatusNotFound)
return
}
titleCandidates := []string{anime.Title}
if anime.TitleEnglish != "" && anime.TitleEnglish != anime.Title {
titleCandidates = append(titleCandidates, anime.TitleEnglish)
}
if anime.TitleJapanese != "" {
titleCandidates = append(titleCandidates, anime.TitleJapanese)
}
watchData, err := h.svc.BuildWatchPageData(r.Context(), animeID, titleCandidates, episodeID, "", userID)
if err != nil {
http.Error(w, "failed to build watch data", http.StatusBadGateway)
return
}
w.Header().Set("Content-Type", "application/json")
json.NewEncoder(w).Encode(map[string]any{
"mal_id": watchData.MalID,
"title": watchData.Title,
"current_episode": watchData.CurrentEpisode,
"total_episodes": anime.Episodes,
"initial_mode": watchData.InitialMode,
"token": "", // The token might be per-source, wait, in Go it was per-mode?
"available_modes": watchData.AvailableModes,
"mode_sources": watchData.ModeSources,
"segments": watchData.Segments,
"episode_title": "", // Find episode title if possible
})
}

View File

@@ -32,7 +32,9 @@ func (h *Handler) HandleDeleteContinueWatching(w http.ResponseWriter, r *http.Re
}
func (h *Handler) HandleGetWatchlist(w http.ResponseWriter, r *http.Request) {
if err := templates.GetRenderer().ExecuteTemplate(w, "not_found.gohtml", nil); err != nil {
if err := templates.GetRenderer().ExecuteTemplate(w, "not_found.gohtml", map[string]any{
"CurrentPath": r.URL.Path,
}); err != nil {
log.Printf("render error: %v", err)
}
}

View File

@@ -68,8 +68,16 @@ func NewRouter(cfg Config) http.Handler {
mux.HandleFunc("/", animeHandler.HandleCatalog)
mux.HandleFunc("/search", animeHandler.HandleSearch)
mux.HandleFunc("/browse", animeHandler.HandleBrowse)
mux.HandleFunc("/api/search-quick", animeHandler.HandleQuickSearch)
mux.HandleFunc("/anime/", animeHandler.HandleAnimeDetails)
mux.HandleFunc("/anime/", func(w http.ResponseWriter, r *http.Request) {
if strings.HasSuffix(r.URL.Path, "/watch") {
playbackHandler.HandleWatchPage(w, r)
return
}
animeHandler.HandleAnimeDetails(w, r)
})
mux.HandleFunc("/api/watch-order", animeHandler.HandleHTMLWatchOrder)
mux.HandleFunc("/watch/", playbackHandler.HandleWatchPage)
mux.HandleFunc("/watch/proxy/stream", playbackHandler.HandleProxy)
mux.HandleFunc("/watch/proxy/segment", playbackHandler.HandleProxy)

55
static/dropdown.ts Normal file
View File

@@ -0,0 +1,55 @@
class UIDropdown extends HTMLElement {
isOpen: boolean = false
contentEl: HTMLElement | null = null
constructor() {
super()
this.toggle = this.toggle.bind(this)
this.handleClickOutside = this.handleClickOutside.bind(this)
}
connectedCallback() {
const trigger = this.querySelector('[data-trigger]')
this.contentEl = this.querySelector('[data-content]')
if (trigger) {
trigger.addEventListener('click', this.toggle)
}
document.addEventListener('click', this.handleClickOutside)
}
disconnectedCallback() {
const trigger = this.querySelector('[data-trigger]')
if (trigger) {
trigger.removeEventListener('click', this.toggle)
}
document.removeEventListener('click', this.handleClickOutside)
}
toggle() {
this.isOpen = !this.isOpen
if (this.contentEl) {
if (this.isOpen) {
this.contentEl.classList.remove('hidden')
} else {
this.contentEl.classList.add('hidden')
}
}
}
close() {
this.isOpen = false
if (this.contentEl) {
this.contentEl.classList.add('hidden')
}
}
handleClickOutside(event: MouseEvent) {
if (!this.contains(event.target as Node)) {
this.close()
}
}
}
customElements.define('ui-dropdown', UIDropdown)

View File

@@ -5,17 +5,31 @@
@source "..";
@source "../web/**/*.templ";
@theme {
--color-background: #080808;
--color-background-sidebar: #0f0f0f;
--color-background-header: #141414;
--color-background-surface: #202020;
--color-background-button: #1a1a1a;
--color-background-button-hover: #252525;
--color-foreground-muted: #6a6b70;
--color-foreground: #f8f9fa;
--color-accent: #7cb518;
}
:root {
color-scheme: light dark;
--bg: light-dark(#fafaf9, #080808);
--bg: var(--color-background);
--panel: light-dark(#f5f5f4, #181818);
--panel-soft: light-dark(#e7e5e4, #202020);
--header: light-dark(#ffffff, #101010);
--text: light-dark(#1c1917, #e7e5e4);
--text-muted: light-dark(#57534e, #a8a29e);
--text-faint: light-dark(#a8a29e, #78716c);
--accent: light-dark(#0c0a09, #fafaf9);
--accent: var(--color-accent);
--danger: #dc2626;
--surface-search: light-dark(#f5f5f4, #181818);
--surface-search-focus-border: light-dark(rgba(12, 10, 9, 0.12), rgba(255, 255, 255, 0.12));
@@ -51,3 +65,39 @@
[data-theme="dark"] {
color-scheme: dark;
}
html, body {
background-color: var(--color-background);
color: var(--text);
}
.scrollbar-hide::-webkit-scrollbar {
display: none;
}
.scrollbar-hide {
-ms-overflow-style: none;
scrollbar-width: none;
}
@media (min-width: 1024px) {
.scrollbar-hide::-webkit-scrollbar {
display: block;
height: 8px;
}
.scrollbar-hide::-webkit-scrollbar-track {
background: rgba(255, 255, 255, 0.05);
border-radius: 4px;
}
.scrollbar-hide::-webkit-scrollbar-thumb {
background: rgba(255, 255, 255, 0.2);
border-radius: 4px;
}
.scrollbar-hide::-webkit-scrollbar-thumb:hover {
background: rgba(255, 255, 255, 0.3);
}
.scrollbar-hide {
-ms-overflow-style: auto;
scrollbar-width: thin;
scrollbar-color: rgba(255, 255, 255, 0.2) rgba(255, 255, 255, 0.05);
}
}

65
templates/anime.gohtml Normal file
View File

@@ -0,0 +1,65 @@
{{define "title"}}{{.Anime.DisplayTitle}}{{end}}
{{define "content"}}
{{$anime := .Anime}}
<div class="flex flex-col gap-10">
<div class="flex flex-col gap-8 md:flex-row lg:gap-12">
<div class="flex w-64 shrink-0 flex-col items-center gap-6 md:w-80 md:items-start lg:w-96">
<div class="aspect-[2/3] w-full overflow-hidden bg-white/5 shadow-lg">
{{$imageUrl := "https://placehold.co/400x600?text=No+Image"}}
{{if $anime.Images.Webp.LargeImageURL}}
{{$imageUrl = $anime.Images.Webp.LargeImageURL}}
{{else if $anime.Images.Jpg.LargeImageURL}}
{{$imageUrl = $anime.Images.Jpg.LargeImageURL}}
{{end}}
<img src="{{$imageUrl}}" alt="{{$anime.Title}}" class="h-full w-full object-cover" />
</div>
</div>
<div class="flex grow flex-col">
<h1 class="mb-4 text-2xl font-bold text-white md:text-4xl">
{{$anime.DisplayTitle}}
</h1>
{{if and $anime.TitleEnglish (ne $anime.Title $anime.TitleEnglish)}}
<h2 class="text-foreground-muted mb-4 text-base">{{$anime.Title}}</h2>
{{end}}
{{template "watchlist_actions" dict "Anime" $anime "User" .User "Status" .Status}}
<div class="space-y-4">
<div>
<h2 class="mb-2 text-lg font-medium text-neutral-300">Synopsis</h2>
<p id="synopsis-container" class="text-foreground-muted max-w-4xl text-base leading-relaxed whitespace-pre-line line-clamp-5 md:line-clamp-none">
{{if $anime.Synopsis}}{{$anime.Synopsis}}{{else}}No synopsis available.{{end}}
</p>
{{if and $anime.Synopsis (gt (len $anime.Synopsis) 200)}}
<button id="synopsis-toggle" class="mt-2 text-sm font-medium text-neutral-300 transition-colors hover:text-white md:hidden" onclick="
const container = document.getElementById('synopsis-container');
const btn = document.getElementById('synopsis-toggle');
if (container.classList.contains('line-clamp-5')) {
container.classList.remove('line-clamp-5');
btn.textContent = 'Show less';
} else {
container.classList.add('line-clamp-5');
btn.textContent = 'Read more';
}
">
Read more
</button>
{{end}}
</div>
</div>
</div>
</div>
<div class="w-full">
<div hx-get="/api/watch-order?animeId={{$anime.MalID}}" hx-trigger="load">
<div class="mt-8 flex items-center gap-3 text-neutral-400">
<div class="border-t-accent size-5 animate-spin rounded-full border-2 border-neutral-600"></div>
<span class="text-sm">Loading watch order sequence...</span>
</div>
</div>
</div>
</div>
{{end}}

View File

@@ -4,14 +4,52 @@
<meta charset="UTF-8">
<meta name="viewport" content="width=device-width, initial-scale=1.0">
<title>{{template "title" .}} - MAL</title>
<link rel="stylesheet" href="/static/style.css">
<link rel="icon" type="image/svg+xml" href="/static/favicon.svg">
<link rel="stylesheet" href="/dist/tailwind.css">
<script type="module" src="/dist/static/theme.js" defer></script>
<script type="module" src="/dist/static/dropdown.js" defer></script>
<script type="module" src="/dist/static/discover.js" defer></script>
<script type="module" src="/dist/static/anime.js" defer></script>
<script type="module" src="/dist/static/timezone.js" defer></script>
<script type="module" src="/dist/static/player.js" defer></script>
<script type="module" src="/dist/static/search.js" defer></script>
<script type="module" src="/dist/static/sort_filter.js" defer></script>
<script src="https://unpkg.com/htmx.org@1.9.12"></script>
<script>
function toggleMobileMenu() {
const menu = document.getElementById('mobile-menu');
if (menu.classList.contains('-translate-x-full')) {
menu.classList.remove('-translate-x-full');
document.getElementById('mobile-overlay').classList.remove('hidden');
} else {
menu.classList.add('-translate-x-full');
document.getElementById('mobile-overlay').classList.add('hidden');
}
}
</script>
</head>
<body class="bg-gray-900 text-white">
<header class="p-4">
<h1 class="text-2xl font-bold">MAL</h1>
</header>
<main class="container mx-auto px-4 py-8">
{{template "content" .}}
</main>
<body class="bg-background text-neutral-200">
<div class="flex min-h-screen flex-col">
<div class="sticky top-0 z-50">
{{block "header" .}}
{{template "header" .}}
{{end}}
</div>
<div class="flex flex-1">
<button id="mobile-overlay" class="hidden fixed inset-0 z-40 w-full cursor-default border-none bg-black/60 backdrop-blur-sm outline-none lg:hidden" onclick="toggleMobileMenu()" aria-label="Close mobile menu"></button>
<!-- Sidebar -->
<div id="mobile-menu" class="fixed inset-y-0 left-0 z-50 shrink-0 overflow-hidden transform transition-all duration-300 lg:sticky lg:top-16 lg:z-auto lg:h-[calc(100vh-4rem)] -translate-x-full lg:shadow-none lg:w-64 lg:translate-x-0 w-64 shadow-2xl">
{{block "sidebar" .}}
{{template "navigation" dict "CurrentPath" .CurrentPath "IsCollapsed" false}}
{{end}}
</div>
<main class="w-full flex-1 overflow-x-hidden p-4 md:p-8 lg:p-12">
{{template "content" .}}
</main>
</div>
</div>
</body>
</html>

25
templates/browse.gohtml Normal file
View File

@@ -0,0 +1,25 @@
{{define "title"}}Browse{{end}}
{{define "content"}}
<div class="flex flex-col gap-6">
<div class="flex items-end justify-between">
<h1 class="text-lg font-normal text-neutral-300">Browse</h1>
</div>
{{template "filter_bar" .}}
<main class="w-full">
{{if eq (len .Animes) 0}}
<div class="flex h-64 flex-col items-center justify-center gap-2 text-neutral-400">
<svg class="h-12 w-12 opacity-50" viewBox="0 0 24 24" fill="none" stroke="currentColor" stroke-width="1.5"><circle cx="11" cy="11" r="8" /><line x1="21" y1="21" x2="16.65" y2="16.65" /></svg>
<p>No anime found matching your filters.</p>
</div>
{{else}}
<div class="grid grid-cols-2 gap-4 sm:grid-cols-3 md:grid-cols-4 xl:grid-cols-5 2xl:grid-cols-6">
{{range .Animes}}
{{template "anime_card" dict "Anime" . "WithActions" true}}
{{end}}
</div>
{{end}}
</main>
</div>
{{end}}

View File

@@ -1,9 +1,87 @@
{{define "anime_card"}}
<div class="bg-gray-800 rounded-lg overflow-hidden shadow-lg">
<img src="{{.ImageURL}}" alt="{{.DisplayTitle}}" class="w-full h-48 object-cover">
<div class="p-4">
<h3 class="text-lg font-semibold mb-2">{{.DisplayTitle}}</h3>
<p class="text-sm text-gray-400">{{.Type}}</p>
</div>
</div>
{{$anime := .Anime}}
{{$withActions := .WithActions}}
{{$compact := .Compact}}
{{$hideTitle := .HideTitle}}
{{$isWatchlist := .IsWatchlist}}
{{$hasTopBadge := .HasTopBadge}}
{{$imageUrl := "https://placehold.co/400x600?text=No+Image"}}
{{if $anime.Images.Webp.LargeImageURL}}
{{$imageUrl = $anime.Images.Webp.LargeImageURL}}
{{else if $anime.Images.Jpg.LargeImageURL}}
{{$imageUrl = $anime.Images.Jpg.LargeImageURL}}
{{end}}
{{$displayTitle := $anime.Title}}
{{if $anime.TitleEnglish}}
{{$displayTitle = $anime.TitleEnglish}}
{{end}}
<div class="flex w-full flex-col gap-2">
<a href="/anime/{{$anime.MalID}}" class="group relative flex aspect-[2/3] w-full flex-col overflow-hidden bg-white/5 after:absolute after:inset-0 {{if $withActions}}after:bg-black/80 after:opacity-0 hover:after:opacity-100{{else}}after:bg-linear-to-t after:from-black/80 after:via-black/20 after:to-transparent after:opacity-80 hover:after:opacity-100{{end}} after:transition-opacity">
<img src="{{$imageUrl}}" alt="{{$displayTitle}}" class="h-full w-full object-cover" loading="lazy" />
{{if $withActions}}
<div class="absolute inset-0 z-10 flex flex-col p-3 {{if $hasTopBadge}}pt-10{{end}} opacity-0 transition-opacity duration-300 group-hover:opacity-100">
{{if $isWatchlist}}
<div class="flex justify-end">
<button class="text-white/70 transition-colors hover:text-white focus:outline-none disabled:opacity-50" aria-label="Remove from Watchlist">
<svg xmlns="http://www.w3.org/2000/svg" width="20" height="20" viewBox="0 0 24 24" fill="none" stroke="currentColor" stroke-width="2" stroke-linecap="round" stroke-linejoin="round" class="size-5 shadow-black drop-shadow-md"><path d="M18 6 6 18"/><path d="m6 6 12 12"/></svg>
</button>
</div>
{{end}}
{{if not $isWatchlist}}
<h3 class="mb-1.5 line-clamp-2 text-sm font-medium text-white shadow-black drop-shadow-md">
{{$displayTitle}}
</h3>
{{end}}
{{if $anime.Synopsis}}
<p class="line-clamp-4 text-xs leading-relaxed text-white/80 shadow-black drop-shadow-md">
{{$anime.Synopsis}}
</p>
{{end}}
{{if not $isWatchlist}}
<div class="mt-auto flex items-center justify-start pb-2 pl-2">
<button class="text-accent hover:text-accent/80 transition-colors focus:outline-none disabled:opacity-50" aria-label="Add to Watchlist">
<svg class="size-6 shadow-black drop-shadow-md" viewBox="0 0 24 24" fill="none" stroke="currentColor" stroke-width="2" stroke-linecap="round" stroke-linejoin="round"><path d="m19 21-7-4-7 4V5a2 2 0 0 1 2-2h10a2 2 0 0 1 2 2v16z" /></svg>
</button>
</div>
{{end}}
</div>
{{else}}
<div class="absolute bottom-0 left-0 z-10 w-full p-4">
<h3 class="line-clamp-2 text-sm font-medium text-white shadow-black drop-shadow-md {{if $compact}}mb-0{{end}}">
{{$displayTitle}}
</h3>
{{if not $compact}}
<div class="mt-1 flex items-center gap-2 text-xs text-white/70">
{{if $anime.Score}}
<span class="flex items-center gap-1">
<svg class="text-accent h-3 w-3" viewBox="0 0 24 24" fill="currentColor"><path d="M12 2l3.09 6.26L22 9.27l-5 4.87 1.18 6.88L12 17.77l-6.18 3.25L7 14.14 2 9.27l6.91-1.01L12 2z"/></svg>
{{$anime.Score}}
</span>
{{end}}
{{if $anime.Year}}
<span>•</span>
<span>{{$anime.Year}}</span>
{{end}}
{{if $anime.Episodes}}
<span>•</span>
<span>{{$anime.Episodes}} ep</span>
{{end}}
</div>
{{end}}
</div>
{{end}}
</a>
{{if and $withActions (not $hideTitle)}}
<h3 class="line-clamp-2 text-sm font-medium text-white">
{{$displayTitle}}
</h3>
{{end}}
</div>
{{end}}

View File

@@ -0,0 +1,42 @@
{{define "continue_watching"}}
<section class="w-full">
<h2 class="mb-3 text-lg font-normal text-neutral-300">Continue Watching</h2>
<div class="scrollbar-hide flex snap-x snap-mandatory gap-2 overflow-x-auto pb-4">
{{range .}}
{{$title := .TitleOriginal}}
{{if .TitleEnglish.Valid}}{{$title = .TitleEnglish.String}}{{end}}
<a href="/anime/{{.AnimeID}}/watch" class="group relative w-70 shrink-0 snap-start cursor-pointer space-y-2 2xl:w-lg">
<div class="bg-background/80 relative aspect-video w-full overflow-hidden">
<img src="{{if .ImageUrl}}{{.ImageUrl}}{{else}}https://placehold.co/500x500{{end}}" alt="{{$title}}" class="h-full w-full object-cover" />
<div class="absolute inset-0 z-10 flex flex-col p-3 opacity-0 transition-opacity duration-300 group-hover:opacity-100 bg-black/40">
<div class="flex justify-end">
<button class="bg-black/60 hover:bg-black/80 rounded-full p-1.5 text-white transition-colors focus:outline-none disabled:opacity-50 backdrop-blur-sm" aria-label="Remove from Continue Watching">
<svg xmlns="http://www.w3.org/2000/svg" width="20" height="20" viewBox="0 0 24 24" fill="none" stroke="currentColor" stroke-width="2" stroke-linecap="round" stroke-linejoin="round" class="size-5"><path d="M18 6 6 18"/><path d="m6 6 12 12"/></svg>
</button>
</div>
</div>
{{if .CurrentTimeSeconds}}
<!-- Progress calculation would go here if total duration was available -->
<div class="bg-foreground-muted/60 absolute bottom-0 left-0 h-1.5 w-full z-20">
<div class="shadow-background/20 bg-accent h-full shadow-[0_-2px_5px_0]" style="width: 50%"></div>
</div>
{{end}}
</div>
<div>
<h3 class="text-foreground truncate text-lg font-normal">
{{$title}}
</h3>
<p class="text-foreground-muted mt-0.5 text-base">
{{if .CurrentEpisode.Valid}}Episode {{.CurrentEpisode.Int64}}{{end}}
</p>
</div>
</a>
{{end}}
</div>
</section>
{{end}}

View File

@@ -0,0 +1,13 @@
{{define "dropdown"}}
<ui-dropdown class="relative block" data-align="{{if .Align}}{{.Align}}{{else}}right{{end}}" data-position="{{if .Position}}{{.Position}}{{else}}bottom{{end}}" data-width="{{if .Width}}{{.Width}}{{else}}min-w-[320px]{{end}}">
<div data-trigger>
{{template "dropdown_trigger" .}}
</div>
<div data-content class="hidden absolute z-50 {{if .Width}}{{.Width}}{{else}}min-w-[320px]{{end}} bg-background-button rounded-none shadow-2xl {{if eq .Align "left"}}left-0{{else}}right-0{{end}} {{if eq .Position "top"}}bottom-full mb-2{{else}}top-full mt-2{{end}}">
<div class="flex flex-col py-1">
{{template "dropdown_children" .}}
</div>
</div>
</ui-dropdown>
{{end}}

View File

@@ -0,0 +1,87 @@
{{define "filter_bar"}}
<div class="flex flex-wrap items-center gap-3 bg-white/5 p-3">
<div class="min-w-50 flex-1">
<form action="/browse" method="GET" id="browse-search-form">
<input
id="search"
name="q"
type="text"
value="{{.Query}}"
placeholder="Search anime..."
class="focus:ring-accent w-full bg-black/20 px-3 py-2 text-sm text-white placeholder-neutral-500 outline-none focus:ring-1"
onkeydown="if(event.key === 'Enter'){this.form.submit()}"
/>
<!-- Preserve other params -->
{{if .Type}}<input type="hidden" name="type" value="{{.Type}}">{{end}}
{{if .Status}}<input type="hidden" name="status" value="{{.Status}}">{{end}}
{{if .OrderBy}}<input type="hidden" name="order_by" value="{{.OrderBy}}">{{end}}
{{if .Sort}}<input type="hidden" name="sort" value="{{.Sort}}">{{end}}
</form>
</div>
<ui-dropdown class="relative block" data-align="left" data-width="w-40">
<div data-trigger class="cursor-pointer">
<button class="flex items-center gap-2 bg-black/20 px-3 py-2 text-sm text-white hover:bg-black/30">
{{if .Status}}{{.Status}}{{else}}Any Status{{end}}
<svg class="h-4 w-4 opacity-50" viewBox="0 0 24 24" fill="none" stroke="currentColor" stroke-width="2"><path d="m6 9 6 6 6-6" /></svg>
</button>
</div>
<div data-content class="hidden absolute z-50 w-40 bg-background-button rounded-none shadow-2xl left-0 top-full mt-2">
<div class="flex flex-col py-1">
<a href="?status=&q={{.Query}}&type={{.Type}}&order_by={{.OrderBy}}&sort={{.Sort}}" class="flex w-full items-center px-5 py-2.5 transition-colors focus:outline-none hover:bg-white/10 text-sm text-white">Any Status</a>
<a href="?status=airing&q={{.Query}}&type={{.Type}}&order_by={{.OrderBy}}&sort={{.Sort}}" class="flex w-full items-center px-5 py-2.5 transition-colors focus:outline-none hover:bg-white/10 text-sm text-white">Airing</a>
<a href="?status=complete&q={{.Query}}&type={{.Type}}&order_by={{.OrderBy}}&sort={{.Sort}}" class="flex w-full items-center px-5 py-2.5 transition-colors focus:outline-none hover:bg-white/10 text-sm text-white">Complete</a>
<a href="?status=upcoming&q={{.Query}}&type={{.Type}}&order_by={{.OrderBy}}&sort={{.Sort}}" class="flex w-full items-center px-5 py-2.5 transition-colors focus:outline-none hover:bg-white/10 text-sm text-white">Upcoming</a>
</div>
</div>
</ui-dropdown>
<ui-dropdown class="relative block" data-align="left" data-width="w-40">
<div data-trigger class="cursor-pointer">
<button class="flex items-center gap-2 bg-black/20 px-3 py-2 text-sm text-white hover:bg-black/30">
{{if .Type}}{{.Type}}{{else}}Any Type{{end}}
<svg class="h-4 w-4 opacity-50" viewBox="0 0 24 24" fill="none" stroke="currentColor" stroke-width="2"><path d="m6 9 6 6 6-6" /></svg>
</button>
</div>
<div data-content class="hidden absolute z-50 w-40 bg-background-button rounded-none shadow-2xl left-0 top-full mt-2">
<div class="flex flex-col py-1">
<a href="?type=&q={{.Query}}&status={{.Status}}&order_by={{.OrderBy}}&sort={{.Sort}}" class="flex w-full items-center px-5 py-2.5 transition-colors focus:outline-none hover:bg-white/10 text-sm text-white">Any Type</a>
<a href="?type=tv&q={{.Query}}&status={{.Status}}&order_by={{.OrderBy}}&sort={{.Sort}}" class="flex w-full items-center px-5 py-2.5 transition-colors focus:outline-none hover:bg-white/10 text-sm text-white">TV</a>
<a href="?type=movie&q={{.Query}}&status={{.Status}}&order_by={{.OrderBy}}&sort={{.Sort}}" class="flex w-full items-center px-5 py-2.5 transition-colors focus:outline-none hover:bg-white/10 text-sm text-white">Movie</a>
<a href="?type=ova&q={{.Query}}&status={{.Status}}&order_by={{.OrderBy}}&sort={{.Sort}}" class="flex w-full items-center px-5 py-2.5 transition-colors focus:outline-none hover:bg-white/10 text-sm text-white">OVA</a>
<a href="?type=special&q={{.Query}}&status={{.Status}}&order_by={{.OrderBy}}&sort={{.Sort}}" class="flex w-full items-center px-5 py-2.5 transition-colors focus:outline-none hover:bg-white/10 text-sm text-white">Special</a>
<a href="?type=ona&q={{.Query}}&status={{.Status}}&order_by={{.OrderBy}}&sort={{.Sort}}" class="flex w-full items-center px-5 py-2.5 transition-colors focus:outline-none hover:bg-white/10 text-sm text-white">ONA</a>
</div>
</div>
</ui-dropdown>
<div class="flex items-center gap-1">
<ui-dropdown class="relative block" data-align="left" data-width="w-48">
<div data-trigger class="cursor-pointer">
<button class="flex items-center gap-2 bg-black/20 px-3 py-2 text-sm text-white hover:bg-black/30">
Sort: {{if .OrderBy}}{{.OrderBy}}{{else}}Default{{end}}
<svg class="h-4 w-4 opacity-50" viewBox="0 0 24 24" fill="none" stroke="currentColor" stroke-width="2"><path d="m6 9 6 6 6-6" /></svg>
</button>
</div>
<div data-content class="hidden absolute z-50 w-48 bg-background-button rounded-none shadow-2xl left-0 top-full mt-2">
<div class="flex flex-col py-1">
<a href="?order_by=&q={{.Query}}&status={{.Status}}&type={{.Type}}&sort={{.Sort}}" class="flex w-full items-center px-5 py-2.5 transition-colors focus:outline-none hover:bg-white/10 text-sm text-white">Default</a>
<a href="?order_by=popularity&q={{.Query}}&status={{.Status}}&type={{.Type}}&sort={{.Sort}}" class="flex w-full items-center px-5 py-2.5 transition-colors focus:outline-none hover:bg-white/10 text-sm text-white">Popularity</a>
<a href="?order_by=score&q={{.Query}}&status={{.Status}}&type={{.Type}}&sort={{.Sort}}" class="flex w-full items-center px-5 py-2.5 transition-colors focus:outline-none hover:bg-white/10 text-sm text-white">Score</a>
<a href="?order_by=title&q={{.Query}}&status={{.Status}}&type={{.Type}}&sort={{.Sort}}" class="flex w-full items-center px-5 py-2.5 transition-colors focus:outline-none hover:bg-white/10 text-sm text-white">Title</a>
<a href="?order_by=start_date&q={{.Query}}&status={{.Status}}&type={{.Type}}&sort={{.Sort}}" class="flex w-full items-center px-5 py-2.5 transition-colors focus:outline-none hover:bg-white/10 text-sm text-white">Start Date</a>
<a href="?order_by=episodes&q={{.Query}}&status={{.Status}}&type={{.Type}}&sort={{.Sort}}" class="flex w-full items-center px-5 py-2.5 transition-colors focus:outline-none hover:bg-white/10 text-sm text-white">Episodes</a>
</div>
</div>
</ui-dropdown>
<a href="?sort={{if eq .Sort "asc"}}desc{{else}}asc{{end}}&q={{.Query}}&status={{.Status}}&type={{.Type}}&order_by={{.OrderBy}}" class="flex h-9 w-9 items-center justify-center bg-black/20 text-neutral-300 hover:text-white">
{{if eq .Sort "asc"}}
<svg class="h-4 w-4" viewBox="0 0 24 24" fill="none" stroke="currentColor" stroke-width="2"><path d="M12 19V5M5 12l7-7 7 7" /></svg>
{{else}}
<svg class="h-4 w-4" viewBox="0 0 24 24" fill="none" stroke="currentColor" stroke-width="2"><path d="M12 5v14M19 12l-7 7-7-7" /></svg>
{{end}}
</a>
</div>
</div>
{{end}}

View File

@@ -0,0 +1,77 @@
{{define "header"}}
<header class="bg-background-header flex flex-col border-b border-white/5 relative z-50">
<div class="flex h-16 items-center justify-between px-4 md:px-6">
<div class="flex items-center gap-4 lg:w-72">
<button onclick="toggleMobileMenu()" class="block text-neutral-400 transition-colors hover:text-white focus:outline-none md:hidden">
<svg xmlns="http://www.w3.org/2000/svg" width="24" height="24" viewBox="0 0 24 24" fill="none" stroke="currentColor" stroke-width="2" stroke-linecap="round" stroke-linejoin="round"><line x1="4" x2="20" y1="12" y2="12"/><line x1="4" x2="20" y1="6" y2="6"/><line x1="4" x2="20" y1="18" y2="18"/></svg>
</button>
<a href="/" class="group flex items-center gap-2 focus:outline-none">
<svg class="h-7 w-7 transition-transform group-hover:scale-110" viewBox="0 0 100 100" fill="none" xmlns="http://www.w3.org/2000/svg">
<path d="M15 80 L25 30 L50 60 L75 30 L85 80" stroke="currentColor" class="text-accent" stroke-width="12" stroke-linecap="round" stroke-linejoin="round"/>
<circle cx="50" cy="25" r="8" fill="white" />
</svg>
<span class="text-xl font-bold tracking-tight text-white hidden sm:block">MyAnime<span class="text-accent">List</span></span>
</a>
</div>
<div class="hidden max-w-3xl flex-1 items-center justify-center px-4 md:flex">
<form action="/search" method="GET" class="w-full max-w-lg">
<div class="focus-within:border-accent bg-background-surface flex h-10 w-full items-center overflow-hidden border border-transparent transition-colors">
<div class="pr-2 pl-4 text-neutral-400">
<svg xmlns="http://www.w3.org/2000/svg" width="18" height="18" viewBox="0 0 24 24" fill="none" stroke="currentColor" stroke-width="2" stroke-linecap="round" stroke-linejoin="round"><circle cx="11" cy="11" r="8"/><path d="m21 21-4.3-4.3"/></svg>
</div>
<input type="text" name="q" placeholder="Search..." class="h-full w-full bg-transparent px-1 text-sm text-white focus:outline-none" />
</div>
</form>
</div>
<div class="flex items-center justify-end lg:w-72 relative">
<ui-dropdown class="relative block">
<div data-trigger class="cursor-pointer">
<button class="flex items-center gap-1 rounded-full p-1 transition-colors hover:bg-white/5 focus:outline-none">
{{if .User}}
<div class="bg-accent flex h-8 w-8 items-center justify-center overflow-hidden rounded-full text-sm font-semibold text-white">
{{slice .User.Username 0 1}}
</div>
{{else}}
<div class="bg-accent flex h-8 w-8 items-center justify-center rounded-full text-sm font-semibold text-white">
<svg xmlns="http://www.w3.org/2000/svg" width="16" height="16" viewBox="0 0 24 24" fill="none" stroke="currentColor" stroke-width="2" stroke-linecap="round" stroke-linejoin="round"><path d="M19 21v-2a4 4 0 0 0-4-4H9a4 4 0 0 0-4 4v2"/><circle cx="12" cy="7" r="4"/></svg>
</div>
{{end}}
<svg xmlns="http://www.w3.org/2000/svg" width="16" height="16" viewBox="0 0 24 24" fill="none" stroke="currentColor" stroke-width="2" stroke-linecap="round" stroke-linejoin="round" class="text-neutral-400"><path d="m6 9 6 6 6-6"/></svg>
</button>
</div>
<div data-content class="hidden absolute z-50 min-w-[320px] bg-background-button rounded-none shadow-2xl right-0 top-full mt-2">
<div class="flex flex-col py-1">
{{if .User}}
<a href="/logout" class="flex w-full items-center px-5 py-3.5 transition-colors focus:outline-none hover:bg-red-500/10 focus:bg-red-500/10">
<div class="flex w-full items-center justify-between text-left">
<span class="font-medium text-[15px] text-red-500">Log out</span>
</div>
</a>
{{else}}
<a href="/login" class="flex w-full items-center px-5 py-3.5 transition-colors focus:outline-none hover:bg-white/10 focus:bg-white/10">
<div class="flex w-full items-center justify-between text-left">
<span class="font-medium text-[15px] text-white">Log in</span>
</div>
</a>
{{end}}
</div>
</div>
</ui-dropdown>
</div>
</div>
<div class="flex border-t border-white/5 p-3 md:hidden">
<form action="/search" method="GET" class="w-full">
<div class="focus-within:border-accent bg-background-surface flex h-10 w-full items-center overflow-hidden border border-transparent transition-colors">
<div class="pr-2 pl-4 text-neutral-400">
<svg xmlns="http://www.w3.org/2000/svg" width="18" height="18" viewBox="0 0 24 24" fill="none" stroke="currentColor" stroke-width="2" stroke-linecap="round" stroke-linejoin="round"><circle cx="11" cy="11" r="8"/><path d="m21 21-4.3-4.3"/></svg>
</div>
<input type="text" name="q" placeholder="Search..." class="h-full w-full bg-transparent px-1 text-sm text-white focus:outline-none" />
</div>
</form>
</div>
</header>
{{end}}

View File

@@ -0,0 +1,87 @@
{{define "navigation"}}
{{$currentPath := .CurrentPath}}
{{$isCollapsed := .IsCollapsed}}
{{$navItems := dict
"home" (dict "href" "/" "label" "Home")
"browse" (dict "href" "/browse" "label" "Browse")
"discover" (dict "href" "/discover" "label" "Discover")
"watchlist" (dict "href" "/watchlist" "label" "Watchlist")
}}
<nav class="bg-background-sidebar h-full py-6">
<div class="flex flex-col">
{{/* Home */}}
{{$isActive := eq $currentPath "/"}}
<a href="/" class="group relative flex items-center px-7 py-3 transition-colors hover:bg-white/5" {{if $isCollapsed}}title="Home"{{end}}>
{{if $isActive}}
<div class="bg-accent absolute top-1/2 left-0 h-8 w-0.5 -translate-y-1/2 rounded-r-sm shadow-[0_0_8px_rgba(163,230,53,0.6)]"></div>
{{end}}
<svg class="size-6 shrink-0 transition-colors duration-200 {{if $isActive}}text-accent{{else}}text-foreground-muted{{end}}" viewBox="0 0 24 24" fill="none" stroke="currentColor" stroke-width="1.5" stroke-linecap="round" stroke-linejoin="round">
<path d="M3 9l9-7 9 7v11a2 2 0 0 1-2 2H5a2 2 0 0 1-2-2z" />
<polyline points="9 22 9 12 15 12 15 22" />
</svg>
<div class="grid transition-all duration-300 ease-in-out {{if $isCollapsed}}grid-cols-[0fr] opacity-0 ml-0{{else}}grid-cols-[1fr] opacity-100 ml-4{{end}}">
<div class="overflow-hidden min-w-0">
<span class="whitespace-nowrap text-sm font-medium transition-colors duration-200 {{if $isActive}}text-accent{{else}}text-foreground-muted{{end}}">Home</span>
</div>
</div>
</a>
{{/* Browse */}}
{{$isActive := eq $currentPath "/browse"}}
<a href="/browse" class="group relative flex items-center px-7 py-3 transition-colors hover:bg-white/5" {{if $isCollapsed}}title="Browse"{{end}}>
{{if $isActive}}
<div class="bg-accent absolute top-1/2 left-0 h-8 w-0.5 -translate-y-1/2 rounded-r-sm shadow-[0_0_8px_rgba(163,230,53,0.6)]"></div>
{{end}}
<svg class="size-6 shrink-0 transition-colors duration-200 {{if $isActive}}text-accent{{else}}text-foreground-muted{{end}}" viewBox="0 0 24 24" fill="none" stroke="currentColor" stroke-width="1.5" stroke-linecap="round" stroke-linejoin="round">
<rect x="2" y="2" width="20" height="20" rx="2" ry="2" />
<line x1="7" y1="2" x2="7" y2="22" />
<line x1="17" y1="2" x2="17" y2="22" />
<line x1="2" y1="12" x2="22" y2="12" />
<line x1="2" y1="7" x2="7" y2="7" />
<line x1="2" y1="17" x2="7" y2="17" />
<line x1="17" y1="17" x2="22" y2="17" />
<line x1="17" y1="7" x2="22" y2="7" />
</svg>
<div class="grid transition-all duration-300 ease-in-out {{if $isCollapsed}}grid-cols-[0fr] opacity-0 ml-0{{else}}grid-cols-[1fr] opacity-100 ml-4{{end}}">
<div class="overflow-hidden min-w-0">
<span class="whitespace-nowrap text-sm font-medium transition-colors duration-200 {{if $isActive}}text-accent{{else}}text-foreground-muted{{end}}">Browse</span>
</div>
</div>
</a>
{{/* Discover */}}
{{$isActive := eq $currentPath "/discover"}}
<a href="/discover" class="group relative flex items-center px-7 py-3 transition-colors hover:bg-white/5" {{if $isCollapsed}}title="Discover"{{end}}>
{{if $isActive}}
<div class="bg-accent absolute top-1/2 left-0 h-8 w-0.5 -translate-y-1/2 rounded-r-sm shadow-[0_0_8px_rgba(163,230,53,0.6)]"></div>
{{end}}
<svg class="size-6 shrink-0 transition-colors duration-200 {{if $isActive}}text-accent{{else}}text-foreground-muted{{end}}" viewBox="0 0 24 24" fill="none" stroke="currentColor" stroke-width="1.5" stroke-linecap="round" stroke-linejoin="round">
<circle cx="12" cy="12" r="10" />
<polygon points="16.24 7.76 14.12 14.12 7.76 16.24 9.88 9.88 16.24 7.76" />
</svg>
<div class="grid transition-all duration-300 ease-in-out {{if $isCollapsed}}grid-cols-[0fr] opacity-0 ml-0{{else}}grid-cols-[1fr] opacity-100 ml-4{{end}}">
<div class="overflow-hidden min-w-0">
<span class="whitespace-nowrap text-sm font-medium transition-colors duration-200 {{if $isActive}}text-accent{{else}}text-foreground-muted{{end}}">Discover</span>
</div>
</div>
</a>
{{/* Watchlist */}}
{{$isActive := eq $currentPath "/watchlist"}}
<a href="/watchlist" class="group relative flex items-center px-7 py-3 transition-colors hover:bg-white/5" {{if $isCollapsed}}title="Watchlist"{{end}}>
{{if $isActive}}
<div class="bg-accent absolute top-1/2 left-0 h-8 w-0.5 -translate-y-1/2 rounded-r-sm shadow-[0_0_8px_rgba(163,230,53,0.6)]"></div>
{{end}}
<svg class="size-6 shrink-0 transition-colors duration-200 {{if $isActive}}text-accent{{else}}text-foreground-muted{{end}}" viewBox="0 0 24 24" fill="currentColor">
<path d="M17 18.113l-3.256-2.326A2.989 2.989 0 0 0 12 15.228c-.629 0-1.232.194-1.744.559L7 18.113V4h10v14.113zM18 2H6a1 1 0 0 0-1 1v17.056c0 .209.065.412.187.581a.994.994 0 0 0 1.394.233l4.838-3.455a1 1 0 0 1 1.162 0l4.838 3.455A1 1 0 0 0 19 20.056V3a1 1 0 0 0-1-1z" />
</svg>
<div class="grid transition-all duration-300 ease-in-out {{if $isCollapsed}}grid-cols-[0fr] opacity-0 ml-0{{else}}grid-cols-[1fr] opacity-100 ml-4{{end}}">
<div class="overflow-hidden min-w-0">
<span class="whitespace-nowrap text-sm font-medium transition-colors duration-200 {{if $isActive}}text-accent{{else}}text-foreground-muted{{end}}">Watchlist</span>
</div>
</div>
</a>
</div>
</nav>
{{end}}

View File

@@ -0,0 +1,107 @@
{{define "video_player"}}
<div data-video-player
data-anime-id="{{.WatchData.MalID}}"
data-current-episode="{{.WatchData.CurrentEpisode}}"
data-total-episodes="{{.TotalEpisodes}}"
data-initial-mode="{{.WatchData.InitialMode}}"
data-anime-title="{{.WatchData.Title}}"
data-start-time-seconds="{{.WatchData.StartTimeSeconds}}"
data-mode-sources='{{json .WatchData.ModeSources}}'
data-available-modes='{{json .WatchData.AvailableModes}}'
data-segments='{{json .WatchData.Segments}}'
class="group relative aspect-video w-full overflow-hidden bg-black">
<video class="h-full w-full cursor-pointer" preload="metadata" playsinline autoplay></video>
<div data-loading class="absolute inset-0 flex items-center justify-center bg-black/60 hidden z-50">
<div class="border-accent size-10 animate-spin rounded-full border-4 border-t-transparent"></div>
</div>
<div data-video-overlay class="absolute inset-x-0 bottom-0 bg-gradient-to-t from-black/90 via-black/40 to-transparent p-4 transition-opacity duration-300 opacity-0 group-hover:opacity-100 z-40">
<div class="flex flex-col gap-3">
<div class="flex items-center justify-between px-2">
<div class="flex items-center gap-4">
<button data-play-pause class="flex items-center justify-center text-white transition-opacity hover:opacity-80 focus:outline-none">
<svg data-icon-play class="size-6 transition-colors duration-200" viewBox="0 0 24 24" fill="currentColor"><path d="M8 5v14l11-7z" /></svg>
<svg data-icon-pause class="size-6 transition-colors duration-200 hidden" viewBox="0 0 24 24" fill="none" stroke="currentColor" stroke-width="1.5" stroke-linecap="round"><line x1="9" y1="6" x2="9" y2="18" /><line x1="15" y1="6" x2="15" y2="18" /></svg>
</button>
<div data-volume-wrap class="group/volume relative flex items-center justify-center">
<div data-volume-panel class="pointer-events-none absolute bottom-full left-1/2 flex h-26 w-12 -translate-x-1/2 items-start justify-center opacity-0 transition-opacity group-focus-within/volume:pointer-events-auto group-focus-within/volume:opacity-100 group-hover/volume:pointer-events-auto group-hover/volume:opacity-100">
<div class="relative flex h-24 w-8 items-center justify-center">
<div class="relative z-10 flex h-20 w-1.5 flex-col justify-end rounded-full bg-white/50 shadow-[0_0_6px_rgba(0,0,0,0.6)]">
<input type="range" data-volume-range min="0" max="1" step="0.05" class="absolute -inset-x-4 inset-y-0 z-10 cursor-pointer opacity-0" style="writing-mode: vertical-lr; direction: rtl;">
<div data-volume-underline class="bg-accent pointer-events-none w-full rounded-full shadow-[0_0_4px_rgba(0,0,0,0.4)]" style="height: 100%"></div>
</div>
</div>
</div>
<button data-mute class="flex items-center justify-center text-white transition-opacity hover:opacity-80 focus:outline-none">
<svg data-icon-volume class="size-6 transition-colors duration-200" viewBox="0 0 24 24" aria-hidden="true"><polygon points="5 10 9 10 13 6 13 18 9 14 5 14" fill="none" stroke="currentColor" stroke-width="1.5" stroke-linecap="round" stroke-linejoin="round"></polygon><path d="M16 9c1.3 1.3 1.3 4.7 0 6" stroke="currentColor" stroke-width="1.5" stroke-linecap="round" stroke-linejoin="round" fill="none"></path><path d="M18.8 6.5c3 2.9 3 8.1 0 11" stroke="currentColor" stroke-width="1.5" stroke-linecap="round" stroke-linejoin="round" fill="none"></path></svg>
<svg data-icon-muted class="size-6 transition-colors duration-200 hidden" viewBox="0 0 24 24" aria-hidden="true"><polygon points="5 10 9 10 13 6 13 18 9 14 5 14" fill="none" stroke="currentColor" stroke-width="1.5" stroke-linecap="round" stroke-linejoin="round"></polygon><line x1="16" y1="9" x2="21" y2="15" stroke="currentColor" stroke-width="1.5" stroke-linecap="round"></line><line x1="21" y1="9" x2="16" y2="15" stroke="currentColor" stroke-width="1.5" stroke-linecap="round"></line></svg>
</button>
</div>
</div>
<div class="flex items-center gap-4">
<ui-dropdown class="relative block" data-align="right" data-position="top">
<div data-trigger class="cursor-pointer">
<button class="flex items-center justify-center text-white transition-opacity hover:opacity-80 focus:outline-none" aria-label="Settings">
<svg class="size-6 transition-transform duration-300" viewBox="0 0 24 24" fill="none" stroke="currentColor" stroke-width="1.5" stroke-linecap="round" stroke-linejoin="round"><circle cx="12" cy="12" r="3"></circle><path d="M19.4 15a1.65 1.65 0 0 0 .33 1.82l.06.06a2 2 0 0 1 0 2.83 2 2 0 0 1-2.83 0l-.06-.06a1.65 1.65 0 0 0-1.82-.33 1.65 1.65 0 0 0-1 1.51V21a2 2 0 0 1-2 2 2 2 0 0 1-2-2v-.09A1.65 1.65 0 0 0 9 19.4a1.65 1.65 0 0 0-1.82.33l-.06.06a2 2 0 0 1-2.83 0 2 2 0 0 1 0-2.83l.06-.06a1.65 1.65 0 0 0 .33-1.82 1.65 1.65 0 0 0-1.51-1H3a2 2 0 0 1-2-2 2 2 0 0 1 2-2h.09A1.65 1.65 0 0 0 4.6 9a1.65 1.65 0 0 0-.33-1.82l-.06-.06a2 2 0 0 1 0-2.83 2 2 0 0 1 2.83 0l.06.06a1.65 1.65 0 0 0 1.82.33H9a1.65 1.65 0 0 0 1-1.51V3a2 2 0 0 1 2-2 2 2 0 0 1 2 2v.09a1.65 1.65 0 0 0 1 1.51 1.65 1.65 0 0 0 1.82-.33l.06-.06a2 2 0 0 1 2.83 0 2 2 0 0 1 0 2.83l-.06.06a1.65 1.65 0 0 0-.33 1.82V9a1.65 1.65 0 0 0 1.51 1H21a2 2 0 0 1 2 2 2 2 0 0 1-2 2h-.09a1.65 1.65 0 0 0-1.51 1z"></path></svg>
</button>
</div>
<div data-content class="hidden absolute z-50 w-64 bg-background-button rounded-none shadow-2xl right-0 bottom-full mb-2">
<div class="flex flex-col py-1">
<div class="flex items-center justify-between px-5 py-2.5">
<span class="text-[15px] font-medium text-white">Autoplay</span>
<label class="relative inline-flex cursor-pointer items-center">
<input type="checkbox" data-autoplay class="peer sr-only" checked />
<div class="peer-checked:bg-accent peer h-5 w-9 rounded-full bg-white/20 transition-colors after:absolute after:top-[2px] after:left-[2px] after:h-4 after:w-4 after:rounded-full after:bg-white after:transition-all peer-checked:after:translate-x-full"></div>
</label>
</div>
<div class="my-1 h-px w-full bg-white/10"></div>
<div class="py-1">
<span class="mb-1 block px-5 text-xs font-medium tracking-wider text-neutral-400 uppercase">Audio / Subtitles</span>
<div class="flex flex-col">
<button data-mode-dub class="flex items-center justify-between px-5 py-2.5 text-left transition-colors hover:bg-white/10 text-white focus:outline-none">
<span class="text-sm font-medium">English (Dub)</span>
</button>
<button data-mode-sub class="flex items-center justify-between px-5 py-2.5 text-left transition-colors hover:bg-white/10 text-white focus:outline-none">
<span class="text-sm font-medium">Japanese (Sub)</span>
</button>
<select data-subtitle-select class="mt-2 mx-4 bg-black/40 text-white text-xs border border-white/10 px-2 py-1 outline-none hidden"></select>
</div>
</div>
</div>
</div>
</ui-dropdown>
<button data-fullscreen class="flex items-center justify-center text-white transition-opacity hover:opacity-80 focus:outline-none">
<svg class="size-6 transition-colors duration-200" viewBox="0 0 240 240" aria-hidden="true"><path d="M143.7,53.9c-1.9-1.9-1.3-4,1.4-4.4l50.6-8.4c1.8-0.5,3.7,0.6,4.2,2.4c0.2,0.6,0.2,1.2,0,1.7l-8.4,50.6c-0.4,2.7-2.4,3.4-4.4,1.4l-14.5-14.5l-28.2,28.2l-14.3-14.3l28.2-28.2L143.7,53.9z M44.2,200.9l50.6-8.4c2.7-0.4,3.4-2.4,1.4-4.4l-14.5-14.5l28.2-28.2l-14.3-14.3l-28.2,28.2l-14.5-14.5c-1.9-1.9-4-1.3-4.4,1.4l-8.4,50.6c-0.5,1.8,0.6,3.6,2.4,4.2C43,201,43.6,201,44.2,200.9L44.2,200.9z" fill="currentColor"></path></svg>
</button>
</div>
</div>
<div class="flex items-center gap-4 text-xs font-medium text-white px-2">
<span data-time class="w-12 text-center">0:00</span>
<div data-progress-wrap class="group/progress relative flex h-6 flex-1 cursor-pointer items-center">
<div data-preview-popover class="pointer-events-none absolute bottom-full mb-2 -translate-x-1/2 bg-white px-2 py-1 text-xs font-bold text-black shadow-md opacity-0 transition-opacity duration-200">
<span data-preview-time>0:00</span>
</div>
<div data-segments class="absolute inset-0 z-10 pointer-events-none"></div>
<div class="h-1.5 w-full rounded-full bg-white/30 transition-all group-hover/progress:h-2">
<div data-progress class="bg-accent h-full rounded-full" style="width: 0%"></div>
</div>
<div data-scrubber class="bg-accent pointer-events-none absolute top-1/2 h-3.5 w-3.5 -translate-x-1/2 -translate-y-1/2 rounded-full opacity-0 shadow-sm transition-[opacity,transform] group-hover/progress:scale-110 group-hover/progress:opacity-100" style="left: 0%"></div>
</div>
<span class="w-12 text-center" data-duration>0:00</span>
</div>
<button data-skip class="hidden absolute right-6 bottom-16 bg-white text-black font-bold px-4 py-2 text-sm rounded shadow-lg transition-transform hover:scale-105 active:scale-95">Skip</button>
<button data-backward class="hidden absolute left-1/4 top-1/2 -translate-y-1/2 -translate-x-1/2 p-4 bg-black/40 rounded-full text-white opacity-0 transition-opacity"><svg class="size-8" fill="none" viewBox="0 0 24 24" stroke="currentColor"><path stroke-linecap="round" stroke-linejoin="round" stroke-width="2" d="M12.066 11.2a1 1 0 000 1.6l5.334 4A1 1 0 0019 16V8a1 1 0 00-1.6-.8l-5.333 4zM4.066 11.2a1 1 0 000 1.6l5.334 4A1 1 0 0011 16V8a1 1 0 00-1.6-.8l-5.334 4z" /></svg></button>
<button data-forward class="hidden absolute right-1/4 top-1/2 -translate-y-1/2 translate-x-1/2 p-4 bg-black/40 rounded-full text-white opacity-0 transition-opacity"><svg class="size-8" fill="none" viewBox="0 0 24 24" stroke="currentColor"><path stroke-linecap="round" stroke-linejoin="round" stroke-width="2" d="M11.933 12.8a1 1 0 000-1.6l-5.333-4A1 1 0 005 8v8a1 1 0 001.6.8l5.333-4zM19.933 12.8a1 1 0 000-1.6l-5.333-4A1 1 0 0013 8v8a1 1 0 001.6.8l5.333-4z" /></svg></button>
<div data-subtitle-text class="absolute bottom-20 left-0 right-0 text-center pointer-events-none drop-shadow-md z-30" style="text-shadow: 0px 0px 4px black, 0px 0px 8px black; font-size: clamp(1rem, 2.5vw, 2rem); font-weight: 600; color: white;"></div>
</div>
</div>
</div>
{{end}}

View File

@@ -0,0 +1,20 @@
{{define "watch_order"}}
<div class="space-y-4">
<h2 class="text-lg font-medium text-neutral-300">Watch Order</h2>
<div class="grid grid-cols-2 gap-4 md:grid-cols-3 lg:grid-cols-4 2xl:grid-cols-6">
{{range .Relations}}
<div class="group relative">
{{template "anime_card" dict "Anime" .Anime "WithActions" true "Compact" true "HasTopBadge" true}}
{{if eq .Anime.MalID $.AnimeID}}
<div class="bg-accent absolute -top-2 -right-2 z-20 px-2 py-0.5 text-[10px] font-bold text-white shadow-md">
CURRENT
</div>
{{end}}
<div class="absolute top-2 left-2 z-20 border border-white/10 bg-black/80 px-2 py-0.5 text-[10px] font-medium text-white backdrop-blur-md">
{{.Relation}}
</div>
</div>
{{end}}
</div>
</div>
{{end}}

View File

@@ -0,0 +1,77 @@
{{define "watchlist_actions"}}
{{$anime := .Anime}}
{{$user := .User}}
{{$status := .Status}}
<div class="mb-4 flex gap-2">
<ui-dropdown class="relative block" data-align="left" data-width="min-w-[160px]">
<div data-trigger class="cursor-pointer">
<button class="bg-background-button hover:bg-background-button-hover flex items-center justify-between gap-3 px-4 py-2.5 text-sm font-medium text-white transition-colors disabled:opacity-50">
<span id="watchlist-status-display-{{$anime.MalID}}">
{{if $status}}
{{if eq $status "watching"}}Watching{{end}}
{{if eq $status "completed"}}Completed{{end}}
{{if eq $status "plan to watch"}}Plan to Watch{{end}}
{{if eq $status "dropped"}}Dropped{{end}}
{{else}}
Add to Watchlist
{{end}}
</span>
<svg xmlns="http://www.w3.org/2000/svg" width="14" height="14" viewBox="0 0 24 24" fill="none" stroke="currentColor" stroke-width="2" stroke-linecap="round" stroke-linejoin="round" class="text-neutral-400"><path d="m6 9 6 6 6-6"/></svg>
</button>
</div>
<div data-content class="hidden absolute z-50 min-w-[160px] bg-background-button rounded-none shadow-2xl left-0 top-full mt-2">
<div class="flex flex-col py-1">
<button class="flex w-full items-center px-5 py-2.5 transition-colors focus:outline-none hover:bg-white/10 focus:bg-white/10" onclick="updateWatchlist({{$anime.MalID}}, 'watching', 'Watching')">
<span class="font-medium text-sm text-white">Watching</span>
</button>
<button class="flex w-full items-center px-5 py-2.5 transition-colors focus:outline-none hover:bg-white/10 focus:bg-white/10" onclick="updateWatchlist({{$anime.MalID}}, 'completed', 'Completed')">
<span class="font-medium text-sm text-white">Completed</span>
</button>
<button class="flex w-full items-center px-5 py-2.5 transition-colors focus:outline-none hover:bg-white/10 focus:bg-white/10" onclick="updateWatchlist({{$anime.MalID}}, 'plan to watch', 'Plan to Watch')">
<span class="font-medium text-sm text-white">Plan to Watch</span>
</button>
<button class="flex w-full items-center px-5 py-2.5 transition-colors focus:outline-none hover:bg-white/10 focus:bg-white/10" onclick="updateWatchlist({{$anime.MalID}}, 'dropped', 'Dropped')">
<span class="font-medium text-sm text-white">Dropped</span>
</button>
<div id="remove-watchlist-container-{{$anime.MalID}}" class="{{if not $status}}hidden{{end}}">
<div class="my-1 h-px bg-white/10"></div>
<button class="flex w-full items-center px-5 py-2.5 transition-colors focus:outline-none hover:bg-red-500/10 focus:bg-red-500/10" onclick="removeWatchlist({{$anime.MalID}})">
<span class="font-medium text-sm text-red-500 text-left whitespace-nowrap">Remove from Watchlist</span>
</button>
</div>
</div>
</div>
</ui-dropdown>
<a href="/anime/{{$anime.MalID}}/watch" class="bg-background-button hover:bg-background-button-hover px-5 py-2.5 text-sm font-medium text-white transition-colors">
Watch
</a>
</div>
<script>
function updateWatchlist(id, status, display) {
fetch('/api/watchlist', {
method: 'POST',
headers: { 'Content-Type': 'application/json' },
body: JSON.stringify({ animeId: id, status: status })
}).then(res => {
if (res.ok) {
document.getElementById('watchlist-status-display-' + id).textContent = display;
document.getElementById('remove-watchlist-container-' + id).classList.remove('hidden');
}
});
}
function removeWatchlist(id) {
fetch('/api/watchlist/' + id, { method: 'DELETE' }).then(res => {
if (res.ok) {
document.getElementById('watchlist-status-display-' + id).textContent = 'Add to Watchlist';
document.getElementById('remove-watchlist-container-' + id).classList.add('hidden');
}
});
}
</script>
{{end}}

View File

@@ -1,9 +1,40 @@
{{define "title"}}Hello World{{end}}
{{define "title"}}Home{{end}}
{{define "content"}}
<h2 class="text-3xl font-bold mb-8 text-center">Hello World</h2>
<div class="grid grid-cols-2 md:grid-cols-4 gap-4">
{{range .Animes}}
{{template "anime_card" .}}
{{end}}
</div>
{{end}}
<div class="flex flex-col gap-10">
{{if .ContinueWatching}}
{{template "continue_watching" .ContinueWatching}}
{{end}}
<section class="w-full">
<div class="mb-4 flex items-end justify-between">
<h2 class="text-lg font-normal text-neutral-300">Currently Airing</h2>
<a href="/browse?status=airing&order_by=popularity&sort=desc" class="group flex items-center gap-1 text-sm text-neutral-400 transition-colors hover:text-white">
See more
<svg xmlns="http://www.w3.org/2000/svg" width="16" height="16" viewBox="0 0 24 24" fill="none" stroke="currentColor" stroke-width="2" stroke-linecap="round" stroke-linejoin="round" class="transition-transform group-hover:translate-x-0.5"><path d="m9 18 6-6-6-6"/></svg>
</a>
</div>
<div class="grid grid-cols-2 gap-4 md:grid-cols-3 lg:grid-cols-4 2xl:grid-cols-6">
{{range .CurrentlyAiring}}
{{template "anime_card" dict "Anime" . "WithActions" true}}
{{end}}
</div>
</section>
<section class="w-full">
<div class="mb-4 flex items-end justify-between">
<h2 class="text-lg font-normal text-neutral-300">Most Popular</h2>
<a href="/browse?order_by=popularity&sort=desc" class="group flex items-center gap-1 text-sm text-neutral-400 transition-colors hover:text-white">
See more
<svg xmlns="http://www.w3.org/2000/svg" width="16" height="16" viewBox="0 0 24 24" fill="none" stroke="currentColor" stroke-width="2" stroke-linecap="round" stroke-linejoin="round" class="transition-transform group-hover:translate-x-0.5"><path d="m9 18 6-6-6-6"/></svg>
</a>
</div>
<div class="grid grid-cols-2 gap-4 md:grid-cols-3 lg:grid-cols-4 2xl:grid-cols-6">
{{range .MostPopular}}
{{template "anime_card" dict "Anime" . "WithActions" true}}
{{end}}
</div>
</section>
</div>
{{end}}

View File

@@ -1,22 +1,49 @@
{{define "title"}}Login{{end}}
{{define "header"}}{{end}}
{{define "sidebar"}}{{end}}
{{define "content"}}
<div class="max-w-md mx-auto mt-20">
<h2 class="text-3xl font-bold mb-8 text-center">Login</h2>
{{if .Error}}
<div class="bg-red-500 text-white p-3 rounded mb-4">{{.Error}}</div>
{{end}}
<form method="POST" action="/login" class="space-y-4">
<div>
<label for="username" class="block text-sm font-medium mb-2">Username</label>
<input type="text" id="username" name="username" value="{{.Username}}" class="w-full p-2 rounded bg-gray-800 border border-gray-700 focus:border-blue-500 outline-none">
</div>
<div>
<label for="password" class="block text-sm font-medium mb-2">Password</label>
<input type="password" id="password" name="password" class="w-full p-2 rounded bg-gray-800 border border-gray-700 focus:border-blue-500 outline-none">
</div>
<button type="submit" class="w-full bg-blue-600 hover:bg-blue-700 text-white font-bold py-2 px-4 rounded">
Login
<div class="flex min-h-screen items-center justify-center p-4">
<div class="w-full max-w-md">
<h1 class="mb-2 text-3xl font-medium text-white">Sign in</h1>
<p class="text-neutral-400 mb-8">Enter your credentials to continue.</p>
<form method="POST" action="/login" class="flex flex-col gap-6">
<label class="flex flex-col gap-2">
<span class="text-white text-sm">Username</span>
<input
type="text"
name="username"
value="{{.Username}}"
required
placeholder="username"
class="bg-background-surface text-white placeholder-neutral-500 border-none px-4 py-3 focus:outline-none focus-within:ring-2 focus-within:ring-accent rounded-sm"
/>
</label>
<label class="flex flex-col gap-2">
<span class="text-white text-sm">Password</span>
<div class="relative group focus-within:ring-2 focus-within:ring-accent rounded-sm">
<input
type="password"
name="password"
required
placeholder="Password"
class="bg-background-surface text-white placeholder-neutral-500 w-full border-none px-4 py-3 focus:outline-none rounded-sm"
/>
</div>
</label>
<button
type="submit"
class="hover:bg-gray-200 mt-2 bg-white px-4 py-3 font-medium text-black transition-colors rounded-sm"
>
Sign in
</button>
</form>
{{if .Error}}
<p class="mt-6 text-sm text-red-500">{{.Error}}</p>
{{end}}
</div>
{{end}}
</div>
{{end}}

View File

@@ -1,6 +1,7 @@
package templates
import (
"encoding/json"
"fmt"
"html/template"
"io"
@@ -36,6 +37,10 @@ func GetRenderer() *Renderer {
}
return m
},
"json": func(v any) template.HTMLAttr {
b, _ := json.Marshal(v)
return template.HTMLAttr(b)
},
}
pages, err := filepath.Glob(filepath.Join(".", "templates", "*.gohtml"))
@@ -55,11 +60,16 @@ func GetRenderer() *Renderer {
}
tmpl := template.New(name).Funcs(funcs)
// Parse base first so it establishes the core definitions
tmpl = template.Must(tmpl.ParseFiles(filepath.Join(".", "templates", "base.gohtml")))
tmpl = template.Must(tmpl.ParseFiles(page))
// Parse all components next so they are available to the page
if len(components) > 0 {
tmpl = template.Must(tmpl.ParseFiles(components...))
}
// Parse the page itself last
tmpl = template.Must(tmpl.ParseFiles(page))
renderer.templates[name] = tmpl
log.Printf("Loaded page template: %s", name)
@@ -75,3 +85,11 @@ func (r *Renderer) ExecuteTemplate(wr io.Writer, name string, data any) error {
}
return tmpl.ExecuteTemplate(wr, "base.gohtml", data)
}
func (r *Renderer) ExecuteFragment(wr io.Writer, name string, block string, data any) error {
tmpl, ok := r.templates[name]
if !ok {
return fmt.Errorf("template %s not found", name)
}
return tmpl.ExecuteTemplate(wr, block, data)
}

46
templates/watch.gohtml Normal file
View File

@@ -0,0 +1,46 @@
{{define "title"}}Watch {{.Anime.Title}} - MyAnimeList{{end}}
{{define "content"}}
{{$anime := .Anime}}
{{$episodes := .Episodes}}
{{$currentEpID := .CurrentEpID}}
<div class="flex flex-col gap-8 pb-12">
<div id="video-player-container">
{{template "video_player" dict "WatchData" .WatchData "TotalEpisodes" $anime.Episodes}}
</div>
{{if eq (len $episodes) 0}}
<div class="flex flex-col items-center justify-center gap-2 py-24 text-neutral-400">
<svg class="h-12 w-12 opacity-30" viewBox="0 0 24 24" fill="none" stroke="currentColor" stroke-width="1.5"><path stroke-linecap="round" stroke-linejoin="round" d="M15 10l4.553-2.276A1 1 0 0121 8.618v6.764a1 1 0 01-1.447.894L15 14M5 18h8a2 2 0 002-2V8a2 2 0 00-2-2H5a2 2 0 00-2 2v8a2 2 0 002 2z" /></svg>
<p class="text-lg">No episodes found for this anime.</p>
</div>
{{else}}
<div class="grid grid-cols-1 gap-4 sm:grid-cols-2 md:grid-cols-3 lg:grid-cols-4 xl:grid-cols-5">
{{range $episodes}}
{{$isCurrent := eq (printf "%v" .MalID) $currentEpID}}
<a href="/anime/{{$anime.MalID}}/watch?ep={{.MalID}}" class="group flex flex-col overflow-hidden bg-white/5 transition-colors hover:bg-white/10 {{if $isCurrent}}ring-accent ring-2{{end}}">
<div class="relative aspect-video w-full overflow-hidden bg-black/50">
{{if and .Images .Images.Jpg.ImageURL}}
<img src="{{.Images.Jpg.ImageURL}}" alt="{{.Title}}" class="h-full w-full object-cover transition-transform group-hover:scale-105" loading="lazy" />
{{else}}
<div class="flex h-full w-full items-center justify-center text-neutral-600">
<svg class="h-8 w-8" viewBox="0 0 24 24" fill="none" stroke="currentColor" stroke-width="2"><path stroke-linecap="round" stroke-linejoin="round" d="M4 16l4.586-4.586a2 2 0 012.828 0L16 16m-2-2l1.586-1.586a2 2 0 012.828 0L20 14m-6-6h.01M6 20h12a2 2 0 002-2V6a2 2 0 00-2-2H6a2 2 0 00-2 2v12a2 2 0 002 2z"/></svg>
</div>
{{end}}
<div class="absolute inset-0 flex items-center justify-center bg-black/40 opacity-0 transition-opacity group-hover:opacity-100">
<div class="bg-accent flex h-12 w-12 items-center justify-center rounded-full text-white shadow-lg">
<svg class="ml-1 h-6 w-6" viewBox="0 0 24 24" fill="currentColor"><path d="M8 5v14l11-7z" /></svg>
</div>
</div>
</div>
<div class="flex flex-col gap-1 p-3">
<!-- Note: Jikan episodes don't always have .episode or .title cleanly named in my Go struct, but I will map them dynamically -->
<span class="text-accent text-xs font-semibold">Episode {{.MalID}}</span>
<span class="line-clamp-2 text-sm font-medium text-neutral-200">{{.Title}}</span>
</div>
</a>
{{end}}
</div>
{{end}}
</div>
{{end}}