feat: add discover page with surprise me and deduplication
This commit is contained in:
@@ -291,7 +291,100 @@ func (h *Handler) HandleQuickSearch(w http.ResponseWriter, r *http.Request) {
|
||||
}
|
||||
|
||||
func (h *Handler) HandleDiscover(w http.ResponseWriter, r *http.Request) {
|
||||
renderNotFoundPage(r, w)
|
||||
trending, err := h.jikanClient.GetSeasonsNow(r.Context(), 1)
|
||||
if err != nil {
|
||||
log.Printf("seasons now error: %v", err)
|
||||
}
|
||||
|
||||
upcoming, err := h.jikanClient.GetSeasonsUpcoming(r.Context(), 1)
|
||||
if err != nil {
|
||||
log.Printf("seasons upcoming error: %v", err)
|
||||
}
|
||||
|
||||
top, err := h.jikanClient.GetTopAnime(r.Context(), 1)
|
||||
if err != nil {
|
||||
log.Printf("top anime error: %v", err)
|
||||
}
|
||||
|
||||
seen := make(map[int]bool)
|
||||
uniqueTrending := make([]jikan.Anime, 0)
|
||||
for _, a := range trending.Animes {
|
||||
if !seen[a.MalID] {
|
||||
seen[a.MalID] = true
|
||||
uniqueTrending = append(uniqueTrending, a)
|
||||
}
|
||||
if len(uniqueTrending) >= 10 {
|
||||
break
|
||||
}
|
||||
}
|
||||
|
||||
uniqueUpcoming := make([]jikan.Anime, 0)
|
||||
for _, a := range upcoming.Animes {
|
||||
if !seen[a.MalID] {
|
||||
seen[a.MalID] = true
|
||||
uniqueUpcoming = append(uniqueUpcoming, a)
|
||||
}
|
||||
if len(uniqueUpcoming) >= 10 {
|
||||
break
|
||||
}
|
||||
}
|
||||
|
||||
uniqueTop := make([]jikan.Anime, 0)
|
||||
for _, a := range top.Animes {
|
||||
if !seen[a.MalID] {
|
||||
seen[a.MalID] = true
|
||||
uniqueTop = append(uniqueTop, a)
|
||||
}
|
||||
if len(uniqueTop) >= 10 {
|
||||
break
|
||||
}
|
||||
}
|
||||
|
||||
user, _ := r.Context().Value(ctxpkg.UserKey).(*database.User)
|
||||
watchlistMap := make(map[int64]bool)
|
||||
var watchlistIDs []int64
|
||||
if user != nil {
|
||||
watchlist, _ := h.db.GetUserWatchList(r.Context(), user.ID)
|
||||
watchlistIDs = make([]int64, len(watchlist))
|
||||
for i, entry := range watchlist {
|
||||
watchlistMap[entry.AnimeID] = true
|
||||
watchlistIDs[i] = entry.AnimeID
|
||||
}
|
||||
}
|
||||
|
||||
if err := templates.GetRenderer().ExecuteTemplate(w, "discover.gohtml", map[string]any{
|
||||
"User": user,
|
||||
"CurrentPath": r.URL.Path,
|
||||
"Trending": uniqueTrending,
|
||||
"Upcoming": uniqueUpcoming,
|
||||
"Top": uniqueTop,
|
||||
"WatchlistMap": watchlistMap,
|
||||
"WatchlistIDs": watchlistIDs,
|
||||
}); err != nil {
|
||||
log.Printf("render error: %v", err)
|
||||
http.Error(w, "Internal Server Error", http.StatusInternalServerError)
|
||||
}
|
||||
}
|
||||
|
||||
func (h *Handler) HandleRandomAnime(w http.ResponseWriter, r *http.Request) {
|
||||
w.Header().Set("Content-Type", "application/json")
|
||||
|
||||
anime, err := h.jikanClient.GetRandomAnime(r.Context())
|
||||
if err != nil {
|
||||
log.Printf("random anime error: %v", err)
|
||||
w.WriteHeader(http.StatusInternalServerError)
|
||||
json.NewEncoder(w).Encode(map[string]string{"error": "Failed to fetch random anime"})
|
||||
return
|
||||
}
|
||||
|
||||
if anime.MalID == 0 {
|
||||
w.WriteHeader(http.StatusNotFound)
|
||||
json.NewEncoder(w).Encode(map[string]string{"error": "No anime found"})
|
||||
return
|
||||
}
|
||||
|
||||
w.WriteHeader(http.StatusOK)
|
||||
json.NewEncoder(w).Encode(map[string]any{"data": anime})
|
||||
}
|
||||
|
||||
func (h *Handler) HandleAPIDiscoverAiring(w http.ResponseWriter, r *http.Request) {
|
||||
|
||||
@@ -83,3 +83,17 @@ func (c *Client) GetSeasonsUpcoming(ctx context.Context, page int) (TopAnimeResu
|
||||
HasNextPage: result.Pagination.HasNextPage,
|
||||
}, nil
|
||||
}
|
||||
|
||||
func (c *Client) GetRandomAnime(ctx context.Context) (Anime, error) {
|
||||
var result struct {
|
||||
Data Anime `json:"data"`
|
||||
}
|
||||
|
||||
reqURL := fmt.Sprintf("%s/random/anime", c.baseURL)
|
||||
err := c.fetchWithRetry(ctx, reqURL, &result)
|
||||
if err != nil {
|
||||
return Anime{}, err
|
||||
}
|
||||
|
||||
return result.Data, nil
|
||||
}
|
||||
|
||||
@@ -69,7 +69,9 @@ func NewRouter(cfg Config) http.Handler {
|
||||
mux.HandleFunc("/", animeHandler.HandleCatalog)
|
||||
mux.HandleFunc("/search", animeHandler.HandleSearch)
|
||||
mux.HandleFunc("/browse", animeHandler.HandleBrowse)
|
||||
mux.HandleFunc("/discover", animeHandler.HandleDiscover)
|
||||
mux.HandleFunc("/api/search-quick", animeHandler.HandleQuickSearch)
|
||||
mux.HandleFunc("/api/jikan/random/anime", animeHandler.HandleRandomAnime)
|
||||
mux.HandleFunc("/anime/", func(w http.ResponseWriter, r *http.Request) {
|
||||
if strings.HasSuffix(r.URL.Path, "/watch") {
|
||||
playbackHandler.HandleWatchPage(w, r)
|
||||
|
||||
@@ -1,13 +1,12 @@
|
||||
const dedupe = (): void => {
|
||||
const script = document.currentScript as HTMLScriptElement | null
|
||||
if (!script) return
|
||||
const containerId = script.getAttribute('data-container')
|
||||
const container = containerId ? document.getElementById(containerId) : document
|
||||
if (!container) return
|
||||
console.log('Dedupe running...')
|
||||
const seen = new Set<string>()
|
||||
container.querySelectorAll('[data-id]').forEach((item) => {
|
||||
const elements = document.querySelectorAll('[data-id]')
|
||||
console.log('Found elements:', elements.length)
|
||||
elements.forEach((item) => {
|
||||
const id = item.getAttribute('data-id')
|
||||
if (id && seen.has(id)) {
|
||||
console.log('Removing duplicate:', id)
|
||||
item.remove()
|
||||
} else if (id) {
|
||||
seen.add(id)
|
||||
@@ -15,4 +14,10 @@ const dedupe = (): void => {
|
||||
})
|
||||
}
|
||||
|
||||
dedupe()
|
||||
if (document.readyState === 'loading') {
|
||||
document.addEventListener('DOMContentLoaded', dedupe)
|
||||
} else {
|
||||
dedupe()
|
||||
}
|
||||
// Also run on window load to be sure
|
||||
window.addEventListener('load', dedupe)
|
||||
|
||||
@@ -14,6 +14,7 @@
|
||||
<script type="module" src="/dist/static/player.js" defer></script>
|
||||
<script type="module" src="/dist/static/search.js" defer></script>
|
||||
<script type="module" src="/dist/static/sort_filter.js" defer></script>
|
||||
<script type="module" src="/dist/static/dedupe.js" defer></script>
|
||||
<script src="https://unpkg.com/htmx.org@1.9.12"></script>
|
||||
<script>
|
||||
function toggleMobileMenu() {
|
||||
|
||||
@@ -5,6 +5,7 @@
|
||||
{{$hideTitle := .HideTitle}}
|
||||
{{$isWatchlist := .IsWatchlist}}
|
||||
{{$hasTopBadge := .HasTopBadge}}
|
||||
{{$dataId := printf "id-%d" $anime.MalID}}
|
||||
|
||||
{{$imageUrl := "https://placehold.co/400x600?text=No+Image"}}
|
||||
{{if $anime.Images.Webp.LargeImageURL}}
|
||||
@@ -18,7 +19,7 @@
|
||||
{{$displayTitle = $anime.TitleEnglish}}
|
||||
{{end}}
|
||||
|
||||
<div class="flex w-full flex-col gap-2">
|
||||
<div class="flex w-full flex-col gap-2" data-id="{{$dataId}}">
|
||||
<a href="/anime/{{$anime.MalID}}" class="group relative flex aspect-[2/3] w-full flex-col overflow-hidden bg-white/5 after:absolute after:inset-0 {{if $withActions}}after:bg-black/80 after:opacity-0 hover:after:opacity-100{{else}}after:bg-linear-to-t after:from-black/80 after:via-black/20 after:to-transparent after:opacity-80 hover:after:opacity-100{{end}} after:transition-opacity">
|
||||
<img src="{{$imageUrl}}" alt="{{$displayTitle}}" class="h-full w-full object-cover" loading="lazy" />
|
||||
|
||||
|
||||
106
templates/discover.gohtml
Normal file
106
templates/discover.gohtml
Normal file
@@ -0,0 +1,106 @@
|
||||
{{define "title"}}Discover{{end}}
|
||||
{{define "content"}}
|
||||
{{if .WatchlistIDs}}<script>initWatchlist({{.WatchlistIDs}})</script>{{end}}
|
||||
<script>
|
||||
let isFetchingRandom = false;
|
||||
|
||||
async function handleSurpriseMe() {
|
||||
if (isFetchingRandom) return;
|
||||
isFetchingRandom = true;
|
||||
const btn = document.getElementById('surprise-btn');
|
||||
const spinner = document.getElementById('surprise-spinner');
|
||||
const text = document.getElementById('surprise-text');
|
||||
try {
|
||||
const res = await fetch(`/api/jikan/random/anime?t=${Date.now()}`, {
|
||||
cache: 'no-store'
|
||||
});
|
||||
if (!res.ok) throw new Error('Failed to fetch random anime');
|
||||
const json = await res.json();
|
||||
if (json.data && json.data.mal_id) {
|
||||
window.location.href = `/anime/${json.data.mal_id}`;
|
||||
}
|
||||
} catch (error) {
|
||||
console.error(error);
|
||||
} finally {
|
||||
isFetchingRandom = false;
|
||||
}
|
||||
}
|
||||
</script>
|
||||
|
||||
<div class="flex flex-col gap-12 pb-12">
|
||||
<section class="relative flex flex-col items-center justify-center overflow-hidden rounded-2xl bg-neutral-900 px-6 py-24 text-center">
|
||||
<div class="from-accent/10 absolute inset-0 bg-linear-to-b to-transparent opacity-50"></div>
|
||||
<div class="relative z-10 flex max-w-2xl flex-col items-center gap-6">
|
||||
<h1 class="text-4xl font-bold tracking-tight text-white sm:text-5xl">
|
||||
Don't know what to watch?
|
||||
</h1>
|
||||
<p class="text-lg text-neutral-400">
|
||||
Let us pick something for you from our vast collection of anime.
|
||||
</p>
|
||||
<button
|
||||
id="surprise-btn"
|
||||
class="group bg-accent relative inline-flex items-center justify-center gap-2 overflow-hidden rounded-full px-8 py-4 font-medium text-black transition-transform hover:scale-105 active:scale-95 disabled:opacity-70 disabled:hover:scale-100"
|
||||
onclick="handleSurpriseMe()"
|
||||
>
|
||||
<span id="surprise-spinner" class="hidden h-5 w-5 animate-spin rounded-full border-2 border-black border-t-transparent"></span>
|
||||
<svg id="surprise-icon" class="h-5 w-5 transition-transform group-hover:rotate-12" viewBox="0 0 24 24" fill="none" stroke="currentColor" stroke-width="2">
|
||||
<path d="M12 2v20M17 5H9.5a3.5 3.5 0 0 0 0 7h5a3.5 3.5 0 0 1 0 7H6" />
|
||||
</svg>
|
||||
<span id="surprise-text">Surprise Me</span>
|
||||
</button>
|
||||
</div>
|
||||
</section>
|
||||
|
||||
<section class="flex flex-col gap-4">
|
||||
<div class="flex items-center justify-between">
|
||||
<h2 class="text-xl font-semibold text-white">Trending This Season</h2>
|
||||
<a href="/browse?order_by=popularity&sort=desc" class="text-accent text-sm hover:underline">View all</a>
|
||||
</div>
|
||||
<div class="scrollbar-hide flex snap-x snap-mandatory gap-4 overflow-x-auto pb-4">
|
||||
{{range $i, $anime := .Trending}}
|
||||
<div class="w-40 shrink-0 snap-start sm:w-48 md:w-56">
|
||||
{{template "anime_card" dict "Anime" $anime "WithActions" true "IsWatchlist" (index $.WatchlistMap $anime.MalID)}}
|
||||
</div>
|
||||
{{end}}
|
||||
</div>
|
||||
</section>
|
||||
|
||||
<section class="flex flex-col gap-4">
|
||||
<div class="flex items-center justify-between">
|
||||
<h2 class="text-xl font-semibold text-white">Highly Anticipated</h2>
|
||||
<a href="/browse?status=upcoming&order_by=members&sort=desc" class="text-accent text-sm hover:underline">View all</a>
|
||||
</div>
|
||||
<div class="scrollbar-hide flex snap-x snap-mandatory gap-4 overflow-x-auto pb-4">
|
||||
{{range $i, $anime := .Upcoming}}
|
||||
<div class="w-40 shrink-0 snap-start sm:w-48 md:w-56">
|
||||
{{template "anime_card" dict "Anime" $anime "WithActions" true "IsWatchlist" (index $.WatchlistMap $anime.MalID)}}
|
||||
</div>
|
||||
{{end}}
|
||||
</div>
|
||||
</section>
|
||||
|
||||
<section class="flex flex-col gap-4">
|
||||
<div class="flex items-center justify-between">
|
||||
<h2 class="text-xl font-semibold text-white">All-Time Greats</h2>
|
||||
<a href="/browse?order_by=score&sort=desc" class="text-accent text-sm hover:underline">View all</a>
|
||||
</div>
|
||||
<div class="scrollbar-hide flex snap-x snap-mandatory gap-4 overflow-x-auto pb-4">
|
||||
{{range $i, $anime := .Top}}
|
||||
<div class="w-40 shrink-0 snap-start sm:w-48 md:w-56">
|
||||
{{template "anime_card" dict "Anime" $anime "WithActions" true "IsWatchlist" (index $.WatchlistMap $anime.MalID)}}
|
||||
</div>
|
||||
{{end}}
|
||||
</div>
|
||||
</section>
|
||||
</div>
|
||||
|
||||
<style>
|
||||
.scrollbar-hide::-webkit-scrollbar {
|
||||
display: none;
|
||||
}
|
||||
.scrollbar-hide {
|
||||
-ms-overflow-style: none;
|
||||
scrollbar-width: none;
|
||||
}
|
||||
</style>
|
||||
{{end}}
|
||||
Reference in New Issue
Block a user