From 4f3a61e143b9eee7e819da178273ee663da3ffa8 Mon Sep 17 00:00:00 2001 From: mkelvers Date: Fri, 1 May 2026 17:28:09 +0200 Subject: [PATCH] refactor(ui): complete ui template migration and fix playback --- api/anime/handler.go | 125 ++++++++++++- api/auth/handler.go | 24 +-- api/playback/handler.go | 174 +++++++++++++++++- api/watchlist/handler.go | 4 +- internal/server/routes.go | 10 +- static/dropdown.ts | 55 ++++++ static/style.css | 54 +++++- templates/anime.gohtml | 65 +++++++ templates/base.gohtml | 54 +++++- templates/browse.gohtml | 25 +++ templates/components/anime_card.gohtml | 92 ++++++++- templates/components/continue_watching.gohtml | 42 +++++ templates/components/dropdown.gohtml | 13 ++ templates/components/filter_bar.gohtml | 87 +++++++++ templates/components/header.gohtml | 77 ++++++++ templates/components/navigation.gohtml | 87 +++++++++ templates/components/video_player.gohtml | 107 +++++++++++ templates/components/watch_order.gohtml | 20 ++ templates/components/watchlist_actions.gohtml | 77 ++++++++ templates/index.gohtml | 47 ++++- templates/login.gohtml | 61 ++++-- templates/renderer.go | 20 +- templates/watch.gohtml | 46 +++++ 23 files changed, 1298 insertions(+), 68 deletions(-) create mode 100644 static/dropdown.ts create mode 100644 templates/anime.gohtml create mode 100644 templates/browse.gohtml create mode 100644 templates/components/continue_watching.gohtml create mode 100644 templates/components/dropdown.gohtml create mode 100644 templates/components/filter_bar.gohtml create mode 100644 templates/components/header.gohtml create mode 100644 templates/components/navigation.gohtml create mode 100644 templates/components/video_player.gohtml create mode 100644 templates/components/watch_order.gohtml create mode 100644 templates/components/watchlist_actions.gohtml create mode 100644 templates/watch.gohtml diff --git a/api/anime/handler.go b/api/anime/handler.go index 5876cf4..2568d0d 100644 --- a/api/anime/handler.go +++ b/api/anime/handler.go @@ -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, `
Invalid anime ID.
`, http.StatusBadRequest) + return + } + + relations, err := h.jikanClient.GetFullRelations(r.Context(), id) + if err != nil { + log.Printf("watch order error: %v", err) + http.Error(w, `
Failed to load watch order.
`, 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) diff --git a/api/auth/handler.go b/api/auth/handler.go index 1f8c4b9..a16cfa2 100644 --- a/api/auth/handler.go +++ b/api/auth/handler.go @@ -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 } diff --git a/api/playback/handler.go b/api/playback/handler.go index d6e9d02..cce59b1 100644 --- a/api/playback/handler.go +++ b/api/playback/handler.go @@ -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 + }) } diff --git a/api/watchlist/handler.go b/api/watchlist/handler.go index f304c6a..a1014de 100644 --- a/api/watchlist/handler.go +++ b/api/watchlist/handler.go @@ -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) } } diff --git a/internal/server/routes.go b/internal/server/routes.go index 8961cf2..2aafb6f 100644 --- a/internal/server/routes.go +++ b/internal/server/routes.go @@ -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) diff --git a/static/dropdown.ts b/static/dropdown.ts new file mode 100644 index 0000000..14a89d0 --- /dev/null +++ b/static/dropdown.ts @@ -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) diff --git a/static/style.css b/static/style.css index d6c1a79..9d30521 100644 --- a/static/style.css +++ b/static/style.css @@ -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); + } +} diff --git a/templates/anime.gohtml b/templates/anime.gohtml new file mode 100644 index 0000000..e0b02a4 --- /dev/null +++ b/templates/anime.gohtml @@ -0,0 +1,65 @@ +{{define "title"}}{{.Anime.DisplayTitle}}{{end}} +{{define "content"}} +{{$anime := .Anime}} + +
+
+
+
+ {{$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}} + {{$anime.Title}} +
+
+ +
+

+ {{$anime.DisplayTitle}} +

+ + {{if and $anime.TitleEnglish (ne $anime.Title $anime.TitleEnglish)}} +

{{$anime.Title}}

+ {{end}} + + {{template "watchlist_actions" dict "Anime" $anime "User" .User "Status" .Status}} + +
+
+

Synopsis

+

+ {{if $anime.Synopsis}}{{$anime.Synopsis}}{{else}}No synopsis available.{{end}} +

+ {{if and $anime.Synopsis (gt (len $anime.Synopsis) 200)}} + + {{end}} +
+
+
+
+ +
+
+
+
+ Loading watch order sequence... +
+
+
+
+{{end}} \ No newline at end of file diff --git a/templates/base.gohtml b/templates/base.gohtml index 23408e6..68db8f7 100644 --- a/templates/base.gohtml +++ b/templates/base.gohtml @@ -4,14 +4,52 @@ {{template "title" .}} - MAL - + + + + + + + + + + + + - -
-

