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) {
|
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) {
|
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,
|
HasNextPage: result.Pagination.HasNextPage,
|
||||||
}, nil
|
}, 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("/", animeHandler.HandleCatalog)
|
||||||
mux.HandleFunc("/search", animeHandler.HandleSearch)
|
mux.HandleFunc("/search", animeHandler.HandleSearch)
|
||||||
mux.HandleFunc("/browse", animeHandler.HandleBrowse)
|
mux.HandleFunc("/browse", animeHandler.HandleBrowse)
|
||||||
|
mux.HandleFunc("/discover", animeHandler.HandleDiscover)
|
||||||
mux.HandleFunc("/api/search-quick", animeHandler.HandleQuickSearch)
|
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) {
|
mux.HandleFunc("/anime/", func(w http.ResponseWriter, r *http.Request) {
|
||||||
if strings.HasSuffix(r.URL.Path, "/watch") {
|
if strings.HasSuffix(r.URL.Path, "/watch") {
|
||||||
playbackHandler.HandleWatchPage(w, r)
|
playbackHandler.HandleWatchPage(w, r)
|
||||||
|
|||||||
@@ -1,13 +1,12 @@
|
|||||||
const dedupe = (): void => {
|
const dedupe = (): void => {
|
||||||
const script = document.currentScript as HTMLScriptElement | null
|
console.log('Dedupe running...')
|
||||||
if (!script) return
|
|
||||||
const containerId = script.getAttribute('data-container')
|
|
||||||
const container = containerId ? document.getElementById(containerId) : document
|
|
||||||
if (!container) return
|
|
||||||
const seen = new Set<string>()
|
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')
|
const id = item.getAttribute('data-id')
|
||||||
if (id && seen.has(id)) {
|
if (id && seen.has(id)) {
|
||||||
|
console.log('Removing duplicate:', id)
|
||||||
item.remove()
|
item.remove()
|
||||||
} else if (id) {
|
} else if (id) {
|
||||||
seen.add(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/player.js" defer></script>
|
||||||
<script type="module" src="/dist/static/search.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/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 src="https://unpkg.com/htmx.org@1.9.12"></script>
|
||||||
<script>
|
<script>
|
||||||
function toggleMobileMenu() {
|
function toggleMobileMenu() {
|
||||||
|
|||||||
@@ -5,6 +5,7 @@
|
|||||||
{{$hideTitle := .HideTitle}}
|
{{$hideTitle := .HideTitle}}
|
||||||
{{$isWatchlist := .IsWatchlist}}
|
{{$isWatchlist := .IsWatchlist}}
|
||||||
{{$hasTopBadge := .HasTopBadge}}
|
{{$hasTopBadge := .HasTopBadge}}
|
||||||
|
{{$dataId := printf "id-%d" $anime.MalID}}
|
||||||
|
|
||||||
{{$imageUrl := "https://placehold.co/400x600?text=No+Image"}}
|
{{$imageUrl := "https://placehold.co/400x600?text=No+Image"}}
|
||||||
{{if $anime.Images.Webp.LargeImageURL}}
|
{{if $anime.Images.Webp.LargeImageURL}}
|
||||||
@@ -18,7 +19,7 @@
|
|||||||
{{$displayTitle = $anime.TitleEnglish}}
|
{{$displayTitle = $anime.TitleEnglish}}
|
||||||
{{end}}
|
{{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">
|
<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" />
|
<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