feat: add schedule, notifications, and recommendations
This commit is contained in:
@@ -27,6 +27,13 @@ type Anime struct {
|
|||||||
Airing sql.NullBool `json:"airing"`
|
Airing sql.NullBool `json:"airing"`
|
||||||
}
|
}
|
||||||
|
|
||||||
|
type NotificationPreference struct {
|
||||||
|
ID string `json:"id"`
|
||||||
|
UserID string `json:"user_id"`
|
||||||
|
NotifyNewEpisodes bool `json:"notify_new_episodes"`
|
||||||
|
CreatedAt time.Time `json:"created_at"`
|
||||||
|
}
|
||||||
|
|
||||||
type Session struct {
|
type Session struct {
|
||||||
ID string `json:"id"`
|
ID string `json:"id"`
|
||||||
UserID string `json:"user_id"`
|
UserID string `json:"user_id"`
|
||||||
@@ -48,4 +55,6 @@ type WatchListEntry struct {
|
|||||||
Status string `json:"status"`
|
Status string `json:"status"`
|
||||||
CreatedAt time.Time `json:"created_at"`
|
CreatedAt time.Time `json:"created_at"`
|
||||||
UpdatedAt time.Time `json:"updated_at"`
|
UpdatedAt time.Time `json:"updated_at"`
|
||||||
|
CurrentEpisode sql.NullInt64 `json:"current_episode"`
|
||||||
|
LastEpisodeAt sql.NullTime `json:"last_episode_at"`
|
||||||
}
|
}
|
||||||
|
|||||||
@@ -20,6 +20,7 @@ type Querier interface {
|
|||||||
GetUserByUsername(ctx context.Context, username string) (User, error)
|
GetUserByUsername(ctx context.Context, username string) (User, error)
|
||||||
GetUserWatchList(ctx context.Context, userID string) ([]GetUserWatchListRow, error)
|
GetUserWatchList(ctx context.Context, userID string) ([]GetUserWatchListRow, error)
|
||||||
GetWatchListEntry(ctx context.Context, arg GetWatchListEntryParams) (WatchListEntry, error)
|
GetWatchListEntry(ctx context.Context, arg GetWatchListEntryParams) (WatchListEntry, error)
|
||||||
|
GetWatchingAnime(ctx context.Context, userID string) ([]GetWatchingAnimeRow, error)
|
||||||
UpsertAnime(ctx context.Context, arg UpsertAnimeParams) (Anime, error)
|
UpsertAnime(ctx context.Context, arg UpsertAnimeParams) (Anime, error)
|
||||||
UpsertWatchListEntry(ctx context.Context, arg UpsertWatchListEntryParams) (WatchListEntry, error)
|
UpsertWatchListEntry(ctx context.Context, arg UpsertWatchListEntryParams) (WatchListEntry, error)
|
||||||
}
|
}
|
||||||
|
|||||||
@@ -38,10 +38,11 @@ RETURNING *;
|
|||||||
SELECT * FROM anime WHERE id = ? LIMIT 1;
|
SELECT * FROM anime WHERE id = ? LIMIT 1;
|
||||||
|
|
||||||
-- name: UpsertWatchListEntry :one
|
-- name: UpsertWatchListEntry :one
|
||||||
INSERT INTO watch_list_entry (id, user_id, anime_id, status, updated_at)
|
INSERT INTO watch_list_entry (id, user_id, anime_id, status, current_episode, updated_at)
|
||||||
VALUES (?, ?, ?, ?, CURRENT_TIMESTAMP)
|
VALUES (?, ?, ?, ?, ?, CURRENT_TIMESTAMP)
|
||||||
ON CONFLICT (user_id, anime_id) DO UPDATE SET
|
ON CONFLICT (user_id, anime_id) DO UPDATE SET
|
||||||
status = excluded.status,
|
status = excluded.status,
|
||||||
|
current_episode = excluded.current_episode,
|
||||||
updated_at = CURRENT_TIMESTAMP
|
updated_at = CURRENT_TIMESTAMP
|
||||||
RETURNING *;
|
RETURNING *;
|
||||||
|
|
||||||
@@ -65,3 +66,16 @@ ORDER BY e.updated_at DESC;
|
|||||||
-- name: DeleteWatchListEntry :exec
|
-- name: DeleteWatchListEntry :exec
|
||||||
DELETE FROM watch_list_entry
|
DELETE FROM watch_list_entry
|
||||||
WHERE user_id = ? AND anime_id = ?;
|
WHERE user_id = ? AND anime_id = ?;
|
||||||
|
|
||||||
|
-- name: GetWatchingAnime :many
|
||||||
|
SELECT
|
||||||
|
e.*,
|
||||||
|
a.title_original,
|
||||||
|
a.title_english,
|
||||||
|
a.title_japanese,
|
||||||
|
a.image_url,
|
||||||
|
a.airing
|
||||||
|
FROM watch_list_entry e
|
||||||
|
JOIN anime a ON e.anime_id = a.id
|
||||||
|
WHERE e.user_id = ? AND e.status = 'watching' AND a.airing = 1
|
||||||
|
ORDER BY e.updated_at DESC;
|
||||||
|
|||||||
@@ -161,7 +161,7 @@ func (q *Queries) GetUserByUsername(ctx context.Context, username string) (User,
|
|||||||
|
|
||||||
const getUserWatchList = `-- name: GetUserWatchList :many
|
const getUserWatchList = `-- name: GetUserWatchList :many
|
||||||
SELECT
|
SELECT
|
||||||
e.id, e.user_id, e.anime_id, e.status, e.created_at, e.updated_at,
|
e.id, e.user_id, e.anime_id, e.status, e.created_at, e.updated_at, e.current_episode, e.last_episode_at,
|
||||||
a.title_original,
|
a.title_original,
|
||||||
a.title_english,
|
a.title_english,
|
||||||
a.title_japanese,
|
a.title_japanese,
|
||||||
@@ -180,6 +180,8 @@ type GetUserWatchListRow struct {
|
|||||||
Status string `json:"status"`
|
Status string `json:"status"`
|
||||||
CreatedAt time.Time `json:"created_at"`
|
CreatedAt time.Time `json:"created_at"`
|
||||||
UpdatedAt time.Time `json:"updated_at"`
|
UpdatedAt time.Time `json:"updated_at"`
|
||||||
|
CurrentEpisode sql.NullInt64 `json:"current_episode"`
|
||||||
|
LastEpisodeAt sql.NullTime `json:"last_episode_at"`
|
||||||
TitleOriginal string `json:"title_original"`
|
TitleOriginal string `json:"title_original"`
|
||||||
TitleEnglish sql.NullString `json:"title_english"`
|
TitleEnglish sql.NullString `json:"title_english"`
|
||||||
TitleJapanese sql.NullString `json:"title_japanese"`
|
TitleJapanese sql.NullString `json:"title_japanese"`
|
||||||
@@ -203,6 +205,8 @@ func (q *Queries) GetUserWatchList(ctx context.Context, userID string) ([]GetUse
|
|||||||
&i.Status,
|
&i.Status,
|
||||||
&i.CreatedAt,
|
&i.CreatedAt,
|
||||||
&i.UpdatedAt,
|
&i.UpdatedAt,
|
||||||
|
&i.CurrentEpisode,
|
||||||
|
&i.LastEpisodeAt,
|
||||||
&i.TitleOriginal,
|
&i.TitleOriginal,
|
||||||
&i.TitleEnglish,
|
&i.TitleEnglish,
|
||||||
&i.TitleJapanese,
|
&i.TitleJapanese,
|
||||||
@@ -223,7 +227,7 @@ func (q *Queries) GetUserWatchList(ctx context.Context, userID string) ([]GetUse
|
|||||||
}
|
}
|
||||||
|
|
||||||
const getWatchListEntry = `-- name: GetWatchListEntry :one
|
const getWatchListEntry = `-- name: GetWatchListEntry :one
|
||||||
SELECT id, user_id, anime_id, status, created_at, updated_at FROM watch_list_entry
|
SELECT id, user_id, anime_id, status, created_at, updated_at, current_episode, last_episode_at FROM watch_list_entry
|
||||||
WHERE user_id = ? AND anime_id = ? LIMIT 1
|
WHERE user_id = ? AND anime_id = ? LIMIT 1
|
||||||
`
|
`
|
||||||
|
|
||||||
@@ -242,10 +246,79 @@ func (q *Queries) GetWatchListEntry(ctx context.Context, arg GetWatchListEntryPa
|
|||||||
&i.Status,
|
&i.Status,
|
||||||
&i.CreatedAt,
|
&i.CreatedAt,
|
||||||
&i.UpdatedAt,
|
&i.UpdatedAt,
|
||||||
|
&i.CurrentEpisode,
|
||||||
|
&i.LastEpisodeAt,
|
||||||
)
|
)
|
||||||
return i, err
|
return i, err
|
||||||
}
|
}
|
||||||
|
|
||||||
|
const getWatchingAnime = `-- name: GetWatchingAnime :many
|
||||||
|
SELECT
|
||||||
|
e.id, e.user_id, e.anime_id, e.status, e.created_at, e.updated_at, e.current_episode, e.last_episode_at,
|
||||||
|
a.title_original,
|
||||||
|
a.title_english,
|
||||||
|
a.title_japanese,
|
||||||
|
a.image_url,
|
||||||
|
a.airing
|
||||||
|
FROM watch_list_entry e
|
||||||
|
JOIN anime a ON e.anime_id = a.id
|
||||||
|
WHERE e.user_id = ? AND e.status = 'watching' AND a.airing = 1
|
||||||
|
ORDER BY e.updated_at DESC
|
||||||
|
`
|
||||||
|
|
||||||
|
type GetWatchingAnimeRow struct {
|
||||||
|
ID string `json:"id"`
|
||||||
|
UserID string `json:"user_id"`
|
||||||
|
AnimeID int64 `json:"anime_id"`
|
||||||
|
Status string `json:"status"`
|
||||||
|
CreatedAt time.Time `json:"created_at"`
|
||||||
|
UpdatedAt time.Time `json:"updated_at"`
|
||||||
|
CurrentEpisode sql.NullInt64 `json:"current_episode"`
|
||||||
|
LastEpisodeAt sql.NullTime `json:"last_episode_at"`
|
||||||
|
TitleOriginal string `json:"title_original"`
|
||||||
|
TitleEnglish sql.NullString `json:"title_english"`
|
||||||
|
TitleJapanese sql.NullString `json:"title_japanese"`
|
||||||
|
ImageUrl string `json:"image_url"`
|
||||||
|
Airing sql.NullBool `json:"airing"`
|
||||||
|
}
|
||||||
|
|
||||||
|
func (q *Queries) GetWatchingAnime(ctx context.Context, userID string) ([]GetWatchingAnimeRow, error) {
|
||||||
|
rows, err := q.db.QueryContext(ctx, getWatchingAnime, userID)
|
||||||
|
if err != nil {
|
||||||
|
return nil, err
|
||||||
|
}
|
||||||
|
defer rows.Close()
|
||||||
|
var items []GetWatchingAnimeRow
|
||||||
|
for rows.Next() {
|
||||||
|
var i GetWatchingAnimeRow
|
||||||
|
if err := rows.Scan(
|
||||||
|
&i.ID,
|
||||||
|
&i.UserID,
|
||||||
|
&i.AnimeID,
|
||||||
|
&i.Status,
|
||||||
|
&i.CreatedAt,
|
||||||
|
&i.UpdatedAt,
|
||||||
|
&i.CurrentEpisode,
|
||||||
|
&i.LastEpisodeAt,
|
||||||
|
&i.TitleOriginal,
|
||||||
|
&i.TitleEnglish,
|
||||||
|
&i.TitleJapanese,
|
||||||
|
&i.ImageUrl,
|
||||||
|
&i.Airing,
|
||||||
|
); err != nil {
|
||||||
|
return nil, err
|
||||||
|
}
|
||||||
|
items = append(items, i)
|
||||||
|
}
|
||||||
|
if err := rows.Close(); err != nil {
|
||||||
|
return nil, err
|
||||||
|
}
|
||||||
|
if err := rows.Err(); err != nil {
|
||||||
|
return nil, err
|
||||||
|
}
|
||||||
|
return items, nil
|
||||||
|
}
|
||||||
|
|
||||||
const upsertAnime = `-- name: UpsertAnime :one
|
const upsertAnime = `-- name: UpsertAnime :one
|
||||||
INSERT INTO anime (id, title_original, title_english, title_japanese, image_url, airing)
|
INSERT INTO anime (id, title_original, title_english, title_japanese, image_url, airing)
|
||||||
VALUES (?, ?, ?, ?, ?, ?)
|
VALUES (?, ?, ?, ?, ?, ?)
|
||||||
@@ -290,12 +363,13 @@ func (q *Queries) UpsertAnime(ctx context.Context, arg UpsertAnimeParams) (Anime
|
|||||||
}
|
}
|
||||||
|
|
||||||
const upsertWatchListEntry = `-- name: UpsertWatchListEntry :one
|
const upsertWatchListEntry = `-- name: UpsertWatchListEntry :one
|
||||||
INSERT INTO watch_list_entry (id, user_id, anime_id, status, updated_at)
|
INSERT INTO watch_list_entry (id, user_id, anime_id, status, current_episode, updated_at)
|
||||||
VALUES (?, ?, ?, ?, CURRENT_TIMESTAMP)
|
VALUES (?, ?, ?, ?, ?, CURRENT_TIMESTAMP)
|
||||||
ON CONFLICT (user_id, anime_id) DO UPDATE SET
|
ON CONFLICT (user_id, anime_id) DO UPDATE SET
|
||||||
status = excluded.status,
|
status = excluded.status,
|
||||||
|
current_episode = excluded.current_episode,
|
||||||
updated_at = CURRENT_TIMESTAMP
|
updated_at = CURRENT_TIMESTAMP
|
||||||
RETURNING id, user_id, anime_id, status, created_at, updated_at
|
RETURNING id, user_id, anime_id, status, created_at, updated_at, current_episode, last_episode_at
|
||||||
`
|
`
|
||||||
|
|
||||||
type UpsertWatchListEntryParams struct {
|
type UpsertWatchListEntryParams struct {
|
||||||
@@ -303,6 +377,7 @@ type UpsertWatchListEntryParams struct {
|
|||||||
UserID string `json:"user_id"`
|
UserID string `json:"user_id"`
|
||||||
AnimeID int64 `json:"anime_id"`
|
AnimeID int64 `json:"anime_id"`
|
||||||
Status string `json:"status"`
|
Status string `json:"status"`
|
||||||
|
CurrentEpisode sql.NullInt64 `json:"current_episode"`
|
||||||
}
|
}
|
||||||
|
|
||||||
func (q *Queries) UpsertWatchListEntry(ctx context.Context, arg UpsertWatchListEntryParams) (WatchListEntry, error) {
|
func (q *Queries) UpsertWatchListEntry(ctx context.Context, arg UpsertWatchListEntryParams) (WatchListEntry, error) {
|
||||||
@@ -311,6 +386,7 @@ func (q *Queries) UpsertWatchListEntry(ctx context.Context, arg UpsertWatchListE
|
|||||||
arg.UserID,
|
arg.UserID,
|
||||||
arg.AnimeID,
|
arg.AnimeID,
|
||||||
arg.Status,
|
arg.Status,
|
||||||
|
arg.CurrentEpisode,
|
||||||
)
|
)
|
||||||
var i WatchListEntry
|
var i WatchListEntry
|
||||||
err := row.Scan(
|
err := row.Scan(
|
||||||
@@ -320,6 +396,8 @@ func (q *Queries) UpsertWatchListEntry(ctx context.Context, arg UpsertWatchListE
|
|||||||
&i.Status,
|
&i.Status,
|
||||||
&i.CreatedAt,
|
&i.CreatedAt,
|
||||||
&i.UpdatedAt,
|
&i.UpdatedAt,
|
||||||
|
&i.CurrentEpisode,
|
||||||
|
&i.LastEpisodeAt,
|
||||||
)
|
)
|
||||||
return i, err
|
return i, err
|
||||||
}
|
}
|
||||||
|
|||||||
@@ -145,6 +145,62 @@ func (h *Handler) HandleAPIAnimeRelations(w http.ResponseWriter, r *http.Request
|
|||||||
templates.AnimeRelationsList(relations).Render(r.Context(), w)
|
templates.AnimeRelationsList(relations).Render(r.Context(), w)
|
||||||
}
|
}
|
||||||
|
|
||||||
|
// HandleAPIAnime routes anime API requests
|
||||||
|
func (h *Handler) HandleAPIAnime(w http.ResponseWriter, r *http.Request) {
|
||||||
|
path := r.URL.Path[len("/api/anime/"):]
|
||||||
|
|
||||||
|
// Parse: {id}/relations or {id}/recommendations
|
||||||
|
parts := splitPath(path)
|
||||||
|
if len(parts) < 2 {
|
||||||
|
http.Error(w, "invalid path", http.StatusBadRequest)
|
||||||
|
return
|
||||||
|
}
|
||||||
|
|
||||||
|
id, err := strconv.Atoi(parts[0])
|
||||||
|
if err != nil || id <= 0 {
|
||||||
|
http.Error(w, "invalid id", http.StatusBadRequest)
|
||||||
|
return
|
||||||
|
}
|
||||||
|
|
||||||
|
switch parts[1] {
|
||||||
|
case "relations":
|
||||||
|
relations := h.svc.GetRelations(id)
|
||||||
|
templates.AnimeRelationsList(relations).Render(r.Context(), w)
|
||||||
|
case "recommendations":
|
||||||
|
recs, err := h.svc.GetRecommendations(id)
|
||||||
|
if err != nil {
|
||||||
|
log.Printf("recommendations error for %d: %v", id, err)
|
||||||
|
http.Error(w, "Failed to fetch recommendations", http.StatusInternalServerError)
|
||||||
|
return
|
||||||
|
}
|
||||||
|
if len(recs) > 10 {
|
||||||
|
recs = recs[:10]
|
||||||
|
}
|
||||||
|
templates.AnimeRecommendations(recs).Render(r.Context(), w)
|
||||||
|
default:
|
||||||
|
http.Error(w, "not found", http.StatusNotFound)
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
func splitPath(path string) []string {
|
||||||
|
var parts []string
|
||||||
|
var current string
|
||||||
|
for _, c := range path {
|
||||||
|
if c == '/' {
|
||||||
|
if current != "" {
|
||||||
|
parts = append(parts, current)
|
||||||
|
current = ""
|
||||||
|
}
|
||||||
|
} else {
|
||||||
|
current += string(c)
|
||||||
|
}
|
||||||
|
}
|
||||||
|
if current != "" {
|
||||||
|
parts = append(parts, current)
|
||||||
|
}
|
||||||
|
return parts
|
||||||
|
}
|
||||||
|
|
||||||
func (h *Handler) HandleQuickSearch(w http.ResponseWriter, r *http.Request) {
|
func (h *Handler) HandleQuickSearch(w http.ResponseWriter, r *http.Request) {
|
||||||
query := r.URL.Query().Get("q")
|
query := r.URL.Query().Get("q")
|
||||||
if query == "" {
|
if query == "" {
|
||||||
@@ -231,3 +287,46 @@ func (h *Handler) HandleAPIDiscoverUpcoming(w http.ResponseWriter, r *http.Reque
|
|||||||
|
|
||||||
templates.DiscoverItems(res.Animes, "upcoming", page+1, res.HasNextPage).Render(r.Context(), w)
|
templates.DiscoverItems(res.Animes, "upcoming", page+1, res.HasNextPage).Render(r.Context(), w)
|
||||||
}
|
}
|
||||||
|
|
||||||
|
func (h *Handler) HandleSchedule(w http.ResponseWriter, r *http.Request) {
|
||||||
|
templates.Schedule().Render(r.Context(), w)
|
||||||
|
}
|
||||||
|
|
||||||
|
func (h *Handler) HandleAPISchedule(w http.ResponseWriter, r *http.Request) {
|
||||||
|
day := r.URL.Query().Get("day")
|
||||||
|
if day == "" {
|
||||||
|
day = "monday"
|
||||||
|
}
|
||||||
|
|
||||||
|
res, err := h.svc.GetSchedule(day)
|
||||||
|
if err != nil {
|
||||||
|
log.Printf("schedule error for %s: %v", day, err)
|
||||||
|
http.Error(w, "Failed to fetch schedule", http.StatusInternalServerError)
|
||||||
|
return
|
||||||
|
}
|
||||||
|
|
||||||
|
res.Animes = deduplicateAnimes(res.Animes)
|
||||||
|
|
||||||
|
templates.ScheduleDay(day, res.Animes).Render(r.Context(), w)
|
||||||
|
}
|
||||||
|
|
||||||
|
func (h *Handler) HandleNotifications(w http.ResponseWriter, r *http.Request) {
|
||||||
|
userID := ""
|
||||||
|
if user, ok := r.Context().Value(middleware.UserContextKey).(*database.User); ok && user != nil {
|
||||||
|
userID = user.ID
|
||||||
|
}
|
||||||
|
|
||||||
|
if userID == "" {
|
||||||
|
http.Redirect(w, r, "/login", http.StatusSeeOther)
|
||||||
|
return
|
||||||
|
}
|
||||||
|
|
||||||
|
watching, err := h.svc.GetWatchingAnime(r.Context(), userID)
|
||||||
|
if err != nil {
|
||||||
|
log.Printf("watching anime error: %v", err)
|
||||||
|
http.Error(w, "Failed to fetch watching anime", http.StatusInternalServerError)
|
||||||
|
return
|
||||||
|
}
|
||||||
|
|
||||||
|
templates.Notifications(watching).Render(r.Context(), w)
|
||||||
|
}
|
||||||
|
|||||||
@@ -6,6 +6,7 @@ import (
|
|||||||
|
|
||||||
"mal/internal/database"
|
"mal/internal/database"
|
||||||
"mal/internal/jikan"
|
"mal/internal/jikan"
|
||||||
|
"mal/internal/templates"
|
||||||
)
|
)
|
||||||
|
|
||||||
type Service struct {
|
type Service struct {
|
||||||
@@ -59,3 +60,34 @@ func (s *Service) GetAnimeDetails(ctx context.Context, id int, userID string) (j
|
|||||||
func (s *Service) GetRelations(id int) []jikan.RelationEntry {
|
func (s *Service) GetRelations(id int) []jikan.RelationEntry {
|
||||||
return s.jikanClient.GetFullRelations(id)
|
return s.jikanClient.GetFullRelations(id)
|
||||||
}
|
}
|
||||||
|
|
||||||
|
func (s *Service) GetSchedule(day string) (jikan.ScheduleResult, error) {
|
||||||
|
return s.jikanClient.GetSchedule(day)
|
||||||
|
}
|
||||||
|
|
||||||
|
func (s *Service) GetRecommendations(animeID int) ([]jikan.Anime, error) {
|
||||||
|
return s.jikanClient.GetRecommendations(animeID)
|
||||||
|
}
|
||||||
|
|
||||||
|
func (s *Service) GetWatchingAnime(ctx context.Context, userID string) ([]templates.WatchingAnimeWithDetails, error) {
|
||||||
|
rows, err := s.db.GetWatchingAnime(ctx, userID)
|
||||||
|
if err != nil {
|
||||||
|
return nil, fmt.Errorf("failed to get watching anime: %w", err)
|
||||||
|
}
|
||||||
|
|
||||||
|
var result []templates.WatchingAnimeWithDetails
|
||||||
|
for _, row := range rows {
|
||||||
|
anime, err := s.jikanClient.GetAnimeByID(int(row.AnimeID))
|
||||||
|
if err != nil {
|
||||||
|
// Skip if we can't fetch anime details
|
||||||
|
continue
|
||||||
|
}
|
||||||
|
|
||||||
|
result = append(result, templates.WatchingAnimeWithDetails{
|
||||||
|
Entry: row,
|
||||||
|
Anime: anime,
|
||||||
|
})
|
||||||
|
}
|
||||||
|
|
||||||
|
return result, nil
|
||||||
|
}
|
||||||
|
|||||||
@@ -52,6 +52,7 @@ func (s *Service) AddEntry(ctx context.Context, userID string, req AddRequest) e
|
|||||||
UserID: userID,
|
UserID: userID,
|
||||||
AnimeID: req.AnimeID,
|
AnimeID: req.AnimeID,
|
||||||
Status: req.Status,
|
Status: req.Status,
|
||||||
|
CurrentEpisode: sql.NullInt64{Int64: 0, Valid: false},
|
||||||
})
|
})
|
||||||
if err != nil {
|
if err != nil {
|
||||||
return fmt.Errorf("failed to update watchlist: %w", err)
|
return fmt.Errorf("failed to update watchlist: %w", err)
|
||||||
@@ -156,6 +157,7 @@ func (s *Service) Import(ctx context.Context, userID string, export ExportData)
|
|||||||
UserID: userID,
|
UserID: userID,
|
||||||
AnimeID: entry.AnimeID,
|
AnimeID: entry.AnimeID,
|
||||||
Status: entry.Status,
|
Status: entry.Status,
|
||||||
|
CurrentEpisode: sql.NullInt64{Int64: 0, Valid: false},
|
||||||
})
|
})
|
||||||
if err != nil {
|
if err != nil {
|
||||||
continue
|
continue
|
||||||
|
|||||||
@@ -18,6 +18,8 @@ type Client struct {
|
|||||||
upcomingCache *expirable.LRU[int, TopAnimeResult]
|
upcomingCache *expirable.LRU[int, TopAnimeResult]
|
||||||
animeCache *expirable.LRU[int, Anime]
|
animeCache *expirable.LRU[int, Anime]
|
||||||
relationsCache *expirable.LRU[int, JikanRelationsResponse]
|
relationsCache *expirable.LRU[int, JikanRelationsResponse]
|
||||||
|
scheduleCache *expirable.LRU[string, ScheduleResult]
|
||||||
|
recsCache *expirable.LRU[int, []Anime]
|
||||||
}
|
}
|
||||||
|
|
||||||
func NewClient() *Client {
|
func NewClient() *Client {
|
||||||
@@ -27,6 +29,8 @@ func NewClient() *Client {
|
|||||||
upcomingCache := expirable.NewLRU[int, TopAnimeResult](100, nil, time.Hour*1)
|
upcomingCache := expirable.NewLRU[int, TopAnimeResult](100, nil, time.Hour*1)
|
||||||
animeCache := expirable.NewLRU[int, Anime](1000, nil, time.Hour*24)
|
animeCache := expirable.NewLRU[int, Anime](1000, nil, time.Hour*24)
|
||||||
relationsCache := expirable.NewLRU[int, JikanRelationsResponse](1000, nil, time.Hour*24)
|
relationsCache := expirable.NewLRU[int, JikanRelationsResponse](1000, nil, time.Hour*24)
|
||||||
|
scheduleCache := expirable.NewLRU[string, ScheduleResult](50, nil, time.Hour*1)
|
||||||
|
recsCache := expirable.NewLRU[int, []Anime](500, nil, time.Hour*24)
|
||||||
|
|
||||||
return &Client{
|
return &Client{
|
||||||
httpClient: &http.Client{Timeout: 10 * time.Second},
|
httpClient: &http.Client{Timeout: 10 * time.Second},
|
||||||
@@ -37,6 +41,8 @@ func NewClient() *Client {
|
|||||||
upcomingCache: upcomingCache,
|
upcomingCache: upcomingCache,
|
||||||
animeCache: animeCache,
|
animeCache: animeCache,
|
||||||
relationsCache: relationsCache,
|
relationsCache: relationsCache,
|
||||||
|
scheduleCache: scheduleCache,
|
||||||
|
recsCache: recsCache,
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
|
|||||||
61
internal/jikan/recommendations.go
Normal file
61
internal/jikan/recommendations.go
Normal file
@@ -0,0 +1,61 @@
|
|||||||
|
package jikan
|
||||||
|
|
||||||
|
import "fmt"
|
||||||
|
|
||||||
|
// RecommendationEntry represents a single recommendation
|
||||||
|
type RecommendationEntry struct {
|
||||||
|
Entry struct {
|
||||||
|
MalID int `json:"mal_id"`
|
||||||
|
URL string `json:"url"`
|
||||||
|
Images struct {
|
||||||
|
Webp struct {
|
||||||
|
LargeImageURL string `json:"large_image_url"`
|
||||||
|
} `json:"webp"`
|
||||||
|
} `json:"images"`
|
||||||
|
Title string `json:"title"`
|
||||||
|
} `json:"entry"`
|
||||||
|
Votes int `json:"votes"`
|
||||||
|
}
|
||||||
|
|
||||||
|
type RecommendationsResponse struct {
|
||||||
|
Data []RecommendationEntry `json:"data"`
|
||||||
|
}
|
||||||
|
|
||||||
|
// GetRecommendations fetches recommendations for an anime
|
||||||
|
func (c *Client) GetRecommendations(animeID int) ([]Anime, error) {
|
||||||
|
if cached, ok := c.recsCache.Get(animeID); ok {
|
||||||
|
return cached, nil
|
||||||
|
}
|
||||||
|
|
||||||
|
var result RecommendationsResponse
|
||||||
|
reqURL := fmt.Sprintf("%s/anime/%d/recommendations", c.baseURL, animeID)
|
||||||
|
if err := c.fetchWithRetry(reqURL, &result); err != nil {
|
||||||
|
return nil, err
|
||||||
|
}
|
||||||
|
|
||||||
|
// Convert to Anime slice (partial data)
|
||||||
|
animes := make([]Anime, 0, len(result.Data))
|
||||||
|
for _, rec := range result.Data {
|
||||||
|
animes = append(animes, Anime{
|
||||||
|
MalID: rec.Entry.MalID,
|
||||||
|
Title: rec.Entry.Title,
|
||||||
|
Images: struct {
|
||||||
|
Jpg struct {
|
||||||
|
LargeImageURL string `json:"large_image_url"`
|
||||||
|
} `json:"jpg"`
|
||||||
|
Webp struct {
|
||||||
|
LargeImageURL string `json:"large_image_url"`
|
||||||
|
} `json:"webp"`
|
||||||
|
}{
|
||||||
|
Webp: struct {
|
||||||
|
LargeImageURL string `json:"large_image_url"`
|
||||||
|
}{
|
||||||
|
LargeImageURL: rec.Entry.Images.Webp.LargeImageURL,
|
||||||
|
},
|
||||||
|
},
|
||||||
|
})
|
||||||
|
}
|
||||||
|
|
||||||
|
c.recsCache.Add(animeID, animes)
|
||||||
|
return animes, nil
|
||||||
|
}
|
||||||
@@ -1,6 +1,56 @@
|
|||||||
package jikan
|
package jikan
|
||||||
|
|
||||||
import "fmt"
|
import (
|
||||||
|
"fmt"
|
||||||
|
"strings"
|
||||||
|
)
|
||||||
|
|
||||||
|
// 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)
|
||||||
|
|
||||||
|
if cached, ok := c.scheduleCache.Get(cacheKey); ok {
|
||||||
|
return cached, nil
|
||||||
|
}
|
||||||
|
|
||||||
|
var result TopAnimeResponse
|
||||||
|
reqURL := fmt.Sprintf("%s/schedules?filter=%s&sfw=true", c.baseURL, day)
|
||||||
|
if err := c.fetchWithRetry(reqURL, &result); err != nil {
|
||||||
|
return ScheduleResult{}, err
|
||||||
|
}
|
||||||
|
|
||||||
|
res := ScheduleResult{
|
||||||
|
Animes: result.Data,
|
||||||
|
HasNextPage: result.Pagination.HasNextPage,
|
||||||
|
}
|
||||||
|
|
||||||
|
c.scheduleCache.Add(cacheKey, res)
|
||||||
|
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)
|
||||||
|
|
||||||
|
for _, day := range days {
|
||||||
|
res, err := c.GetSchedule(day)
|
||||||
|
if err != nil {
|
||||||
|
return nil, fmt.Errorf("failed to fetch %s schedule: %w", day, err)
|
||||||
|
}
|
||||||
|
schedule[day] = res.Animes
|
||||||
|
}
|
||||||
|
|
||||||
|
return schedule, nil
|
||||||
|
}
|
||||||
|
|
||||||
// GetSeasonsNow fetches currently airing anime
|
// GetSeasonsNow fetches currently airing anime
|
||||||
func (c *Client) GetSeasonsNow(page int) (TopAnimeResult, error) {
|
func (c *Client) GetSeasonsNow(page int) (TopAnimeResult, error) {
|
||||||
|
|||||||
@@ -35,6 +35,9 @@ func NewRouter(cfg Config) http.Handler {
|
|||||||
// Anime / Search / Catalog
|
// Anime / Search / Catalog
|
||||||
mux.HandleFunc("/", animeHandler.HandleCatalog)
|
mux.HandleFunc("/", animeHandler.HandleCatalog)
|
||||||
mux.HandleFunc("/discover", animeHandler.HandleDiscover)
|
mux.HandleFunc("/discover", animeHandler.HandleDiscover)
|
||||||
|
mux.HandleFunc("/schedule", animeHandler.HandleSchedule)
|
||||||
|
mux.HandleFunc("/notifications", animeHandler.HandleNotifications)
|
||||||
|
mux.HandleFunc("/api/schedule", animeHandler.HandleAPISchedule)
|
||||||
mux.HandleFunc("/api/discover/airing", animeHandler.HandleAPIDiscoverAiring)
|
mux.HandleFunc("/api/discover/airing", animeHandler.HandleAPIDiscoverAiring)
|
||||||
mux.HandleFunc("/api/discover/upcoming", animeHandler.HandleAPIDiscoverUpcoming)
|
mux.HandleFunc("/api/discover/upcoming", animeHandler.HandleAPIDiscoverUpcoming)
|
||||||
mux.HandleFunc("/search", animeHandler.HandleSearch)
|
mux.HandleFunc("/search", animeHandler.HandleSearch)
|
||||||
@@ -42,7 +45,7 @@ func NewRouter(cfg Config) http.Handler {
|
|||||||
mux.HandleFunc("/api/search-quick", animeHandler.HandleQuickSearch)
|
mux.HandleFunc("/api/search-quick", animeHandler.HandleQuickSearch)
|
||||||
mux.HandleFunc("/api/catalog", animeHandler.HandleAPICatalog)
|
mux.HandleFunc("/api/catalog", animeHandler.HandleAPICatalog)
|
||||||
mux.HandleFunc("/anime/", animeHandler.HandleAnimeDetails)
|
mux.HandleFunc("/anime/", animeHandler.HandleAnimeDetails)
|
||||||
mux.HandleFunc("/api/anime/", animeHandler.HandleAPIAnimeRelations)
|
mux.HandleFunc("/api/anime/", animeHandler.HandleAPIAnime)
|
||||||
|
|
||||||
// Auth Endpoints
|
// Auth Endpoints
|
||||||
mux.HandleFunc("/login", func(w http.ResponseWriter, r *http.Request) {
|
mux.HandleFunc("/login", func(w http.ResponseWriter, r *http.Request) {
|
||||||
|
|||||||
@@ -56,6 +56,14 @@ templ AnimeDetails(anime jikan.Anime, currentStatus string) {
|
|||||||
<span>loading relations</span>
|
<span>loading relations</span>
|
||||||
</div>
|
</div>
|
||||||
</section>
|
</section>
|
||||||
|
<section class="anime-recommendations" hx-get={ string(templ.URL(fmt.Sprintf("/api/anime/%d/recommendations", anime.MalID))) } hx-trigger="load">
|
||||||
|
<div class="loading-indicator">
|
||||||
|
<div class="loading-dot"></div>
|
||||||
|
<div class="loading-dot"></div>
|
||||||
|
<div class="loading-dot"></div>
|
||||||
|
<span>loading recommendations</span>
|
||||||
|
</div>
|
||||||
|
</section>
|
||||||
</div>
|
</div>
|
||||||
<aside class="anime-sidebar">
|
<aside class="anime-sidebar">
|
||||||
if anime.TitleJapanese != "" {
|
if anime.TitleJapanese != "" {
|
||||||
@@ -292,3 +300,21 @@ templ AnimeRelationsList(relations []jikan.RelationEntry) {
|
|||||||
</div>
|
</div>
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
|
templ AnimeRecommendations(recs []jikan.Anime) {
|
||||||
|
if len(recs) > 0 {
|
||||||
|
<h3>recommendations</h3>
|
||||||
|
<div class="relations-grid">
|
||||||
|
for _, anime := range recs {
|
||||||
|
<a href={ templ.URL(fmt.Sprintf("/anime/%d", anime.MalID)) } class="relation-card">
|
||||||
|
if anime.ImageURL() != "" {
|
||||||
|
<img src={ anime.ImageURL() } alt={ anime.DisplayTitle() } class="relation-thumb"/>
|
||||||
|
} else {
|
||||||
|
<div class="no-image">no image</div>
|
||||||
|
}
|
||||||
|
<div class="relation-title">{ anime.DisplayTitle() }</div>
|
||||||
|
</a>
|
||||||
|
}
|
||||||
|
</div>
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|||||||
File diff suppressed because it is too large
Load Diff
@@ -18,6 +18,8 @@ templ Layout(title string) {
|
|||||||
<div class="nav">
|
<div class="nav">
|
||||||
<a href="/">catalog</a>
|
<a href="/">catalog</a>
|
||||||
<a href="/discover">discover</a>
|
<a href="/discover">discover</a>
|
||||||
|
<a href="/schedule">schedule</a>
|
||||||
|
<a href="/notifications">notifications</a>
|
||||||
<a href="/watchlist">watchlist</a>
|
<a href="/watchlist">watchlist</a>
|
||||||
</div>
|
</div>
|
||||||
</div>
|
</div>
|
||||||
|
|||||||
@@ -42,7 +42,7 @@ func Layout(title string) templ.Component {
|
|||||||
if templ_7745c5c3_Err != nil {
|
if templ_7745c5c3_Err != nil {
|
||||||
return templ_7745c5c3_Err
|
return templ_7745c5c3_Err
|
||||||
}
|
}
|
||||||
templ_7745c5c3_Err = templruntime.WriteString(templ_7745c5c3_Buffer, 2, "</title><link rel=\"stylesheet\" href=\"/static/css/style.css\"><script src=\"https://unpkg.com/htmx.org@1.9.11\"></script></head><body><header><div class=\"header-top\"><div class=\"header-left\"><a href=\"/\" class=\"logo\">/mal</a><div class=\"nav\"><a href=\"/\">catalog</a> <a href=\"/discover\">discover</a> <a href=\"/watchlist\">watchlist</a></div></div><div class=\"header-search-wrapper\"><form action=\"/search\" method=\"GET\" class=\"header-search\"><input type=\"text\" id=\"search-input\" name=\"q\" class=\"search-input\" placeholder=\"search anime...\" autocomplete=\"off\"><div id=\"search-dropdown\" class=\"search-dropdown\"></div></form></div></div></header><main>")
|
templ_7745c5c3_Err = templruntime.WriteString(templ_7745c5c3_Buffer, 2, "</title><link rel=\"stylesheet\" href=\"/static/css/style.css\"><script src=\"https://unpkg.com/htmx.org@1.9.11\"></script></head><body><header><div class=\"header-top\"><div class=\"header-left\"><a href=\"/\" class=\"logo\">/mal</a><div class=\"nav\"><a href=\"/\">catalog</a> <a href=\"/discover\">discover</a> <a href=\"/schedule\">schedule</a> <a href=\"/watchlist\">watchlist</a></div></div><div class=\"header-search-wrapper\"><form action=\"/search\" method=\"GET\" class=\"header-search\"><input type=\"text\" id=\"search-input\" name=\"q\" class=\"search-input\" placeholder=\"search anime...\" autocomplete=\"off\"><div id=\"search-dropdown\" class=\"search-dropdown\"></div></form></div></div></header><main>")
|
||||||
if templ_7745c5c3_Err != nil {
|
if templ_7745c5c3_Err != nil {
|
||||||
return templ_7745c5c3_Err
|
return templ_7745c5c3_Err
|
||||||
}
|
}
|
||||||
|
|||||||
87
internal/templates/notifications.templ
Normal file
87
internal/templates/notifications.templ
Normal file
@@ -0,0 +1,87 @@
|
|||||||
|
package templates
|
||||||
|
|
||||||
|
import "mal/internal/jikan"
|
||||||
|
import "mal/internal/database"
|
||||||
|
import "fmt"
|
||||||
|
|
||||||
|
type WatchingAnimeWithDetails struct {
|
||||||
|
Entry database.GetWatchingAnimeRow
|
||||||
|
Anime jikan.Anime
|
||||||
|
}
|
||||||
|
|
||||||
|
templ Notifications(watching []WatchingAnimeWithDetails) {
|
||||||
|
@Layout("mal - notifications") {
|
||||||
|
<div class="notifications-page">
|
||||||
|
<h1>upcoming episodes</h1>
|
||||||
|
<p class="notifications-subtitle">anime you're watching</p>
|
||||||
|
|
||||||
|
if len(watching) == 0 {
|
||||||
|
<div class="no-notifications">
|
||||||
|
<p>no airing anime in your watching list</p>
|
||||||
|
<p class="hint">add currently airing shows to your watching list to see upcoming episodes here</p>
|
||||||
|
</div>
|
||||||
|
} else {
|
||||||
|
<div class="notifications-list">
|
||||||
|
for _, item := range watching {
|
||||||
|
@NotificationCard(item)
|
||||||
|
}
|
||||||
|
</div>
|
||||||
|
}
|
||||||
|
</div>
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
templ NotificationCard(item WatchingAnimeWithDetails) {
|
||||||
|
<div class="notification-card">
|
||||||
|
<a href={ templ.URL(fmt.Sprintf("/anime/%d", item.Entry.AnimeID)) } class="notification-image">
|
||||||
|
if item.Entry.ImageUrl != "" {
|
||||||
|
<img src={ item.Entry.ImageUrl } alt={ displayTitle(item.Entry) } loading="lazy"/>
|
||||||
|
} else {
|
||||||
|
<div class="no-image">no image</div>
|
||||||
|
}
|
||||||
|
</a>
|
||||||
|
<div class="notification-content">
|
||||||
|
<a href={ templ.URL(fmt.Sprintf("/anime/%d", item.Entry.AnimeID)) } class="notification-title">
|
||||||
|
{ displayTitle(item.Entry) }
|
||||||
|
</a>
|
||||||
|
<div class="notification-meta">
|
||||||
|
if item.Anime.Broadcast.String != "" {
|
||||||
|
<span class="notification-broadcast">{ item.Anime.Broadcast.String }</span>
|
||||||
|
}
|
||||||
|
if item.Anime.Episodes > 0 {
|
||||||
|
<span class="notification-progress">
|
||||||
|
if item.Entry.CurrentEpisode.Valid {
|
||||||
|
{ fmt.Sprintf("%d / %d episodes", item.Entry.CurrentEpisode.Int64, item.Anime.Episodes) }
|
||||||
|
} else {
|
||||||
|
{ fmt.Sprintf("0 / %d episodes", item.Anime.Episodes) }
|
||||||
|
}
|
||||||
|
</span>
|
||||||
|
} else if item.Entry.CurrentEpisode.Valid && item.Entry.CurrentEpisode.Int64 > 0 {
|
||||||
|
<span class="notification-progress">
|
||||||
|
{ fmt.Sprintf("%d episodes watched", item.Entry.CurrentEpisode.Int64) }
|
||||||
|
</span>
|
||||||
|
}
|
||||||
|
</div>
|
||||||
|
if item.Anime.Synopsis != "" {
|
||||||
|
<p class="notification-synopsis">{ truncate(item.Anime.Synopsis, 150) }</p>
|
||||||
|
}
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
}
|
||||||
|
|
||||||
|
func displayTitle(entry database.GetWatchingAnimeRow) string {
|
||||||
|
if entry.TitleEnglish.Valid && entry.TitleEnglish.String != "" {
|
||||||
|
return entry.TitleEnglish.String
|
||||||
|
}
|
||||||
|
if entry.TitleJapanese.Valid && entry.TitleJapanese.String != "" {
|
||||||
|
return entry.TitleJapanese.String
|
||||||
|
}
|
||||||
|
return entry.TitleOriginal
|
||||||
|
}
|
||||||
|
|
||||||
|
func truncate(s string, max int) string {
|
||||||
|
if len(s) <= max {
|
||||||
|
return s
|
||||||
|
}
|
||||||
|
return s[:max-3] + "..."
|
||||||
|
}
|
||||||
313
internal/templates/notifications_templ.go
Normal file
313
internal/templates/notifications_templ.go
Normal file
@@ -0,0 +1,313 @@
|
|||||||
|
// Code generated by templ - DO NOT EDIT.
|
||||||
|
|
||||||
|
// templ: version: v0.3.1001
|
||||||
|
package templates
|
||||||
|
|
||||||
|
//lint:file-ignore SA4006 This context is only used if a nested component is present.
|
||||||
|
|
||||||
|
import "github.com/a-h/templ"
|
||||||
|
import templruntime "github.com/a-h/templ/runtime"
|
||||||
|
|
||||||
|
import "mal/internal/jikan"
|
||||||
|
import "mal/internal/database"
|
||||||
|
import "fmt"
|
||||||
|
|
||||||
|
type WatchingAnimeWithDetails struct {
|
||||||
|
Entry database.GetWatchingAnimeRow
|
||||||
|
Anime jikan.Anime
|
||||||
|
}
|
||||||
|
|
||||||
|
func Notifications(watching []WatchingAnimeWithDetails) templ.Component {
|
||||||
|
return templruntime.GeneratedTemplate(func(templ_7745c5c3_Input templruntime.GeneratedComponentInput) (templ_7745c5c3_Err error) {
|
||||||
|
templ_7745c5c3_W, ctx := templ_7745c5c3_Input.Writer, templ_7745c5c3_Input.Context
|
||||||
|
if templ_7745c5c3_CtxErr := ctx.Err(); templ_7745c5c3_CtxErr != nil {
|
||||||
|
return templ_7745c5c3_CtxErr
|
||||||
|
}
|
||||||
|
templ_7745c5c3_Buffer, templ_7745c5c3_IsBuffer := templruntime.GetBuffer(templ_7745c5c3_W)
|
||||||
|
if !templ_7745c5c3_IsBuffer {
|
||||||
|
defer func() {
|
||||||
|
templ_7745c5c3_BufErr := templruntime.ReleaseBuffer(templ_7745c5c3_Buffer)
|
||||||
|
if templ_7745c5c3_Err == nil {
|
||||||
|
templ_7745c5c3_Err = templ_7745c5c3_BufErr
|
||||||
|
}
|
||||||
|
}()
|
||||||
|
}
|
||||||
|
ctx = templ.InitializeContext(ctx)
|
||||||
|
templ_7745c5c3_Var1 := templ.GetChildren(ctx)
|
||||||
|
if templ_7745c5c3_Var1 == nil {
|
||||||
|
templ_7745c5c3_Var1 = templ.NopComponent
|
||||||
|
}
|
||||||
|
ctx = templ.ClearChildren(ctx)
|
||||||
|
templ_7745c5c3_Var2 := templruntime.GeneratedTemplate(func(templ_7745c5c3_Input templruntime.GeneratedComponentInput) (templ_7745c5c3_Err error) {
|
||||||
|
templ_7745c5c3_W, ctx := templ_7745c5c3_Input.Writer, templ_7745c5c3_Input.Context
|
||||||
|
templ_7745c5c3_Buffer, templ_7745c5c3_IsBuffer := templruntime.GetBuffer(templ_7745c5c3_W)
|
||||||
|
if !templ_7745c5c3_IsBuffer {
|
||||||
|
defer func() {
|
||||||
|
templ_7745c5c3_BufErr := templruntime.ReleaseBuffer(templ_7745c5c3_Buffer)
|
||||||
|
if templ_7745c5c3_Err == nil {
|
||||||
|
templ_7745c5c3_Err = templ_7745c5c3_BufErr
|
||||||
|
}
|
||||||
|
}()
|
||||||
|
}
|
||||||
|
ctx = templ.InitializeContext(ctx)
|
||||||
|
templ_7745c5c3_Err = templruntime.WriteString(templ_7745c5c3_Buffer, 1, "<div class=\"notifications-page\"><h1>upcoming episodes</h1><p class=\"notifications-subtitle\">anime you're watching</p>")
|
||||||
|
if templ_7745c5c3_Err != nil {
|
||||||
|
return templ_7745c5c3_Err
|
||||||
|
}
|
||||||
|
if len(watching) == 0 {
|
||||||
|
templ_7745c5c3_Err = templruntime.WriteString(templ_7745c5c3_Buffer, 2, "<div class=\"no-notifications\"><p>no airing anime in your watching list</p><p class=\"hint\">add currently airing shows to your watching list to see upcoming episodes here</p></div>")
|
||||||
|
if templ_7745c5c3_Err != nil {
|
||||||
|
return templ_7745c5c3_Err
|
||||||
|
}
|
||||||
|
} else {
|
||||||
|
templ_7745c5c3_Err = templruntime.WriteString(templ_7745c5c3_Buffer, 3, "<div class=\"notifications-list\">")
|
||||||
|
if templ_7745c5c3_Err != nil {
|
||||||
|
return templ_7745c5c3_Err
|
||||||
|
}
|
||||||
|
for _, item := range watching {
|
||||||
|
templ_7745c5c3_Err = NotificationCard(item).Render(ctx, templ_7745c5c3_Buffer)
|
||||||
|
if templ_7745c5c3_Err != nil {
|
||||||
|
return templ_7745c5c3_Err
|
||||||
|
}
|
||||||
|
}
|
||||||
|
templ_7745c5c3_Err = templruntime.WriteString(templ_7745c5c3_Buffer, 4, "</div>")
|
||||||
|
if templ_7745c5c3_Err != nil {
|
||||||
|
return templ_7745c5c3_Err
|
||||||
|
}
|
||||||
|
}
|
||||||
|
templ_7745c5c3_Err = templruntime.WriteString(templ_7745c5c3_Buffer, 5, "</div>")
|
||||||
|
if templ_7745c5c3_Err != nil {
|
||||||
|
return templ_7745c5c3_Err
|
||||||
|
}
|
||||||
|
return nil
|
||||||
|
})
|
||||||
|
templ_7745c5c3_Err = Layout("mal - notifications").Render(templ.WithChildren(ctx, templ_7745c5c3_Var2), templ_7745c5c3_Buffer)
|
||||||
|
if templ_7745c5c3_Err != nil {
|
||||||
|
return templ_7745c5c3_Err
|
||||||
|
}
|
||||||
|
return nil
|
||||||
|
})
|
||||||
|
}
|
||||||
|
|
||||||
|
func NotificationCard(item WatchingAnimeWithDetails) templ.Component {
|
||||||
|
return templruntime.GeneratedTemplate(func(templ_7745c5c3_Input templruntime.GeneratedComponentInput) (templ_7745c5c3_Err error) {
|
||||||
|
templ_7745c5c3_W, ctx := templ_7745c5c3_Input.Writer, templ_7745c5c3_Input.Context
|
||||||
|
if templ_7745c5c3_CtxErr := ctx.Err(); templ_7745c5c3_CtxErr != nil {
|
||||||
|
return templ_7745c5c3_CtxErr
|
||||||
|
}
|
||||||
|
templ_7745c5c3_Buffer, templ_7745c5c3_IsBuffer := templruntime.GetBuffer(templ_7745c5c3_W)
|
||||||
|
if !templ_7745c5c3_IsBuffer {
|
||||||
|
defer func() {
|
||||||
|
templ_7745c5c3_BufErr := templruntime.ReleaseBuffer(templ_7745c5c3_Buffer)
|
||||||
|
if templ_7745c5c3_Err == nil {
|
||||||
|
templ_7745c5c3_Err = templ_7745c5c3_BufErr
|
||||||
|
}
|
||||||
|
}()
|
||||||
|
}
|
||||||
|
ctx = templ.InitializeContext(ctx)
|
||||||
|
templ_7745c5c3_Var3 := templ.GetChildren(ctx)
|
||||||
|
if templ_7745c5c3_Var3 == nil {
|
||||||
|
templ_7745c5c3_Var3 = templ.NopComponent
|
||||||
|
}
|
||||||
|
ctx = templ.ClearChildren(ctx)
|
||||||
|
templ_7745c5c3_Err = templruntime.WriteString(templ_7745c5c3_Buffer, 6, "<div class=\"notification-card\"><a href=\"")
|
||||||
|
if templ_7745c5c3_Err != nil {
|
||||||
|
return templ_7745c5c3_Err
|
||||||
|
}
|
||||||
|
var templ_7745c5c3_Var4 templ.SafeURL
|
||||||
|
templ_7745c5c3_Var4, templ_7745c5c3_Err = templ.JoinURLErrs(templ.URL(fmt.Sprintf("/anime/%d", item.Entry.AnimeID)))
|
||||||
|
if templ_7745c5c3_Err != nil {
|
||||||
|
return templ.Error{Err: templ_7745c5c3_Err, FileName: `internal/templates/notifications.templ`, Line: 36, Col: 67}
|
||||||
|
}
|
||||||
|
_, templ_7745c5c3_Err = templ_7745c5c3_Buffer.WriteString(templ.EscapeString(templ_7745c5c3_Var4))
|
||||||
|
if templ_7745c5c3_Err != nil {
|
||||||
|
return templ_7745c5c3_Err
|
||||||
|
}
|
||||||
|
templ_7745c5c3_Err = templruntime.WriteString(templ_7745c5c3_Buffer, 7, "\" class=\"notification-image\">")
|
||||||
|
if templ_7745c5c3_Err != nil {
|
||||||
|
return templ_7745c5c3_Err
|
||||||
|
}
|
||||||
|
if item.Entry.ImageUrl != "" {
|
||||||
|
templ_7745c5c3_Err = templruntime.WriteString(templ_7745c5c3_Buffer, 8, "<img src=\"")
|
||||||
|
if templ_7745c5c3_Err != nil {
|
||||||
|
return templ_7745c5c3_Err
|
||||||
|
}
|
||||||
|
var templ_7745c5c3_Var5 string
|
||||||
|
templ_7745c5c3_Var5, templ_7745c5c3_Err = templ.JoinStringErrs(item.Entry.ImageUrl)
|
||||||
|
if templ_7745c5c3_Err != nil {
|
||||||
|
return templ.Error{Err: templ_7745c5c3_Err, FileName: `internal/templates/notifications.templ`, Line: 38, Col: 34}
|
||||||
|
}
|
||||||
|
_, templ_7745c5c3_Err = templ_7745c5c3_Buffer.WriteString(templ.EscapeString(templ_7745c5c3_Var5))
|
||||||
|
if templ_7745c5c3_Err != nil {
|
||||||
|
return templ_7745c5c3_Err
|
||||||
|
}
|
||||||
|
templ_7745c5c3_Err = templruntime.WriteString(templ_7745c5c3_Buffer, 9, "\" alt=\"")
|
||||||
|
if templ_7745c5c3_Err != nil {
|
||||||
|
return templ_7745c5c3_Err
|
||||||
|
}
|
||||||
|
var templ_7745c5c3_Var6 string
|
||||||
|
templ_7745c5c3_Var6, templ_7745c5c3_Err = templ.JoinStringErrs(displayTitle(item.Entry))
|
||||||
|
if templ_7745c5c3_Err != nil {
|
||||||
|
return templ.Error{Err: templ_7745c5c3_Err, FileName: `internal/templates/notifications.templ`, Line: 38, Col: 67}
|
||||||
|
}
|
||||||
|
_, templ_7745c5c3_Err = templ_7745c5c3_Buffer.WriteString(templ.EscapeString(templ_7745c5c3_Var6))
|
||||||
|
if templ_7745c5c3_Err != nil {
|
||||||
|
return templ_7745c5c3_Err
|
||||||
|
}
|
||||||
|
templ_7745c5c3_Err = templruntime.WriteString(templ_7745c5c3_Buffer, 10, "\" loading=\"lazy\">")
|
||||||
|
if templ_7745c5c3_Err != nil {
|
||||||
|
return templ_7745c5c3_Err
|
||||||
|
}
|
||||||
|
} else {
|
||||||
|
templ_7745c5c3_Err = templruntime.WriteString(templ_7745c5c3_Buffer, 11, "<div class=\"no-image\">no image</div>")
|
||||||
|
if templ_7745c5c3_Err != nil {
|
||||||
|
return templ_7745c5c3_Err
|
||||||
|
}
|
||||||
|
}
|
||||||
|
templ_7745c5c3_Err = templruntime.WriteString(templ_7745c5c3_Buffer, 12, "</a><div class=\"notification-content\"><a href=\"")
|
||||||
|
if templ_7745c5c3_Err != nil {
|
||||||
|
return templ_7745c5c3_Err
|
||||||
|
}
|
||||||
|
var templ_7745c5c3_Var7 templ.SafeURL
|
||||||
|
templ_7745c5c3_Var7, templ_7745c5c3_Err = templ.JoinURLErrs(templ.URL(fmt.Sprintf("/anime/%d", item.Entry.AnimeID)))
|
||||||
|
if templ_7745c5c3_Err != nil {
|
||||||
|
return templ.Error{Err: templ_7745c5c3_Err, FileName: `internal/templates/notifications.templ`, Line: 44, Col: 68}
|
||||||
|
}
|
||||||
|
_, templ_7745c5c3_Err = templ_7745c5c3_Buffer.WriteString(templ.EscapeString(templ_7745c5c3_Var7))
|
||||||
|
if templ_7745c5c3_Err != nil {
|
||||||
|
return templ_7745c5c3_Err
|
||||||
|
}
|
||||||
|
templ_7745c5c3_Err = templruntime.WriteString(templ_7745c5c3_Buffer, 13, "\" class=\"notification-title\">")
|
||||||
|
if templ_7745c5c3_Err != nil {
|
||||||
|
return templ_7745c5c3_Err
|
||||||
|
}
|
||||||
|
var templ_7745c5c3_Var8 string
|
||||||
|
templ_7745c5c3_Var8, templ_7745c5c3_Err = templ.JoinStringErrs(displayTitle(item.Entry))
|
||||||
|
if templ_7745c5c3_Err != nil {
|
||||||
|
return templ.Error{Err: templ_7745c5c3_Err, FileName: `internal/templates/notifications.templ`, Line: 45, Col: 30}
|
||||||
|
}
|
||||||
|
_, templ_7745c5c3_Err = templ_7745c5c3_Buffer.WriteString(templ.EscapeString(templ_7745c5c3_Var8))
|
||||||
|
if templ_7745c5c3_Err != nil {
|
||||||
|
return templ_7745c5c3_Err
|
||||||
|
}
|
||||||
|
templ_7745c5c3_Err = templruntime.WriteString(templ_7745c5c3_Buffer, 14, "</a><div class=\"notification-meta\">")
|
||||||
|
if templ_7745c5c3_Err != nil {
|
||||||
|
return templ_7745c5c3_Err
|
||||||
|
}
|
||||||
|
if item.Anime.Broadcast.String != "" {
|
||||||
|
templ_7745c5c3_Err = templruntime.WriteString(templ_7745c5c3_Buffer, 15, "<span class=\"notification-broadcast\">")
|
||||||
|
if templ_7745c5c3_Err != nil {
|
||||||
|
return templ_7745c5c3_Err
|
||||||
|
}
|
||||||
|
var templ_7745c5c3_Var9 string
|
||||||
|
templ_7745c5c3_Var9, templ_7745c5c3_Err = templ.JoinStringErrs(item.Anime.Broadcast.String)
|
||||||
|
if templ_7745c5c3_Err != nil {
|
||||||
|
return templ.Error{Err: templ_7745c5c3_Err, FileName: `internal/templates/notifications.templ`, Line: 49, Col: 71}
|
||||||
|
}
|
||||||
|
_, templ_7745c5c3_Err = templ_7745c5c3_Buffer.WriteString(templ.EscapeString(templ_7745c5c3_Var9))
|
||||||
|
if templ_7745c5c3_Err != nil {
|
||||||
|
return templ_7745c5c3_Err
|
||||||
|
}
|
||||||
|
templ_7745c5c3_Err = templruntime.WriteString(templ_7745c5c3_Buffer, 16, "</span> ")
|
||||||
|
if templ_7745c5c3_Err != nil {
|
||||||
|
return templ_7745c5c3_Err
|
||||||
|
}
|
||||||
|
}
|
||||||
|
if item.Anime.Episodes > 0 {
|
||||||
|
templ_7745c5c3_Err = templruntime.WriteString(templ_7745c5c3_Buffer, 17, "<span class=\"notification-progress\">")
|
||||||
|
if templ_7745c5c3_Err != nil {
|
||||||
|
return templ_7745c5c3_Err
|
||||||
|
}
|
||||||
|
if item.Entry.CurrentEpisode.Valid {
|
||||||
|
var templ_7745c5c3_Var10 string
|
||||||
|
templ_7745c5c3_Var10, templ_7745c5c3_Err = templ.JoinStringErrs(fmt.Sprintf("%d / %d episodes", item.Entry.CurrentEpisode.Int64, item.Anime.Episodes))
|
||||||
|
if templ_7745c5c3_Err != nil {
|
||||||
|
return templ.Error{Err: templ_7745c5c3_Err, FileName: `internal/templates/notifications.templ`, Line: 54, Col: 94}
|
||||||
|
}
|
||||||
|
_, templ_7745c5c3_Err = templ_7745c5c3_Buffer.WriteString(templ.EscapeString(templ_7745c5c3_Var10))
|
||||||
|
if templ_7745c5c3_Err != nil {
|
||||||
|
return templ_7745c5c3_Err
|
||||||
|
}
|
||||||
|
} else {
|
||||||
|
var templ_7745c5c3_Var11 string
|
||||||
|
templ_7745c5c3_Var11, templ_7745c5c3_Err = templ.JoinStringErrs(fmt.Sprintf("0 / %d episodes", item.Anime.Episodes))
|
||||||
|
if templ_7745c5c3_Err != nil {
|
||||||
|
return templ.Error{Err: templ_7745c5c3_Err, FileName: `internal/templates/notifications.templ`, Line: 56, Col: 60}
|
||||||
|
}
|
||||||
|
_, templ_7745c5c3_Err = templ_7745c5c3_Buffer.WriteString(templ.EscapeString(templ_7745c5c3_Var11))
|
||||||
|
if templ_7745c5c3_Err != nil {
|
||||||
|
return templ_7745c5c3_Err
|
||||||
|
}
|
||||||
|
}
|
||||||
|
templ_7745c5c3_Err = templruntime.WriteString(templ_7745c5c3_Buffer, 18, "</span>")
|
||||||
|
if templ_7745c5c3_Err != nil {
|
||||||
|
return templ_7745c5c3_Err
|
||||||
|
}
|
||||||
|
} else if item.Entry.CurrentEpisode.Valid && item.Entry.CurrentEpisode.Int64 > 0 {
|
||||||
|
templ_7745c5c3_Err = templruntime.WriteString(templ_7745c5c3_Buffer, 19, "<span class=\"notification-progress\">")
|
||||||
|
if templ_7745c5c3_Err != nil {
|
||||||
|
return templ_7745c5c3_Err
|
||||||
|
}
|
||||||
|
var templ_7745c5c3_Var12 string
|
||||||
|
templ_7745c5c3_Var12, templ_7745c5c3_Err = templ.JoinStringErrs(fmt.Sprintf("%d episodes watched", item.Entry.CurrentEpisode.Int64))
|
||||||
|
if templ_7745c5c3_Err != nil {
|
||||||
|
return templ.Error{Err: templ_7745c5c3_Err, FileName: `internal/templates/notifications.templ`, Line: 61, Col: 75}
|
||||||
|
}
|
||||||
|
_, templ_7745c5c3_Err = templ_7745c5c3_Buffer.WriteString(templ.EscapeString(templ_7745c5c3_Var12))
|
||||||
|
if templ_7745c5c3_Err != nil {
|
||||||
|
return templ_7745c5c3_Err
|
||||||
|
}
|
||||||
|
templ_7745c5c3_Err = templruntime.WriteString(templ_7745c5c3_Buffer, 20, "</span>")
|
||||||
|
if templ_7745c5c3_Err != nil {
|
||||||
|
return templ_7745c5c3_Err
|
||||||
|
}
|
||||||
|
}
|
||||||
|
templ_7745c5c3_Err = templruntime.WriteString(templ_7745c5c3_Buffer, 21, "</div>")
|
||||||
|
if templ_7745c5c3_Err != nil {
|
||||||
|
return templ_7745c5c3_Err
|
||||||
|
}
|
||||||
|
if item.Anime.Synopsis != "" {
|
||||||
|
templ_7745c5c3_Err = templruntime.WriteString(templ_7745c5c3_Buffer, 22, "<p class=\"notification-synopsis\">")
|
||||||
|
if templ_7745c5c3_Err != nil {
|
||||||
|
return templ_7745c5c3_Err
|
||||||
|
}
|
||||||
|
var templ_7745c5c3_Var13 string
|
||||||
|
templ_7745c5c3_Var13, templ_7745c5c3_Err = templ.JoinStringErrs(truncate(item.Anime.Synopsis, 150))
|
||||||
|
if templ_7745c5c3_Err != nil {
|
||||||
|
return templ.Error{Err: templ_7745c5c3_Err, FileName: `internal/templates/notifications.templ`, Line: 66, Col: 73}
|
||||||
|
}
|
||||||
|
_, templ_7745c5c3_Err = templ_7745c5c3_Buffer.WriteString(templ.EscapeString(templ_7745c5c3_Var13))
|
||||||
|
if templ_7745c5c3_Err != nil {
|
||||||
|
return templ_7745c5c3_Err
|
||||||
|
}
|
||||||
|
templ_7745c5c3_Err = templruntime.WriteString(templ_7745c5c3_Buffer, 23, "</p>")
|
||||||
|
if templ_7745c5c3_Err != nil {
|
||||||
|
return templ_7745c5c3_Err
|
||||||
|
}
|
||||||
|
}
|
||||||
|
templ_7745c5c3_Err = templruntime.WriteString(templ_7745c5c3_Buffer, 24, "</div></div>")
|
||||||
|
if templ_7745c5c3_Err != nil {
|
||||||
|
return templ_7745c5c3_Err
|
||||||
|
}
|
||||||
|
return nil
|
||||||
|
})
|
||||||
|
}
|
||||||
|
|
||||||
|
func displayTitle(entry database.GetWatchingAnimeRow) string {
|
||||||
|
if entry.TitleEnglish.Valid && entry.TitleEnglish.String != "" {
|
||||||
|
return entry.TitleEnglish.String
|
||||||
|
}
|
||||||
|
if entry.TitleJapanese.Valid && entry.TitleJapanese.String != "" {
|
||||||
|
return entry.TitleJapanese.String
|
||||||
|
}
|
||||||
|
return entry.TitleOriginal
|
||||||
|
}
|
||||||
|
|
||||||
|
func truncate(s string, max int) string {
|
||||||
|
if len(s) <= max {
|
||||||
|
return s
|
||||||
|
}
|
||||||
|
return s[:max-3] + "..."
|
||||||
|
}
|
||||||
|
|
||||||
|
var _ = templruntime.GeneratedTemplate
|
||||||
85
internal/templates/schedule.templ
Normal file
85
internal/templates/schedule.templ
Normal file
@@ -0,0 +1,85 @@
|
|||||||
|
package templates
|
||||||
|
|
||||||
|
import "mal/internal/jikan"
|
||||||
|
import "fmt"
|
||||||
|
import "strings"
|
||||||
|
|
||||||
|
templ Schedule() {
|
||||||
|
@Layout("mal - schedule") {
|
||||||
|
<div class="schedule-page">
|
||||||
|
<h1>weekly schedule</h1>
|
||||||
|
<p class="schedule-subtitle">airing times in JST</p>
|
||||||
|
|
||||||
|
<div class="schedule-tabs">
|
||||||
|
<button class="schedule-tab active" data-day="monday" onclick="loadDay('monday', this)">mon</button>
|
||||||
|
<button class="schedule-tab" data-day="tuesday" onclick="loadDay('tuesday', this)">tue</button>
|
||||||
|
<button class="schedule-tab" data-day="wednesday" onclick="loadDay('wednesday', this)">wed</button>
|
||||||
|
<button class="schedule-tab" data-day="thursday" onclick="loadDay('thursday', this)">thu</button>
|
||||||
|
<button class="schedule-tab" data-day="friday" onclick="loadDay('friday', this)">fri</button>
|
||||||
|
<button class="schedule-tab" data-day="saturday" onclick="loadDay('saturday', this)">sat</button>
|
||||||
|
<button class="schedule-tab" data-day="sunday" onclick="loadDay('sunday', this)">sun</button>
|
||||||
|
</div>
|
||||||
|
|
||||||
|
<div id="schedule-content" hx-get="/api/schedule?day=monday" hx-trigger="load">
|
||||||
|
<div class="loading-indicator">
|
||||||
|
<div class="loading-dot"></div>
|
||||||
|
<div class="loading-dot"></div>
|
||||||
|
<div class="loading-dot"></div>
|
||||||
|
<span>loading schedule</span>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
|
||||||
|
<script>
|
||||||
|
function loadDay(day, btn) {
|
||||||
|
document.querySelectorAll('.schedule-tab').forEach(t => t.classList.remove('active'));
|
||||||
|
btn.classList.add('active');
|
||||||
|
htmx.ajax('GET', '/api/schedule?day=' + day, '#schedule-content');
|
||||||
|
}
|
||||||
|
</script>
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
templ ScheduleDay(day string, animes []jikan.Anime) {
|
||||||
|
<div class="schedule-day">
|
||||||
|
<h2>{ strings.Title(day) }</h2>
|
||||||
|
if len(animes) == 0 {
|
||||||
|
<p class="no-anime">no anime scheduled</p>
|
||||||
|
} else {
|
||||||
|
<div class="schedule-grid">
|
||||||
|
for _, anime := range animes {
|
||||||
|
@ScheduleAnimeCard(anime)
|
||||||
|
}
|
||||||
|
</div>
|
||||||
|
}
|
||||||
|
</div>
|
||||||
|
}
|
||||||
|
|
||||||
|
templ ScheduleAnimeCard(anime jikan.Anime) {
|
||||||
|
<a href={ templ.URL(fmt.Sprintf("/anime/%d", anime.MalID)) } class="schedule-card">
|
||||||
|
<div class="schedule-card-image">
|
||||||
|
if anime.ImageURL() != "" {
|
||||||
|
<img src={ anime.ImageURL() } alt={ anime.DisplayTitle() } loading="lazy"/>
|
||||||
|
} else {
|
||||||
|
<div class="no-image">no image</div>
|
||||||
|
}
|
||||||
|
</div>
|
||||||
|
<div class="schedule-card-info">
|
||||||
|
<div class="schedule-card-title">{ anime.DisplayTitle() }</div>
|
||||||
|
<div class="schedule-card-meta">
|
||||||
|
if anime.Broadcast.Time != "" {
|
||||||
|
<span class="schedule-time">{ anime.Broadcast.Time }</span>
|
||||||
|
}
|
||||||
|
if anime.Type != "" {
|
||||||
|
<span class="schedule-type">{ anime.Type }</span>
|
||||||
|
}
|
||||||
|
if anime.Episodes > 0 {
|
||||||
|
<span class="schedule-eps">{ fmt.Sprintf("%d ep", anime.Episodes) }</span>
|
||||||
|
}
|
||||||
|
</div>
|
||||||
|
if anime.Score > 0 {
|
||||||
|
<div class="schedule-card-score">★ { fmt.Sprintf("%.1f", anime.Score) }</div>
|
||||||
|
}
|
||||||
|
</div>
|
||||||
|
</a>
|
||||||
|
}
|
||||||
309
internal/templates/schedule_templ.go
Normal file
309
internal/templates/schedule_templ.go
Normal file
@@ -0,0 +1,309 @@
|
|||||||
|
// Code generated by templ - DO NOT EDIT.
|
||||||
|
|
||||||
|
// templ: version: v0.3.1001
|
||||||
|
package templates
|
||||||
|
|
||||||
|
//lint:file-ignore SA4006 This context is only used if a nested component is present.
|
||||||
|
|
||||||
|
import "github.com/a-h/templ"
|
||||||
|
import templruntime "github.com/a-h/templ/runtime"
|
||||||
|
|
||||||
|
import "mal/internal/jikan"
|
||||||
|
import "fmt"
|
||||||
|
import "strings"
|
||||||
|
|
||||||
|
func Schedule() templ.Component {
|
||||||
|
return templruntime.GeneratedTemplate(func(templ_7745c5c3_Input templruntime.GeneratedComponentInput) (templ_7745c5c3_Err error) {
|
||||||
|
templ_7745c5c3_W, ctx := templ_7745c5c3_Input.Writer, templ_7745c5c3_Input.Context
|
||||||
|
if templ_7745c5c3_CtxErr := ctx.Err(); templ_7745c5c3_CtxErr != nil {
|
||||||
|
return templ_7745c5c3_CtxErr
|
||||||
|
}
|
||||||
|
templ_7745c5c3_Buffer, templ_7745c5c3_IsBuffer := templruntime.GetBuffer(templ_7745c5c3_W)
|
||||||
|
if !templ_7745c5c3_IsBuffer {
|
||||||
|
defer func() {
|
||||||
|
templ_7745c5c3_BufErr := templruntime.ReleaseBuffer(templ_7745c5c3_Buffer)
|
||||||
|
if templ_7745c5c3_Err == nil {
|
||||||
|
templ_7745c5c3_Err = templ_7745c5c3_BufErr
|
||||||
|
}
|
||||||
|
}()
|
||||||
|
}
|
||||||
|
ctx = templ.InitializeContext(ctx)
|
||||||
|
templ_7745c5c3_Var1 := templ.GetChildren(ctx)
|
||||||
|
if templ_7745c5c3_Var1 == nil {
|
||||||
|
templ_7745c5c3_Var1 = templ.NopComponent
|
||||||
|
}
|
||||||
|
ctx = templ.ClearChildren(ctx)
|
||||||
|
templ_7745c5c3_Var2 := templruntime.GeneratedTemplate(func(templ_7745c5c3_Input templruntime.GeneratedComponentInput) (templ_7745c5c3_Err error) {
|
||||||
|
templ_7745c5c3_W, ctx := templ_7745c5c3_Input.Writer, templ_7745c5c3_Input.Context
|
||||||
|
templ_7745c5c3_Buffer, templ_7745c5c3_IsBuffer := templruntime.GetBuffer(templ_7745c5c3_W)
|
||||||
|
if !templ_7745c5c3_IsBuffer {
|
||||||
|
defer func() {
|
||||||
|
templ_7745c5c3_BufErr := templruntime.ReleaseBuffer(templ_7745c5c3_Buffer)
|
||||||
|
if templ_7745c5c3_Err == nil {
|
||||||
|
templ_7745c5c3_Err = templ_7745c5c3_BufErr
|
||||||
|
}
|
||||||
|
}()
|
||||||
|
}
|
||||||
|
ctx = templ.InitializeContext(ctx)
|
||||||
|
templ_7745c5c3_Err = templruntime.WriteString(templ_7745c5c3_Buffer, 1, "<div class=\"schedule-page\"><h1>weekly schedule</h1><p class=\"schedule-subtitle\">airing times in JST</p><div class=\"schedule-tabs\"><button class=\"schedule-tab active\" data-day=\"monday\" onclick=\"loadDay('monday', this)\">mon</button> <button class=\"schedule-tab\" data-day=\"tuesday\" onclick=\"loadDay('tuesday', this)\">tue</button> <button class=\"schedule-tab\" data-day=\"wednesday\" onclick=\"loadDay('wednesday', this)\">wed</button> <button class=\"schedule-tab\" data-day=\"thursday\" onclick=\"loadDay('thursday', this)\">thu</button> <button class=\"schedule-tab\" data-day=\"friday\" onclick=\"loadDay('friday', this)\">fri</button> <button class=\"schedule-tab\" data-day=\"saturday\" onclick=\"loadDay('saturday', this)\">sat</button> <button class=\"schedule-tab\" data-day=\"sunday\" onclick=\"loadDay('sunday', this)\">sun</button></div><div id=\"schedule-content\" hx-get=\"/api/schedule?day=monday\" hx-trigger=\"load\"><div class=\"loading-indicator\"><div class=\"loading-dot\"></div><div class=\"loading-dot\"></div><div class=\"loading-dot\"></div><span>loading schedule</span></div></div></div><script>\n\t\t\tfunction loadDay(day, btn) {\n\t\t\t\tdocument.querySelectorAll('.schedule-tab').forEach(t => t.classList.remove('active'));\n\t\t\t\tbtn.classList.add('active');\n\t\t\t\thtmx.ajax('GET', '/api/schedule?day=' + day, '#schedule-content');\n\t\t\t}\n\t\t</script>")
|
||||||
|
if templ_7745c5c3_Err != nil {
|
||||||
|
return templ_7745c5c3_Err
|
||||||
|
}
|
||||||
|
return nil
|
||||||
|
})
|
||||||
|
templ_7745c5c3_Err = Layout("mal - schedule").Render(templ.WithChildren(ctx, templ_7745c5c3_Var2), templ_7745c5c3_Buffer)
|
||||||
|
if templ_7745c5c3_Err != nil {
|
||||||
|
return templ_7745c5c3_Err
|
||||||
|
}
|
||||||
|
return nil
|
||||||
|
})
|
||||||
|
}
|
||||||
|
|
||||||
|
func ScheduleDay(day string, animes []jikan.Anime) templ.Component {
|
||||||
|
return templruntime.GeneratedTemplate(func(templ_7745c5c3_Input templruntime.GeneratedComponentInput) (templ_7745c5c3_Err error) {
|
||||||
|
templ_7745c5c3_W, ctx := templ_7745c5c3_Input.Writer, templ_7745c5c3_Input.Context
|
||||||
|
if templ_7745c5c3_CtxErr := ctx.Err(); templ_7745c5c3_CtxErr != nil {
|
||||||
|
return templ_7745c5c3_CtxErr
|
||||||
|
}
|
||||||
|
templ_7745c5c3_Buffer, templ_7745c5c3_IsBuffer := templruntime.GetBuffer(templ_7745c5c3_W)
|
||||||
|
if !templ_7745c5c3_IsBuffer {
|
||||||
|
defer func() {
|
||||||
|
templ_7745c5c3_BufErr := templruntime.ReleaseBuffer(templ_7745c5c3_Buffer)
|
||||||
|
if templ_7745c5c3_Err == nil {
|
||||||
|
templ_7745c5c3_Err = templ_7745c5c3_BufErr
|
||||||
|
}
|
||||||
|
}()
|
||||||
|
}
|
||||||
|
ctx = templ.InitializeContext(ctx)
|
||||||
|
templ_7745c5c3_Var3 := templ.GetChildren(ctx)
|
||||||
|
if templ_7745c5c3_Var3 == nil {
|
||||||
|
templ_7745c5c3_Var3 = templ.NopComponent
|
||||||
|
}
|
||||||
|
ctx = templ.ClearChildren(ctx)
|
||||||
|
templ_7745c5c3_Err = templruntime.WriteString(templ_7745c5c3_Buffer, 2, "<div class=\"schedule-day\"><h2>")
|
||||||
|
if templ_7745c5c3_Err != nil {
|
||||||
|
return templ_7745c5c3_Err
|
||||||
|
}
|
||||||
|
var templ_7745c5c3_Var4 string
|
||||||
|
templ_7745c5c3_Var4, templ_7745c5c3_Err = templ.JoinStringErrs(strings.Title(day))
|
||||||
|
if templ_7745c5c3_Err != nil {
|
||||||
|
return templ.Error{Err: templ_7745c5c3_Err, FileName: `internal/templates/schedule.templ`, Line: 45, Col: 26}
|
||||||
|
}
|
||||||
|
_, templ_7745c5c3_Err = templ_7745c5c3_Buffer.WriteString(templ.EscapeString(templ_7745c5c3_Var4))
|
||||||
|
if templ_7745c5c3_Err != nil {
|
||||||
|
return templ_7745c5c3_Err
|
||||||
|
}
|
||||||
|
templ_7745c5c3_Err = templruntime.WriteString(templ_7745c5c3_Buffer, 3, "</h2>")
|
||||||
|
if templ_7745c5c3_Err != nil {
|
||||||
|
return templ_7745c5c3_Err
|
||||||
|
}
|
||||||
|
if len(animes) == 0 {
|
||||||
|
templ_7745c5c3_Err = templruntime.WriteString(templ_7745c5c3_Buffer, 4, "<p class=\"no-anime\">no anime scheduled</p>")
|
||||||
|
if templ_7745c5c3_Err != nil {
|
||||||
|
return templ_7745c5c3_Err
|
||||||
|
}
|
||||||
|
} else {
|
||||||
|
templ_7745c5c3_Err = templruntime.WriteString(templ_7745c5c3_Buffer, 5, "<div class=\"schedule-grid\">")
|
||||||
|
if templ_7745c5c3_Err != nil {
|
||||||
|
return templ_7745c5c3_Err
|
||||||
|
}
|
||||||
|
for _, anime := range animes {
|
||||||
|
templ_7745c5c3_Err = ScheduleAnimeCard(anime).Render(ctx, templ_7745c5c3_Buffer)
|
||||||
|
if templ_7745c5c3_Err != nil {
|
||||||
|
return templ_7745c5c3_Err
|
||||||
|
}
|
||||||
|
}
|
||||||
|
templ_7745c5c3_Err = templruntime.WriteString(templ_7745c5c3_Buffer, 6, "</div>")
|
||||||
|
if templ_7745c5c3_Err != nil {
|
||||||
|
return templ_7745c5c3_Err
|
||||||
|
}
|
||||||
|
}
|
||||||
|
templ_7745c5c3_Err = templruntime.WriteString(templ_7745c5c3_Buffer, 7, "</div>")
|
||||||
|
if templ_7745c5c3_Err != nil {
|
||||||
|
return templ_7745c5c3_Err
|
||||||
|
}
|
||||||
|
return nil
|
||||||
|
})
|
||||||
|
}
|
||||||
|
|
||||||
|
func ScheduleAnimeCard(anime jikan.Anime) templ.Component {
|
||||||
|
return templruntime.GeneratedTemplate(func(templ_7745c5c3_Input templruntime.GeneratedComponentInput) (templ_7745c5c3_Err error) {
|
||||||
|
templ_7745c5c3_W, ctx := templ_7745c5c3_Input.Writer, templ_7745c5c3_Input.Context
|
||||||
|
if templ_7745c5c3_CtxErr := ctx.Err(); templ_7745c5c3_CtxErr != nil {
|
||||||
|
return templ_7745c5c3_CtxErr
|
||||||
|
}
|
||||||
|
templ_7745c5c3_Buffer, templ_7745c5c3_IsBuffer := templruntime.GetBuffer(templ_7745c5c3_W)
|
||||||
|
if !templ_7745c5c3_IsBuffer {
|
||||||
|
defer func() {
|
||||||
|
templ_7745c5c3_BufErr := templruntime.ReleaseBuffer(templ_7745c5c3_Buffer)
|
||||||
|
if templ_7745c5c3_Err == nil {
|
||||||
|
templ_7745c5c3_Err = templ_7745c5c3_BufErr
|
||||||
|
}
|
||||||
|
}()
|
||||||
|
}
|
||||||
|
ctx = templ.InitializeContext(ctx)
|
||||||
|
templ_7745c5c3_Var5 := templ.GetChildren(ctx)
|
||||||
|
if templ_7745c5c3_Var5 == nil {
|
||||||
|
templ_7745c5c3_Var5 = templ.NopComponent
|
||||||
|
}
|
||||||
|
ctx = templ.ClearChildren(ctx)
|
||||||
|
templ_7745c5c3_Err = templruntime.WriteString(templ_7745c5c3_Buffer, 8, "<a href=\"")
|
||||||
|
if templ_7745c5c3_Err != nil {
|
||||||
|
return templ_7745c5c3_Err
|
||||||
|
}
|
||||||
|
var templ_7745c5c3_Var6 templ.SafeURL
|
||||||
|
templ_7745c5c3_Var6, templ_7745c5c3_Err = templ.JoinURLErrs(templ.URL(fmt.Sprintf("/anime/%d", anime.MalID)))
|
||||||
|
if templ_7745c5c3_Err != nil {
|
||||||
|
return templ.Error{Err: templ_7745c5c3_Err, FileName: `internal/templates/schedule.templ`, Line: 59, Col: 59}
|
||||||
|
}
|
||||||
|
_, templ_7745c5c3_Err = templ_7745c5c3_Buffer.WriteString(templ.EscapeString(templ_7745c5c3_Var6))
|
||||||
|
if templ_7745c5c3_Err != nil {
|
||||||
|
return templ_7745c5c3_Err
|
||||||
|
}
|
||||||
|
templ_7745c5c3_Err = templruntime.WriteString(templ_7745c5c3_Buffer, 9, "\" class=\"schedule-card\"><div class=\"schedule-card-image\">")
|
||||||
|
if templ_7745c5c3_Err != nil {
|
||||||
|
return templ_7745c5c3_Err
|
||||||
|
}
|
||||||
|
if anime.ImageURL() != "" {
|
||||||
|
templ_7745c5c3_Err = templruntime.WriteString(templ_7745c5c3_Buffer, 10, "<img src=\"")
|
||||||
|
if templ_7745c5c3_Err != nil {
|
||||||
|
return templ_7745c5c3_Err
|
||||||
|
}
|
||||||
|
var templ_7745c5c3_Var7 string
|
||||||
|
templ_7745c5c3_Var7, templ_7745c5c3_Err = templ.JoinStringErrs(anime.ImageURL())
|
||||||
|
if templ_7745c5c3_Err != nil {
|
||||||
|
return templ.Error{Err: templ_7745c5c3_Err, FileName: `internal/templates/schedule.templ`, Line: 62, Col: 31}
|
||||||
|
}
|
||||||
|
_, templ_7745c5c3_Err = templ_7745c5c3_Buffer.WriteString(templ.EscapeString(templ_7745c5c3_Var7))
|
||||||
|
if templ_7745c5c3_Err != nil {
|
||||||
|
return templ_7745c5c3_Err
|
||||||
|
}
|
||||||
|
templ_7745c5c3_Err = templruntime.WriteString(templ_7745c5c3_Buffer, 11, "\" alt=\"")
|
||||||
|
if templ_7745c5c3_Err != nil {
|
||||||
|
return templ_7745c5c3_Err
|
||||||
|
}
|
||||||
|
var templ_7745c5c3_Var8 string
|
||||||
|
templ_7745c5c3_Var8, templ_7745c5c3_Err = templ.JoinStringErrs(anime.DisplayTitle())
|
||||||
|
if templ_7745c5c3_Err != nil {
|
||||||
|
return templ.Error{Err: templ_7745c5c3_Err, FileName: `internal/templates/schedule.templ`, Line: 62, Col: 60}
|
||||||
|
}
|
||||||
|
_, templ_7745c5c3_Err = templ_7745c5c3_Buffer.WriteString(templ.EscapeString(templ_7745c5c3_Var8))
|
||||||
|
if templ_7745c5c3_Err != nil {
|
||||||
|
return templ_7745c5c3_Err
|
||||||
|
}
|
||||||
|
templ_7745c5c3_Err = templruntime.WriteString(templ_7745c5c3_Buffer, 12, "\" loading=\"lazy\">")
|
||||||
|
if templ_7745c5c3_Err != nil {
|
||||||
|
return templ_7745c5c3_Err
|
||||||
|
}
|
||||||
|
} else {
|
||||||
|
templ_7745c5c3_Err = templruntime.WriteString(templ_7745c5c3_Buffer, 13, "<div class=\"no-image\">no image</div>")
|
||||||
|
if templ_7745c5c3_Err != nil {
|
||||||
|
return templ_7745c5c3_Err
|
||||||
|
}
|
||||||
|
}
|
||||||
|
templ_7745c5c3_Err = templruntime.WriteString(templ_7745c5c3_Buffer, 14, "</div><div class=\"schedule-card-info\"><div class=\"schedule-card-title\">")
|
||||||
|
if templ_7745c5c3_Err != nil {
|
||||||
|
return templ_7745c5c3_Err
|
||||||
|
}
|
||||||
|
var templ_7745c5c3_Var9 string
|
||||||
|
templ_7745c5c3_Var9, templ_7745c5c3_Err = templ.JoinStringErrs(anime.DisplayTitle())
|
||||||
|
if templ_7745c5c3_Err != nil {
|
||||||
|
return templ.Error{Err: templ_7745c5c3_Err, FileName: `internal/templates/schedule.templ`, Line: 68, Col: 58}
|
||||||
|
}
|
||||||
|
_, templ_7745c5c3_Err = templ_7745c5c3_Buffer.WriteString(templ.EscapeString(templ_7745c5c3_Var9))
|
||||||
|
if templ_7745c5c3_Err != nil {
|
||||||
|
return templ_7745c5c3_Err
|
||||||
|
}
|
||||||
|
templ_7745c5c3_Err = templruntime.WriteString(templ_7745c5c3_Buffer, 15, "</div><div class=\"schedule-card-meta\">")
|
||||||
|
if templ_7745c5c3_Err != nil {
|
||||||
|
return templ_7745c5c3_Err
|
||||||
|
}
|
||||||
|
if anime.Broadcast.Time != "" {
|
||||||
|
templ_7745c5c3_Err = templruntime.WriteString(templ_7745c5c3_Buffer, 16, "<span class=\"schedule-time\">")
|
||||||
|
if templ_7745c5c3_Err != nil {
|
||||||
|
return templ_7745c5c3_Err
|
||||||
|
}
|
||||||
|
var templ_7745c5c3_Var10 string
|
||||||
|
templ_7745c5c3_Var10, templ_7745c5c3_Err = templ.JoinStringErrs(anime.Broadcast.Time)
|
||||||
|
if templ_7745c5c3_Err != nil {
|
||||||
|
return templ.Error{Err: templ_7745c5c3_Err, FileName: `internal/templates/schedule.templ`, Line: 71, Col: 55}
|
||||||
|
}
|
||||||
|
_, templ_7745c5c3_Err = templ_7745c5c3_Buffer.WriteString(templ.EscapeString(templ_7745c5c3_Var10))
|
||||||
|
if templ_7745c5c3_Err != nil {
|
||||||
|
return templ_7745c5c3_Err
|
||||||
|
}
|
||||||
|
templ_7745c5c3_Err = templruntime.WriteString(templ_7745c5c3_Buffer, 17, "</span> ")
|
||||||
|
if templ_7745c5c3_Err != nil {
|
||||||
|
return templ_7745c5c3_Err
|
||||||
|
}
|
||||||
|
}
|
||||||
|
if anime.Type != "" {
|
||||||
|
templ_7745c5c3_Err = templruntime.WriteString(templ_7745c5c3_Buffer, 18, "<span class=\"schedule-type\">")
|
||||||
|
if templ_7745c5c3_Err != nil {
|
||||||
|
return templ_7745c5c3_Err
|
||||||
|
}
|
||||||
|
var templ_7745c5c3_Var11 string
|
||||||
|
templ_7745c5c3_Var11, templ_7745c5c3_Err = templ.JoinStringErrs(anime.Type)
|
||||||
|
if templ_7745c5c3_Err != nil {
|
||||||
|
return templ.Error{Err: templ_7745c5c3_Err, FileName: `internal/templates/schedule.templ`, Line: 74, Col: 45}
|
||||||
|
}
|
||||||
|
_, templ_7745c5c3_Err = templ_7745c5c3_Buffer.WriteString(templ.EscapeString(templ_7745c5c3_Var11))
|
||||||
|
if templ_7745c5c3_Err != nil {
|
||||||
|
return templ_7745c5c3_Err
|
||||||
|
}
|
||||||
|
templ_7745c5c3_Err = templruntime.WriteString(templ_7745c5c3_Buffer, 19, "</span> ")
|
||||||
|
if templ_7745c5c3_Err != nil {
|
||||||
|
return templ_7745c5c3_Err
|
||||||
|
}
|
||||||
|
}
|
||||||
|
if anime.Episodes > 0 {
|
||||||
|
templ_7745c5c3_Err = templruntime.WriteString(templ_7745c5c3_Buffer, 20, "<span class=\"schedule-eps\">")
|
||||||
|
if templ_7745c5c3_Err != nil {
|
||||||
|
return templ_7745c5c3_Err
|
||||||
|
}
|
||||||
|
var templ_7745c5c3_Var12 string
|
||||||
|
templ_7745c5c3_Var12, templ_7745c5c3_Err = templ.JoinStringErrs(fmt.Sprintf("%d ep", anime.Episodes))
|
||||||
|
if templ_7745c5c3_Err != nil {
|
||||||
|
return templ.Error{Err: templ_7745c5c3_Err, FileName: `internal/templates/schedule.templ`, Line: 77, Col: 70}
|
||||||
|
}
|
||||||
|
_, templ_7745c5c3_Err = templ_7745c5c3_Buffer.WriteString(templ.EscapeString(templ_7745c5c3_Var12))
|
||||||
|
if templ_7745c5c3_Err != nil {
|
||||||
|
return templ_7745c5c3_Err
|
||||||
|
}
|
||||||
|
templ_7745c5c3_Err = templruntime.WriteString(templ_7745c5c3_Buffer, 21, "</span>")
|
||||||
|
if templ_7745c5c3_Err != nil {
|
||||||
|
return templ_7745c5c3_Err
|
||||||
|
}
|
||||||
|
}
|
||||||
|
templ_7745c5c3_Err = templruntime.WriteString(templ_7745c5c3_Buffer, 22, "</div>")
|
||||||
|
if templ_7745c5c3_Err != nil {
|
||||||
|
return templ_7745c5c3_Err
|
||||||
|
}
|
||||||
|
if anime.Score > 0 {
|
||||||
|
templ_7745c5c3_Err = templruntime.WriteString(templ_7745c5c3_Buffer, 23, "<div class=\"schedule-card-score\">★ ")
|
||||||
|
if templ_7745c5c3_Err != nil {
|
||||||
|
return templ_7745c5c3_Err
|
||||||
|
}
|
||||||
|
var templ_7745c5c3_Var13 string
|
||||||
|
templ_7745c5c3_Var13, templ_7745c5c3_Err = templ.JoinStringErrs(fmt.Sprintf("%.1f", anime.Score))
|
||||||
|
if templ_7745c5c3_Err != nil {
|
||||||
|
return templ.Error{Err: templ_7745c5c3_Err, FileName: `internal/templates/schedule.templ`, Line: 81, Col: 75}
|
||||||
|
}
|
||||||
|
_, templ_7745c5c3_Err = templ_7745c5c3_Buffer.WriteString(templ.EscapeString(templ_7745c5c3_Var13))
|
||||||
|
if templ_7745c5c3_Err != nil {
|
||||||
|
return templ_7745c5c3_Err
|
||||||
|
}
|
||||||
|
templ_7745c5c3_Err = templruntime.WriteString(templ_7745c5c3_Buffer, 24, "</div>")
|
||||||
|
if templ_7745c5c3_Err != nil {
|
||||||
|
return templ_7745c5c3_Err
|
||||||
|
}
|
||||||
|
}
|
||||||
|
templ_7745c5c3_Err = templruntime.WriteString(templ_7745c5c3_Buffer, 25, "</div></a>")
|
||||||
|
if templ_7745c5c3_Err != nil {
|
||||||
|
return templ_7745c5c3_Err
|
||||||
|
}
|
||||||
|
return nil
|
||||||
|
})
|
||||||
|
}
|
||||||
|
|
||||||
|
var _ = templruntime.GeneratedTemplate
|
||||||
12
migrations/004_add_notifications.sql
Normal file
12
migrations/004_add_notifications.sql
Normal file
@@ -0,0 +1,12 @@
|
|||||||
|
-- Add episode tracking to watch list entries
|
||||||
|
ALTER TABLE watch_list_entry ADD COLUMN current_episode INTEGER DEFAULT 0;
|
||||||
|
ALTER TABLE watch_list_entry ADD COLUMN last_episode_at DATETIME;
|
||||||
|
|
||||||
|
-- Add notification preferences
|
||||||
|
CREATE TABLE IF NOT EXISTS notification_preference (
|
||||||
|
id TEXT PRIMARY KEY,
|
||||||
|
user_id TEXT NOT NULL REFERENCES user(id) ON DELETE CASCADE,
|
||||||
|
notify_new_episodes BOOLEAN NOT NULL DEFAULT TRUE,
|
||||||
|
created_at DATETIME NOT NULL DEFAULT CURRENT_TIMESTAMP,
|
||||||
|
UNIQUE(user_id)
|
||||||
|
);
|
||||||
@@ -1026,3 +1026,248 @@ a:visited {
|
|||||||
color: var(--link);
|
color: var(--link);
|
||||||
font-weight: bold;
|
font-weight: bold;
|
||||||
}
|
}
|
||||||
|
|
||||||
|
/* Schedule Page */
|
||||||
|
.schedule-page {
|
||||||
|
max-width: 1400px;
|
||||||
|
margin: 0 auto;
|
||||||
|
padding: var(--space-2xl) var(--space-xl);
|
||||||
|
}
|
||||||
|
|
||||||
|
.schedule-page h1 {
|
||||||
|
font-size: var(--text-2xl);
|
||||||
|
font-weight: 600;
|
||||||
|
margin: 0 0 var(--space-xs) 0;
|
||||||
|
}
|
||||||
|
|
||||||
|
.schedule-subtitle {
|
||||||
|
color: var(--text-muted);
|
||||||
|
font-size: var(--text-sm);
|
||||||
|
margin: 0 0 var(--space-2xl) 0;
|
||||||
|
}
|
||||||
|
|
||||||
|
.schedule-tabs {
|
||||||
|
display: flex;
|
||||||
|
gap: var(--space-sm);
|
||||||
|
margin-bottom: var(--space-xl);
|
||||||
|
border-bottom: 1px solid var(--border);
|
||||||
|
padding-bottom: var(--space-sm);
|
||||||
|
}
|
||||||
|
|
||||||
|
.schedule-tab {
|
||||||
|
background: none;
|
||||||
|
border: none;
|
||||||
|
color: var(--text-muted);
|
||||||
|
cursor: pointer;
|
||||||
|
font-size: var(--text-base);
|
||||||
|
padding: var(--space-sm) var(--space-md);
|
||||||
|
border-radius: 4px 4px 0 0;
|
||||||
|
transition: all 0.15s;
|
||||||
|
}
|
||||||
|
|
||||||
|
.schedule-tab:hover {
|
||||||
|
color: var(--text);
|
||||||
|
background: var(--surface-hover);
|
||||||
|
}
|
||||||
|
|
||||||
|
.schedule-tab.active {
|
||||||
|
color: var(--link);
|
||||||
|
background: var(--surface);
|
||||||
|
font-weight: 600;
|
||||||
|
}
|
||||||
|
|
||||||
|
.schedule-day h2 {
|
||||||
|
font-size: var(--text-xl);
|
||||||
|
font-weight: 600;
|
||||||
|
margin: 0 0 var(--space-lg) 0;
|
||||||
|
}
|
||||||
|
|
||||||
|
.schedule-grid {
|
||||||
|
display: grid;
|
||||||
|
grid-template-columns: repeat(auto-fill, minmax(clamp(140px, 12vw + 80px, 200px), 1fr));
|
||||||
|
gap: var(--space-lg);
|
||||||
|
}
|
||||||
|
|
||||||
|
.schedule-card {
|
||||||
|
background: var(--surface);
|
||||||
|
border: 1px solid var(--border);
|
||||||
|
border-radius: 4px;
|
||||||
|
overflow: hidden;
|
||||||
|
transition: border-color 0.15s, transform 0.15s;
|
||||||
|
text-decoration: none;
|
||||||
|
color: var(--text);
|
||||||
|
display: flex;
|
||||||
|
flex-direction: column;
|
||||||
|
}
|
||||||
|
|
||||||
|
.schedule-card:hover {
|
||||||
|
border-color: var(--link);
|
||||||
|
transform: translateY(-2px);
|
||||||
|
}
|
||||||
|
|
||||||
|
.schedule-card-image {
|
||||||
|
aspect-ratio: 2/3;
|
||||||
|
background: var(--bg);
|
||||||
|
overflow: hidden;
|
||||||
|
}
|
||||||
|
|
||||||
|
.schedule-card-image img {
|
||||||
|
width: 100%;
|
||||||
|
height: 100%;
|
||||||
|
object-fit: cover;
|
||||||
|
}
|
||||||
|
|
||||||
|
.schedule-card-info {
|
||||||
|
padding: var(--space-md);
|
||||||
|
flex: 1;
|
||||||
|
display: flex;
|
||||||
|
flex-direction: column;
|
||||||
|
}
|
||||||
|
|
||||||
|
.schedule-card-title {
|
||||||
|
font-size: var(--text-sm);
|
||||||
|
font-weight: 600;
|
||||||
|
margin-bottom: var(--space-xs);
|
||||||
|
line-height: 1.3;
|
||||||
|
display: -webkit-box;
|
||||||
|
-webkit-line-clamp: 2;
|
||||||
|
-webkit-box-orient: vertical;
|
||||||
|
overflow: hidden;
|
||||||
|
}
|
||||||
|
|
||||||
|
.schedule-card-meta {
|
||||||
|
display: flex;
|
||||||
|
flex-wrap: wrap;
|
||||||
|
gap: var(--space-xs);
|
||||||
|
font-size: var(--text-xs);
|
||||||
|
color: var(--text-muted);
|
||||||
|
margin-top: auto;
|
||||||
|
}
|
||||||
|
|
||||||
|
.schedule-card-meta span {
|
||||||
|
background: var(--surface-hover);
|
||||||
|
padding: 2px 6px;
|
||||||
|
border-radius: 3px;
|
||||||
|
}
|
||||||
|
|
||||||
|
.schedule-time {
|
||||||
|
color: var(--link) !important;
|
||||||
|
font-weight: 600;
|
||||||
|
}
|
||||||
|
|
||||||
|
.schedule-card-score {
|
||||||
|
font-size: var(--text-xs);
|
||||||
|
color: var(--link);
|
||||||
|
font-weight: 600;
|
||||||
|
margin-top: var(--space-xs);
|
||||||
|
}
|
||||||
|
|
||||||
|
.no-anime {
|
||||||
|
color: var(--text-muted);
|
||||||
|
font-size: var(--text-base);
|
||||||
|
text-align: center;
|
||||||
|
padding: var(--space-2xl);
|
||||||
|
}
|
||||||
|
|
||||||
|
|
||||||
|
/* Notifications Page */
|
||||||
|
.notifications-page {
|
||||||
|
max-width: 900px;
|
||||||
|
margin: 0 auto;
|
||||||
|
padding: var(--space-2xl) var(--space-xl);
|
||||||
|
}
|
||||||
|
|
||||||
|
.notifications-page h1 {
|
||||||
|
font-size: var(--text-2xl);
|
||||||
|
font-weight: 600;
|
||||||
|
margin: 0 0 var(--space-xs) 0;
|
||||||
|
}
|
||||||
|
|
||||||
|
.notifications-subtitle {
|
||||||
|
color: var(--text-muted);
|
||||||
|
font-size: var(--text-sm);
|
||||||
|
margin: 0 0 var(--space-2xl) 0;
|
||||||
|
}
|
||||||
|
|
||||||
|
.no-notifications {
|
||||||
|
text-align: center;
|
||||||
|
color: var(--text-muted);
|
||||||
|
padding: var(--space-2xl);
|
||||||
|
}
|
||||||
|
|
||||||
|
.no-notifications .hint {
|
||||||
|
font-size: var(--text-sm);
|
||||||
|
margin-top: var(--space-sm);
|
||||||
|
}
|
||||||
|
|
||||||
|
.notifications-list {
|
||||||
|
display: flex;
|
||||||
|
flex-direction: column;
|
||||||
|
gap: var(--space-lg);
|
||||||
|
}
|
||||||
|
|
||||||
|
.notification-card {
|
||||||
|
display: flex;
|
||||||
|
gap: var(--space-lg);
|
||||||
|
background: var(--surface);
|
||||||
|
border: 1px solid var(--border);
|
||||||
|
padding: var(--space-lg);
|
||||||
|
border-radius: 4px;
|
||||||
|
transition: border-color 0.15s;
|
||||||
|
}
|
||||||
|
|
||||||
|
.notification-card:hover {
|
||||||
|
border-color: var(--link);
|
||||||
|
}
|
||||||
|
|
||||||
|
.notification-image {
|
||||||
|
flex-shrink: 0;
|
||||||
|
width: var(--thumb-width);
|
||||||
|
}
|
||||||
|
|
||||||
|
.notification-image img {
|
||||||
|
width: 100%;
|
||||||
|
aspect-ratio: 2/3;
|
||||||
|
object-fit: cover;
|
||||||
|
border-radius: 4px;
|
||||||
|
}
|
||||||
|
|
||||||
|
.notification-content {
|
||||||
|
flex: 1;
|
||||||
|
min-width: 0;
|
||||||
|
}
|
||||||
|
|
||||||
|
.notification-title {
|
||||||
|
font-size: var(--text-md);
|
||||||
|
font-weight: 600;
|
||||||
|
color: var(--text);
|
||||||
|
text-decoration: none;
|
||||||
|
display: block;
|
||||||
|
margin-bottom: var(--space-xs);
|
||||||
|
}
|
||||||
|
|
||||||
|
.notification-title:hover {
|
||||||
|
color: var(--link);
|
||||||
|
}
|
||||||
|
|
||||||
|
.notification-meta {
|
||||||
|
display: flex;
|
||||||
|
flex-wrap: wrap;
|
||||||
|
gap: var(--space-sm);
|
||||||
|
font-size: var(--text-sm);
|
||||||
|
color: var(--text-muted);
|
||||||
|
margin-bottom: var(--space-md);
|
||||||
|
}
|
||||||
|
|
||||||
|
.notification-broadcast {
|
||||||
|
color: var(--link);
|
||||||
|
font-weight: 600;
|
||||||
|
}
|
||||||
|
|
||||||
|
.notification-synopsis {
|
||||||
|
font-size: var(--text-sm);
|
||||||
|
line-height: 1.5;
|
||||||
|
color: var(--text-muted);
|
||||||
|
margin: 0;
|
||||||
|
}
|
||||||
|
|
||||||
|
|||||||
Reference in New Issue
Block a user