MAL

-
-
- {{template "content" .}} -
+ +
+
+ {{block "header" .}} + {{template "header" .}} + {{end}} +
+ +
+ + + +
+ {{block "sidebar" .}} + {{template "navigation" dict "CurrentPath" .CurrentPath "IsCollapsed" false}} + {{end}} +
+ +
+ {{template "content" .}} +
+
+
diff --git a/templates/browse.gohtml b/templates/browse.gohtml new file mode 100644 index 0000000..dffa0b1 --- /dev/null +++ b/templates/browse.gohtml @@ -0,0 +1,25 @@ +{{define "title"}}Browse{{end}} +{{define "content"}} +
+
+

Browse

+
+ + {{template "filter_bar" .}} + +
+ {{if eq (len .Animes) 0}} +
+ +

No anime found matching your filters.

+
+ {{else}} +
+ {{range .Animes}} + {{template "anime_card" dict "Anime" . "WithActions" true}} + {{end}} +
+ {{end}} +
+
+{{end}} \ No newline at end of file diff --git a/templates/components/anime_card.gohtml b/templates/components/anime_card.gohtml index ea0437c..e85e7ea 100644 --- a/templates/components/anime_card.gohtml +++ b/templates/components/anime_card.gohtml @@ -1,9 +1,87 @@ {{define "anime_card"}} -
- {{.DisplayTitle}} -
-

{{.DisplayTitle}}

-

{{.Type}}

-
-
+{{$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}} + +
+ + {{$displayTitle}} + + {{if $withActions}} +
+ {{if $isWatchlist}} +
+ +
+ {{end}} + + {{if not $isWatchlist}} +

+ {{$displayTitle}} +

+ {{end}} + + {{if $anime.Synopsis}} +

+ {{$anime.Synopsis}} +

+ {{end}} + + {{if not $isWatchlist}} +
+ +
+ {{end}} +
+ {{else}} +
+

+ {{$displayTitle}} +

+ {{if not $compact}} +
+ {{if $anime.Score}} + + + {{$anime.Score}} + + {{end}} + {{if $anime.Year}} + + {{$anime.Year}} + {{end}} + {{if $anime.Episodes}} + + {{$anime.Episodes}} ep + {{end}} +
+ {{end}} +
+ {{end}} +
+ {{if and $withActions (not $hideTitle)}} +

+ {{$displayTitle}} +

+ {{end}} +
+{{end}} \ No newline at end of file diff --git a/templates/components/continue_watching.gohtml b/templates/components/continue_watching.gohtml new file mode 100644 index 0000000..e15f5a3 --- /dev/null +++ b/templates/components/continue_watching.gohtml @@ -0,0 +1,42 @@ +{{define "continue_watching"}} +
+

Continue Watching

+ +
+ {{range .}} + {{$title := .TitleOriginal}} + {{if .TitleEnglish.Valid}}{{$title = .TitleEnglish.String}}{{end}} + + +
+ {{$title}} + +
+
+ +
+
+ + {{if .CurrentTimeSeconds}} + +
+
+
+ {{end}} +
+ +
+

+ {{$title}} +

+

+ {{if .CurrentEpisode.Valid}}Episode {{.CurrentEpisode.Int64}}{{end}} +

+
+
+ {{end}} +
+
+{{end}} \ No newline at end of file diff --git a/templates/components/dropdown.gohtml b/templates/components/dropdown.gohtml new file mode 100644 index 0000000..fad594a --- /dev/null +++ b/templates/components/dropdown.gohtml @@ -0,0 +1,13 @@ +{{define "dropdown"}} + +
+ {{template "dropdown_trigger" .}} +
+ + +
+{{end}} \ No newline at end of file diff --git a/templates/components/filter_bar.gohtml b/templates/components/filter_bar.gohtml new file mode 100644 index 0000000..35b3ca0 --- /dev/null +++ b/templates/components/filter_bar.gohtml @@ -0,0 +1,87 @@ +{{define "filter_bar"}} +
+
+
+ + + {{if .Type}}{{end}} + {{if .Status}}{{end}} + {{if .OrderBy}}{{end}} + {{if .Sort}}{{end}} +
+
+ + +
+ +
+ +
+ + +
+ +
+ +
+ +
+ +
+ +
+ +
+ + + {{if eq .Sort "asc"}} + + {{else}} + + {{end}} + +
+
+{{end}} \ No newline at end of file diff --git a/templates/components/header.gohtml b/templates/components/header.gohtml new file mode 100644 index 0000000..0bc29e5 --- /dev/null +++ b/templates/components/header.gohtml @@ -0,0 +1,77 @@ +{{define "header"}} +
+
+ + + + +
+ +
+ +
+ + +
+
+
+ +
+
+
+
+ +
+ +
+
+
+
+{{end}} \ No newline at end of file diff --git a/templates/components/navigation.gohtml b/templates/components/navigation.gohtml new file mode 100644 index 0000000..c6683e7 --- /dev/null +++ b/templates/components/navigation.gohtml @@ -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") +}} + + +{{end}} \ No newline at end of file diff --git a/templates/components/video_player.gohtml b/templates/components/video_player.gohtml new file mode 100644 index 0000000..6c2fb08 --- /dev/null +++ b/templates/components/video_player.gohtml @@ -0,0 +1,107 @@ +{{define "video_player"}} +
+ + + + + +
+
+
+
+ + +
+
+
+
+ +
+
+
+
+ +
+
+ +
+ +
+ +
+ +
+ + +
+
+ +
+ 0:00 +
+
+ 0:00 +
+
+
+
+
+
+
+ 0:00 +
+ + + + + +
+
+
+
+{{end}} \ No newline at end of file diff --git a/templates/components/watch_order.gohtml b/templates/components/watch_order.gohtml new file mode 100644 index 0000000..7cb8a2a --- /dev/null +++ b/templates/components/watch_order.gohtml @@ -0,0 +1,20 @@ +{{define "watch_order"}} +
+

