chore: cleanup files
This commit is contained in:
1
go.mod
1
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
|
||||
)
|
||||
|
||||
@@ -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 {
|
||||
|
||||
@@ -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)
|
||||
}
|
||||
|
||||
@@ -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
|
||||
}
|
||||
|
||||
|
||||
@@ -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]
|
||||
|
||||
@@ -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
|
||||
|
||||
@@ -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)
|
||||
|
||||
@@ -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
|
||||
|
||||
@@ -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)
|
||||
|
||||
|
||||
@@ -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
|
||||
|
||||
@@ -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
|
||||
|
||||
@@ -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
|
||||
|
||||
@@ -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)
|
||||
|
||||
@@ -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 {
|
||||
|
||||
@@ -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
|
||||
|
||||
@@ -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()
|
||||
|
||||
@@ -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;
|
||||
}
|
||||
|
||||
|
||||
Reference in New Issue
Block a user