diff --git a/go.mod b/go.mod index d829a25..b182bee 100644 --- a/go.mod +++ b/go.mod @@ -5,7 +5,6 @@ go 1.24.0 require ( github.com/a-h/templ v0.3.1001 github.com/google/uuid v1.6.0 - github.com/hashicorp/golang-lru/v2 v2.0.7 github.com/mattn/go-sqlite3 v1.14.40 golang.org/x/crypto v0.31.0 ) diff --git a/internal/database/db.go b/internal/database/db.go index 85d4b8c..37a578c 100644 --- a/internal/database/db.go +++ b/internal/database/db.go @@ -10,10 +10,10 @@ import ( ) type DBTX interface { - ExecContext(context.Context, string, ...interface{}) (sql.Result, error) + ExecContext(context.Context, string, ...any) (sql.Result, error) PrepareContext(context.Context, string) (*sql.Stmt, error) - QueryContext(context.Context, string, ...interface{}) (*sql.Rows, error) - QueryRowContext(context.Context, string, ...interface{}) *sql.Row + QueryContext(context.Context, string, ...any) (*sql.Rows, error) + QueryRowContext(context.Context, string, ...any) *sql.Row } func New(db DBTX) *Queries { diff --git a/internal/database/helpers.go b/internal/database/helpers.go index d3f9c36..e80ed12 100644 --- a/internal/database/helpers.go +++ b/internal/database/helpers.go @@ -2,7 +2,6 @@ package database import "database/sql" -// DisplayTitle returns the English title if available, otherwise Japanese, otherwise original func DisplayTitle(titleEnglish, titleJapanese sql.NullString, titleOriginal string) string { if titleEnglish.Valid && titleEnglish.String != "" { return titleEnglish.String @@ -13,7 +12,6 @@ func DisplayTitle(titleEnglish, titleJapanese sql.NullString, titleOriginal stri return titleOriginal } -// Deprecated: use DisplayTitle function directly func (r GetUserWatchListRow) DisplayTitle() string { return DisplayTitle(r.TitleEnglish, r.TitleJapanese, r.TitleOriginal) } diff --git a/internal/features/anime/handler.go b/internal/features/anime/handler.go index 421f246..99b4501 100644 --- a/internal/features/anime/handler.go +++ b/internal/features/anime/handler.go @@ -215,7 +215,7 @@ func (h *Handler) HandleQuickSearch(w http.ResponseWriter, r *http.Request) { if query == "" { w.Header().Set("Content-Type", "application/json") w.WriteHeader(http.StatusOK) - json.NewEncoder(w).Encode([]interface{}{}) + json.NewEncoder(w).Encode([]any{}) return } diff --git a/internal/features/watchlist/handler.go b/internal/features/watchlist/handler.go index 2cefb9d..2804721 100644 --- a/internal/features/watchlist/handler.go +++ b/internal/features/watchlist/handler.go @@ -269,7 +269,7 @@ func (h *Handler) sortEntries(entries []database.GetUserWatchListRow, sortBy, so } // Simple bubble sort for small lists - for i := 0; i < len(entries); i++ { + for i := range len(entries) { for j := i + 1; j < len(entries); j++ { if less(j, i) { entries[i], entries[j] = entries[j], entries[i] diff --git a/internal/jikan/anime.go b/internal/jikan/anime.go index 5497d11..4855600 100644 --- a/internal/jikan/anime.go +++ b/internal/jikan/anime.go @@ -5,7 +5,6 @@ import ( "time" ) -// GetAnimeByID fetches full details for a single anime func (c *Client) GetAnimeByID(id int) (Anime, error) { cacheKey := fmt.Sprintf("anime:%d", id) var cached Anime diff --git a/internal/jikan/client.go b/internal/jikan/client.go index 77fdf94..c4a1b00 100644 --- a/internal/jikan/client.go +++ b/internal/jikan/client.go @@ -43,7 +43,7 @@ func (c *Client) waitRateLimit() { } } -func (c *Client) getCache(key string, out interface{}) bool { +func (c *Client) getCache(key string, out any) bool { ctx, cancel := context.WithTimeout(context.Background(), 2*time.Second) defer cancel() @@ -56,7 +56,7 @@ func (c *Client) getCache(key string, out interface{}) bool { return err == nil } -func (c *Client) setCache(key string, data interface{}, ttl time.Duration) { +func (c *Client) setCache(key string, data any, ttl time.Duration) { ctx, cancel := context.WithTimeout(context.Background(), 2*time.Second) defer cancel() @@ -72,10 +72,9 @@ func (c *Client) setCache(key string, data interface{}, ttl time.Duration) { }) } -// fetchWithRetry provides robust fetching respecting Jikan's strict 3 req/sec rate limit -func (c *Client) fetchWithRetry(urlStr string, out interface{}) error { +func (c *Client) fetchWithRetry(urlStr string, out any) error { maxRetries := 5 - for i := 0; i < maxRetries; i++ { + for range maxRetries { c.waitRateLimit() resp, err := c.httpClient.Get(urlStr) diff --git a/internal/jikan/recommendations.go b/internal/jikan/recommendations.go index d6bacbc..bedfe6a 100644 --- a/internal/jikan/recommendations.go +++ b/internal/jikan/recommendations.go @@ -5,7 +5,6 @@ import ( "time" ) -// RecommendationEntry represents a single recommendation type RecommendationEntry struct { Entry struct { MalID int `json:"mal_id"` @@ -24,7 +23,6 @@ type RecommendationsResponse struct { Data []RecommendationEntry `json:"data"` } -// GetRecommendations fetches recommended anime func (c *Client) GetRecommendations(animeID int, limit int) ([]Anime, error) { cacheKey := fmt.Sprintf("recs:%d", animeID) var cached []Anime diff --git a/internal/jikan/relations.go b/internal/jikan/relations.go index e3c9406..b86f2a9 100644 --- a/internal/jikan/relations.go +++ b/internal/jikan/relations.go @@ -1,6 +1,7 @@ package jikan -// findFirstAnimeRelation extracts the first related anime ID for a specific relation type +import "maps" + func findFirstAnimeRelation(groups []JikanRelationGroup, relType string) *int { for _, group := range groups { if group.Relation == relType { @@ -15,7 +16,6 @@ func findFirstAnimeRelation(groups []JikanRelationGroup, relType string) *int { return nil } -// fetchChain recursively builds the relational chain (Prequels or Sequels) func (c *Client) fetchChain(startID int, direction string, visited map[int]bool) ([]RelationEntry, error) { anime, err := c.GetAnimeByID(startID) if err != nil { @@ -24,7 +24,7 @@ func (c *Client) fetchChain(startID int, direction string, visited map[int]bool) nextIDPtr := findFirstAnimeRelation(anime.Relations, direction) if nextIDPtr == nil { - return nil, nil // normal end of chain + return nil, nil } nextID := *nextIDPtr @@ -50,7 +50,6 @@ func (c *Client) fetchChain(startID int, direction string, visited map[int]bool) return append([]RelationEntry{entry}, rest...), nil } -// GetFullRelations resolves the full Prequel/Sequel chronological chain synchronously func (c *Client) GetFullRelations(id int) ([]RelationEntry, error) { currentAnime, err := c.GetAnimeByID(id) if err != nil { @@ -62,9 +61,7 @@ func (c *Client) GetFullRelations(id int) ([]RelationEntry, error) { prequels, err1 := c.fetchChain(id, "Prequel", visited) visitedSeq := make(map[int]bool) - for k, v := range visited { - visitedSeq[k] = v - } + maps.Copy(visitedSeq, visited) sequels, err2 := c.fetchChain(id, "Sequel", visitedSeq) diff --git a/internal/jikan/search.go b/internal/jikan/search.go index 3d814ad..ffa7718 100644 --- a/internal/jikan/search.go +++ b/internal/jikan/search.go @@ -6,7 +6,6 @@ import ( "time" ) -// Search returns the anime list with pagination support func (c *Client) Search(query string, page int) (SearchResult, error) { if query == "" { return SearchResult{}, nil @@ -36,7 +35,6 @@ func (c *Client) Search(query string, page int) (SearchResult, error) { return res, nil } -// GetTopAnime fetches the top anime by popularity (default) or other filters func (c *Client) GetTopAnime(page int) (TopAnimeResult, error) { if page < 1 { page = 1 diff --git a/internal/jikan/seasons.go b/internal/jikan/seasons.go index fae5296..d928a97 100644 --- a/internal/jikan/seasons.go +++ b/internal/jikan/seasons.go @@ -6,14 +6,11 @@ import ( "time" ) -// ScheduleResult contains anime grouped by day type ScheduleResult struct { Animes []Anime HasNextPage bool } -// GetSchedule fetches anime airing on a specific day -// day can be: monday, tuesday, wednesday, thursday, friday, saturday, sunday, unknown, other func (c *Client) GetSchedule(day string) (ScheduleResult, error) { day = strings.ToLower(day) cacheKey := fmt.Sprintf("schedule_%s", day) @@ -38,7 +35,6 @@ func (c *Client) GetSchedule(day string) (ScheduleResult, error) { return res, nil } -// GetFullSchedule fetches all days at once func (c *Client) GetFullSchedule() (map[string][]Anime, error) { days := []string{"monday", "tuesday", "wednesday", "thursday", "friday", "saturday", "sunday"} schedule := make(map[string][]Anime) @@ -54,7 +50,6 @@ func (c *Client) GetFullSchedule() (map[string][]Anime, error) { return schedule, nil } -// GetSeasonsNow fetches currently airing anime func (c *Client) GetSeasonsNow(page int) (TopAnimeResult, error) { if page < 1 { page = 1 @@ -80,7 +75,6 @@ func (c *Client) GetSeasonsNow(page int) (TopAnimeResult, error) { return res, nil } -// GetSeasonsUpcoming fetches upcoming anime func (c *Client) GetSeasonsUpcoming(page int) (TopAnimeResult, error) { if page < 1 { page = 1 diff --git a/internal/jikan/types.go b/internal/jikan/types.go index 6d6bbd3..0ed8134 100644 --- a/internal/jikan/types.go +++ b/internal/jikan/types.go @@ -12,20 +12,17 @@ type TopAnimeResult struct { HasNextPage bool } -// NamedEntity represents genres, studios, producers, etc. type NamedEntity struct { MalID int `json:"mal_id"` Name string `json:"name"` } -// Aired represents the airing date range type Aired struct { From string `json:"from"` To string `json:"to"` String string `json:"string"` } -// Anime struct matching the Jikan v4 API structure type Anime struct { MalID int `json:"mal_id"` Title string `json:"title"` @@ -73,12 +70,10 @@ type Anime struct { Relations []JikanRelationGroup `json:"relations"` } -// ImageURL returns the webp large image URL func (a Anime) ImageURL() string { return a.Images.Webp.LargeImageURL } -// ShortRating returns abbreviated rating (e.g., "PG-13" from "PG-13 - Teens 13 or older") func (a Anime) ShortRating() string { if a.Rating == "" { return "" @@ -92,7 +87,6 @@ func (a Anime) ShortRating() string { return a.Rating } -// ShortDuration returns abbreviated duration (e.g., "23m" from "23 min per ep") func (a Anime) ShortDuration() string { if a.Duration == "" { return "" @@ -112,7 +106,6 @@ func (a Anime) ShortDuration() string { return a.Duration } -// Premiered returns season + year (e.g., "Fall 2002") func (a Anime) Premiered() string { if a.Season != "" && a.Year > 0 { return fmt.Sprintf("%s %d", a.Season, a.Year) @@ -138,7 +131,6 @@ type TopAnimeResponse struct { Pagination Pagination `json:"pagination"` } -// Relation Types type JikanRelationEntry struct { MalID int `json:"mal_id"` Type string `json:"type"` @@ -160,7 +152,6 @@ type RelationEntry struct { IsCurrent bool } -// DisplayTitle prefers English, falls back to Japanese, then standard Title func (a Anime) DisplayTitle() string { if a.TitleEnglish != "" { return a.TitleEnglish diff --git a/internal/server/routes.go b/internal/server/routes.go index 768ae2a..e729ed2 100644 --- a/internal/server/routes.go +++ b/internal/server/routes.go @@ -32,7 +32,6 @@ func NewRouter(cfg Config) http.Handler { fs := http.FileServer(http.Dir("./static")) mux.Handle("/static/", http.StripPrefix("/static/", fs)) - // Anime / Search / Catalog mux.HandleFunc("/", animeHandler.HandleCatalog) mux.HandleFunc("/discover", animeHandler.HandleDiscover) mux.HandleFunc("/schedule", animeHandler.HandleSchedule) @@ -67,12 +66,12 @@ func NewRouter(cfg Config) http.Handler { middleware.VerifyOrigin(http.HandlerFunc(authHandler.HandleLogout)).ServeHTTP(w, r) }) - // Watchlist POST endpoint (Protected) - mux.Handle("/api/watchlist/export", middleware.RequireAuth(http.HandlerFunc(watchlistHandler.HandleExportWatchlist))) - mux.Handle("/api/watchlist/import", middleware.RequireAuth(http.HandlerFunc(watchlistHandler.HandleImportWatchlist))) - mux.Handle("/api/watchlist", middleware.RequireAuth(http.HandlerFunc(watchlistHandler.HandleUpdateWatchlist))) - mux.Handle("/api/watchlist/", middleware.RequireAuth(http.HandlerFunc(watchlistHandler.HandleDeleteWatchlist))) - mux.Handle("/watchlist", middleware.RequireAuth(http.HandlerFunc(watchlistHandler.HandleGetWatchlist))) + // Watchlist Endpoints + mux.HandleFunc("/api/watchlist/export", watchlistHandler.HandleExportWatchlist) + mux.HandleFunc("/api/watchlist/import", watchlistHandler.HandleImportWatchlist) + mux.HandleFunc("/api/watchlist", watchlistHandler.HandleUpdateWatchlist) + mux.HandleFunc("/api/watchlist/", watchlistHandler.HandleDeleteWatchlist) + mux.HandleFunc("/watchlist", watchlistHandler.HandleGetWatchlist) // Wrap mux with global auth checking, THEN auth context parsing protectedHandler := middleware.RequireGlobalAuth(mux) diff --git a/internal/shared/middleware/auth.go b/internal/shared/middleware/auth.go index 7224d99..f466c0d 100644 --- a/internal/shared/middleware/auth.go +++ b/internal/shared/middleware/auth.go @@ -41,7 +41,6 @@ func Auth(authService *auth.Service) func(http.Handler) http.Handler { } } -// RequireAuth ensures that a valid user is in the context, otherwise unauthorized func RequireAuth(next http.Handler) http.Handler { return http.HandlerFunc(func(w http.ResponseWriter, r *http.Request) { user, ok := r.Context().Value(UserContextKey).(*database.User) @@ -58,7 +57,6 @@ func RequireAuth(next http.Handler) http.Handler { }) } -// RequireGlobalAuth ensures that a valid user is in the context for all routes except login and static func RequireGlobalAuth(next http.Handler) http.Handler { return http.HandlerFunc(func(w http.ResponseWriter, r *http.Request) { // Allow unauthenticated access to login, register, search, and static files @@ -83,7 +81,6 @@ func RequireGlobalAuth(next http.Handler) http.Handler { }) } -// GetUser returns the user from context, or nil if not logged in func GetUser(ctx context.Context) *database.User { user, ok := ctx.Value(UserContextKey).(*database.User) if !ok { diff --git a/internal/shared/middleware/csrf.go b/internal/shared/middleware/csrf.go index 9099c96..db7cc56 100644 --- a/internal/shared/middleware/csrf.go +++ b/internal/shared/middleware/csrf.go @@ -5,8 +5,6 @@ import ( "net/url" ) -// VerifyOrigin prevents simple CSRF by ensuring the Origin or Referer header matches the Host header -// for state-changing endpoints (POST/PUT/DELETE). func VerifyOrigin(next http.Handler) http.Handler { return http.HandlerFunc(func(w http.ResponseWriter, r *http.Request) { if r.Method == http.MethodGet || r.Method == http.MethodHead || r.Method == http.MethodOptions { @@ -22,7 +20,7 @@ func VerifyOrigin(next http.Handler) http.Handler { http.Error(w, "Missing Origin or Referer header", http.StatusForbidden) return } - + refURL, err := url.Parse(referer) if err != nil { http.Error(w, "Invalid Referer header", http.StatusForbidden) @@ -32,12 +30,10 @@ func VerifyOrigin(next http.Handler) http.Handler { } host := r.Host - // Optional: strip port if you only care about domain - // If origin doesn't match host (accounting for potential schema prefixes) expectedHTTP := "http://" + host expectedHTTPS := "https://" + host - + if origin != expectedHTTP && origin != expectedHTTPS { http.Error(w, "Cross-Site Request Forgery (CSRF) origin mismatch", http.StatusForbidden) return diff --git a/internal/shared/middleware/ratelimit.go b/internal/shared/middleware/ratelimit.go index 2d61366..e844327 100644 --- a/internal/shared/middleware/ratelimit.go +++ b/internal/shared/middleware/ratelimit.go @@ -34,7 +34,6 @@ func cleanupVisitors() { } } -// getIP attempts to get the real IP, falling back to RemoteAddr func getIP(r *http.Request) string { if xff := r.Header.Get("X-Forwarded-For"); xff != "" { ips := strings.Split(xff, ",") @@ -50,7 +49,6 @@ func getIP(r *http.Request) string { return ip } -// RateLimitAuth limits login/register attempts to prevent brute force func RateLimitAuth(next http.Handler) http.Handler { return http.HandlerFunc(func(w http.ResponseWriter, r *http.Request) { ip := getIP(r) @@ -67,7 +65,7 @@ func RateLimitAuth(next http.Handler) http.Handler { v.attempts++ v.lastSeen = time.Now() } - + // If more than 5 attempts within a minute, block if exists && v.attempts > 5 { mu.Unlock() diff --git a/static/css/style.css b/static/css/style.css index 416002d..031f11e 100644 --- a/static/css/style.css +++ b/static/css/style.css @@ -10,21 +10,34 @@ --link-active: #9966ff; /* fluid typography scale */ - --text-xs: clamp(0.625rem, 0.55rem + 0.25vw, 0.75rem); /* 10-12px */ - --text-sm: clamp(0.6875rem, 0.6rem + 0.3vw, 0.8125rem); /* 11-13px */ - --text-base: clamp(0.75rem, 0.65rem + 0.35vw, 0.9375rem); /* 12-15px */ - --text-md: clamp(0.8125rem, 0.7rem + 0.4vw, 1rem); /* 13-16px */ - --text-lg: clamp(0.875rem, 0.75rem + 0.45vw, 1.125rem); /* 14-18px */ - --text-xl: clamp(1rem, 0.85rem + 0.5vw, 1.375rem); /* 16-22px */ - --text-2xl: clamp(1.125rem, 0.95rem + 0.6vw, 1.5rem); /* 18-24px */ + --text-xs: clamp(0.625rem, 0.55rem + 0.25vw, 0.75rem); + /* 10-12px */ + --text-sm: clamp(0.6875rem, 0.6rem + 0.3vw, 0.8125rem); + /* 11-13px */ + --text-base: clamp(0.75rem, 0.65rem + 0.35vw, 0.9375rem); + /* 12-15px */ + --text-md: clamp(0.8125rem, 0.7rem + 0.4vw, 1rem); + /* 13-16px */ + --text-lg: clamp(0.875rem, 0.75rem + 0.45vw, 1.125rem); + /* 14-18px */ + --text-xl: clamp(1rem, 0.85rem + 0.5vw, 1.375rem); + /* 16-22px */ + --text-2xl: clamp(1.125rem, 0.95rem + 0.6vw, 1.5rem); + /* 18-24px */ /* fluid spacing */ - --space-xs: clamp(0.25rem, 0.2rem + 0.15vw, 0.375rem); /* 4-6px */ - --space-sm: clamp(0.375rem, 0.3rem + 0.2vw, 0.5rem); /* 6-8px */ - --space-md: clamp(0.5rem, 0.4rem + 0.3vw, 0.75rem); /* 8-12px */ - --space-lg: clamp(0.75rem, 0.6rem + 0.5vw, 1rem); /* 12-16px */ - --space-xl: clamp(1rem, 0.8rem + 0.7vw, 1.5rem); /* 16-24px */ - --space-2xl: clamp(1.5rem, 1.2rem + 1vw, 2.5rem); /* 24-40px */ + --space-xs: clamp(0.25rem, 0.2rem + 0.15vw, 0.375rem); + /* 4-6px */ + --space-sm: clamp(0.375rem, 0.3rem + 0.2vw, 0.5rem); + /* 6-8px */ + --space-md: clamp(0.5rem, 0.4rem + 0.3vw, 0.75rem); + /* 8-12px */ + --space-lg: clamp(0.75rem, 0.6rem + 0.5vw, 1rem); + /* 12-16px */ + --space-xl: clamp(1rem, 0.8rem + 0.7vw, 1.5rem); + /* 16-24px */ + --space-2xl: clamp(1.5rem, 1.2rem + 1vw, 2.5rem); + /* 24-40px */ /* fluid sizing */ --poster-width: clamp(100px, 8vw + 60px, 180px); @@ -124,7 +137,7 @@ header { border-color: var(--link); } -.search-input:focus + .search-dropdown { +.search-input:focus+.search-dropdown { border-color: var(--link); border-top-color: var(--link); } @@ -214,6 +227,7 @@ header { color: var(--text); font-weight: 500; display: -webkit-box; + line-clamp: 1; -webkit-line-clamp: 1; -webkit-box-orient: vertical; overflow: hidden; @@ -301,6 +315,7 @@ a:visited { font-size: var(--text-base); color: var(--text); display: -webkit-box; + line-clamp: 2; -webkit-line-clamp: 2; -webkit-box-orient: vertical; overflow: hidden; @@ -322,18 +337,38 @@ a:visited { animation: pulse 1.5s ease-in-out infinite; } -.loading-dot:nth-child(2) { animation-delay: 0.2s; } -.loading-dot:nth-child(3) { animation-delay: 0.4s; } +.loading-dot:nth-child(2) { + animation-delay: 0.2s; +} + +.loading-dot:nth-child(3) { + animation-delay: 0.4s; +} @keyframes pulse { - 0%, 100% { opacity: 0.3; } - 50% { opacity: 1; } + + 0%, + 100% { + opacity: 0.3; + } + + 50% { + opacity: 1; + } } /* HTMX Loading States */ -.htmx-indicator { display: none; } -.htmx-request .htmx-indicator { display: inline-block; } -.htmx-request.htmx-indicator { display: inline-block; } +.htmx-indicator { + display: none; +} + +.htmx-request .htmx-indicator { + display: inline-block; +} + +.htmx-request.htmx-indicator { + display: inline-block; +} .dropdown-item.htmx-request, .dropdown-trigger.htmx-request, @@ -754,6 +789,7 @@ a.htmx-request { font-size: var(--text-sm); color: var(--text); display: -webkit-box; + line-clamp: 2; -webkit-line-clamp: 2; -webkit-box-orient: vertical; overflow: hidden; @@ -1157,6 +1193,7 @@ a.htmx-request { margin-bottom: var(--space-xs); line-height: 1.3; display: -webkit-box; + line-clamp: 2; -webkit-line-clamp: 2; -webkit-box-orient: vertical; overflow: hidden; @@ -1280,6 +1317,7 @@ a.htmx-request { margin-bottom: var(--space-xs); line-height: 1.3; display: -webkit-box; + line-clamp: 2; -webkit-line-clamp: 2; -webkit-box-orient: vertical; overflow: hidden; @@ -1315,8 +1353,8 @@ a.htmx-request { color: var(--text-muted); margin: var(--space-xs) 0 0 0; display: -webkit-box; + line-clamp: 3; -webkit-line-clamp: 3; -webkit-box-orient: vertical; overflow: hidden; } -