Watch Order

+
+ {{range .Relations}} +
+ {{template "anime_card" dict "Anime" .Anime "WithActions" true "Compact" true "HasTopBadge" true}} + {{if eq .Anime.MalID $.AnimeID}} +
+ CURRENT +
+ {{end}} +
+ {{.Relation}} +
+
+ {{end}} +
+
+{{end}} \ No newline at end of file diff --git a/templates/components/watchlist_actions.gohtml b/templates/components/watchlist_actions.gohtml new file mode 100644 index 0000000..772e72c --- /dev/null +++ b/templates/components/watchlist_actions.gohtml @@ -0,0 +1,77 @@ +{{define "watchlist_actions"}} +{{$anime := .Anime}} +{{$user := .User}} +{{$status := .Status}} + +
+ +
+ +
+ + +
+ + + Watch + +
+ + +{{end}} \ No newline at end of file diff --git a/templates/index.gohtml b/templates/index.gohtml index ecd9edd..727c98d 100644 --- a/templates/index.gohtml +++ b/templates/index.gohtml @@ -1,9 +1,40 @@ -{{define "title"}}Hello World{{end}} +{{define "title"}}Home{{end}} {{define "content"}} -

Hello World

-
- {{range .Animes}} - {{template "anime_card" .}} - {{end}} -
-{{end}} +
+ {{if .ContinueWatching}} + {{template "continue_watching" .ContinueWatching}} + {{end}} + +
+
+

Currently Airing

+ + See more + + +
+ +
+ {{range .CurrentlyAiring}} + {{template "anime_card" dict "Anime" . "WithActions" true}} + {{end}} +
+
+ +
+
+

Most Popular

+ + See more + + +
+ +
+ {{range .MostPopular}} + {{template "anime_card" dict "Anime" . "WithActions" true}} + {{end}} +
+
+
+{{end}} \ No newline at end of file diff --git a/templates/login.gohtml b/templates/login.gohtml index 1818c45..a54e773 100644 --- a/templates/login.gohtml +++ b/templates/login.gohtml @@ -1,22 +1,49 @@ {{define "title"}}Login{{end}} +{{define "header"}}{{end}} +{{define "sidebar"}}{{end}} {{define "content"}} -
-

Login

- {{if .Error}} -
{{.Error}}
- {{end}} -
-
- - -
-
- - -
-
+ + {{if .Error}} +

{{.Error}}

+ {{end}}
-{{end}} + +{{end}} \ No newline at end of file diff --git a/templates/renderer.go b/templates/renderer.go index c05840a..fe97746 100644 --- a/templates/renderer.go +++ b/templates/renderer.go @@ -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) +} diff --git a/templates/watch.gohtml b/templates/watch.gohtml new file mode 100644 index 0000000..f850e0d --- /dev/null +++ b/templates/watch.gohtml @@ -0,0 +1,46 @@ +{{define "title"}}Watch {{.Anime.Title}} - MyAnimeList{{end}} +{{define "content"}} +{{$anime := .Anime}} +{{$episodes := .Episodes}} +{{$currentEpID := .CurrentEpID}} + +
+
+ {{template "video_player" dict "WatchData" .WatchData "TotalEpisodes" $anime.Episodes}} +
+ + {{if eq (len $episodes) 0}} +
+ +

No episodes found for this anime.

+
+ {{else}} +
+ {{range $episodes}} + {{$isCurrent := eq (printf "%v" .MalID) $currentEpID}} + +
+ {{if and .Images .Images.Jpg.ImageURL}} + {{.Title}} + {{else}} +
+ +
+ {{end}} +
+
+ +
+
+
+
+ + Episode {{.MalID}} + {{.Title}} +
+
+ {{end}} +
+ {{end}} +
+{{end}} \ No newline at end of file