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.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)}}
+
+ Read more
+
+ {{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
-
+
+
+
+
+
+
+
+
+
+
+
+
-
-
-
- {{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}}
-
{{.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}}
+
+
+{{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
+
+
+
+{{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 .Status}}{{.Status}}{{else}}Any Status{{end}}
+
+
+
+
+
+
+
+
+
+ {{if .Type}}{{.Type}}{{else}}Any Type{{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"}}
+
+{{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}}
+
+
+
+
+
+
+ {{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}}
+
+
+
+
+
+
+
+
+
+ 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}}
+
+
+
+
+
+ {{range .CurrentlyAiring}}
+ {{template "anime_card" dict "Anime" . "WithActions" true}}
+ {{end}}
+
+
+
+
+
+
+
+ {{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}}
-