diff --git a/internal/jikan/relations.go b/internal/jikan/relations.go index c2038f5..080d9fd 100644 --- a/internal/jikan/relations.go +++ b/internal/jikan/relations.go @@ -2,83 +2,245 @@ package jikan import ( "context" - "maps" + "slices" + "sort" + "strings" + "time" ) -func findFirstAnimeRelation(groups []JikanRelationGroup, relType string) *int { - for _, group := range groups { - if group.Relation == relType { - for _, entry := range group.Entry { - if entry.Type == "anime" { - id := entry.MalID - return &id - } - } +var canonicalRelationOrder = []string{ + "prequel", + "sequel", + "parent story", + "full story", + "alternative version", + "alternative setting", +} + +var extraRelationOrder = []string{ + "side story", + "spin-off", + "summary", + "other", +} + +var relationPriorityOrder = append( + append([]string{}, canonicalRelationOrder...), + extraRelationOrder..., +) + +func relationKey(rel string) string { + key := strings.ToLower(strings.TrimSpace(rel)) + key = strings.ReplaceAll(key, "_", " ") + key = strings.Join(strings.Fields(key), " ") + + switch key { + case "prequels": + return "prequel" + case "sequels": + return "sequel" + case "side stories": + return "side story" + case "spin off", "spinoff": + return "spin-off" + default: + return key + } +} + +func relationLabel(rel string) string { + key := relationKey(rel) + switch key { + case "prequel": + return "Prequels" + case "sequel": + return "Sequels" + case "parent story": + return "Parent story" + case "full story": + return "Full story" + case "alternative version": + return "Alternative version" + case "alternative setting": + return "Alternative setting" + case "side story": + return "Side story" + case "spin-off": + return "Spin-off" + case "summary": + return "Summary" + case "other": + return "Other" + default: + return strings.TrimSpace(rel) + } +} + +func isCanonicalRelation(rel string) bool { + return slices.Contains(canonicalRelationOrder, relationKey(rel)) +} + +func isExtraRelation(rel string) bool { + return slices.Contains(extraRelationOrder, relationKey(rel)) +} + +func isFranchiseRelation(rel string) bool { + return isCanonicalRelation(rel) || isExtraRelation(rel) +} + +func relationOrder(rel string) int { + key := relationKey(rel) + for i, allowed := range relationPriorityOrder { + if key == allowed { + return i } } - return nil + return len(relationPriorityOrder) + 1 } -func (c *Client) fetchChain(ctx context.Context, startID int, direction string, visited map[int]bool) ([]RelationEntry, error) { - anime, err := c.GetAnimeByID(ctx, startID) - if err != nil { - return nil, err +func relationAiredAt(anime Anime) (time.Time, bool) { + from := strings.TrimSpace(anime.Aired.From) + if from != "" { + if parsed, err := time.Parse(time.RFC3339, from); err == nil { + return parsed, true + } + if parsed, err := time.Parse("2006-01-02", from); err == nil { + return parsed, true + } } - nextIDPtr := findFirstAnimeRelation(anime.Relations, direction) - if nextIDPtr == nil { - return nil, nil + if anime.Year > 0 { + return time.Date(anime.Year, time.January, 1, 0, 0, 0, 0, time.UTC), true } - nextID := *nextIDPtr - if visited[nextID] { // prevent loops - return nil, nil - } - visited[nextID] = true - - nextAnime, err := c.GetAnimeByID(ctx, nextID) - if err != nil { - return nil, err - } - - entry := RelationEntry{Anime: nextAnime, IsCurrent: false} - rest, err := c.fetchChain(ctx, nextID, direction, visited) - if err != nil { - return nil, err - } - - if direction == "Prequel" { - return append(rest, entry), nil - } - return append([]RelationEntry{entry}, rest...), nil + return time.Time{}, false } -func (c *Client) GetFullRelations(ctx context.Context, id int) ([]RelationEntry, error) { +func sortRelationEntriesChronological(entries []RelationEntry) { + sort.SliceStable(entries, func(i int, j int) bool { + left := entries[i] + right := entries[j] + + leftAiredAt, leftHasAiredAt := relationAiredAt(left.Anime) + rightAiredAt, rightHasAiredAt := relationAiredAt(right.Anime) + + if leftHasAiredAt != rightHasAiredAt { + return leftHasAiredAt + } + + if leftHasAiredAt && !leftAiredAt.Equal(rightAiredAt) { + return leftAiredAt.Before(rightAiredAt) + } + + leftRelationOrder := relationOrder(left.Relation) + rightRelationOrder := relationOrder(right.Relation) + if leftRelationOrder != rightRelationOrder { + return leftRelationOrder < rightRelationOrder + } + + leftTitle := strings.ToLower(left.Anime.DisplayTitle()) + rightTitle := strings.ToLower(right.Anime.DisplayTitle()) + if leftTitle != rightTitle { + return leftTitle < rightTitle + } + + return left.Anime.MalID < right.Anime.MalID + }) +} + +func relationEntries(ctx context.Context, c *Client, anime Anime) ([]RelationEntry, error) { + entries := make([]RelationEntry, 0) + + for _, group := range anime.Relations { + if !isFranchiseRelation(group.Relation) { + continue + } + + for _, entry := range group.Entry { + if entry.Type != "anime" { + continue + } + + relAnime, err := c.GetAnimeByID(ctx, entry.MalID) + if err != nil { + return nil, err + } + + entries = append(entries, RelationEntry{ + Anime: relAnime, + Relation: relationLabel(group.Relation), + IsCurrent: false, + IsExtra: !isCanonicalRelation(group.Relation), + }) + } + } + + return entries, nil +} + +func relationMap(ctx context.Context, c *Client, id int) (map[int]RelationEntry, error) { currentAnime, err := c.GetAnimeByID(ctx, id) if err != nil { return nil, err } - visited := map[int]bool{id: true} - - prequels, err1 := c.fetchChain(ctx, id, "Prequel", visited) - - visitedSeq := make(map[int]bool) - maps.Copy(visitedSeq, visited) - - sequels, err2 := c.fetchChain(ctx, id, "Sequel", visitedSeq) - - var result []RelationEntry - result = append(result, prequels...) - result = append(result, RelationEntry{Anime: currentAnime, IsCurrent: true}) - result = append(result, sequels...) - - var finalErr error - if err1 != nil { - finalErr = err1 - } else if err2 != nil { - finalErr = err2 + result := map[int]RelationEntry{ + currentAnime.MalID: { + Anime: currentAnime, + Relation: "Current", + IsCurrent: true, + IsExtra: false, + }, } - return result, finalErr + queue := []Anime{currentAnime} + visited := map[int]bool{currentAnime.MalID: true} + + for len(queue) > 0 { + anime := queue[0] + queue = queue[1:] + + entries, err := relationEntries(ctx, c, anime) + if err != nil { + return nil, err + } + + for _, rel := range entries { + existing, exists := result[rel.Anime.MalID] + if !exists { + result[rel.Anime.MalID] = rel + } else if !existing.IsCurrent { + if existing.IsExtra && !rel.IsExtra { + // Prefer canonical timeline links over extras when both point to the same anime. + result[rel.Anime.MalID] = rel + } else if existing.IsExtra && rel.IsExtra && relationOrder(rel.Relation) < relationOrder(existing.Relation) { + // Keep the most specific extra label when multiple extra relations exist. + result[rel.Anime.MalID] = rel + } + } + + if !rel.IsExtra && !visited[rel.Anime.MalID] { + visited[rel.Anime.MalID] = true + queue = append(queue, rel.Anime) + } + } + } + + return result, nil +} + +func (c *Client) GetFullRelations(ctx context.Context, id int) ([]RelationEntry, error) { + relationByID, err := relationMap(ctx, c, id) + if err != nil { + return nil, err + } + + ordered := make([]RelationEntry, 0, len(relationByID)) + for _, entry := range relationByID { + ordered = append(ordered, entry) + } + + sortRelationEntriesChronological(ordered) + + return ordered, nil } diff --git a/internal/jikan/types.go b/internal/jikan/types.go index 418003f..bd97ef4 100644 --- a/internal/jikan/types.go +++ b/internal/jikan/types.go @@ -170,7 +170,9 @@ type JikanRelationsResponse struct { type RelationEntry struct { Anime Anime + Relation string IsCurrent bool + IsExtra bool } func (a Anime) DisplayTitle() string { diff --git a/internal/templates/anime.templ b/internal/templates/anime.templ index 07b3f40..7522231 100644 --- a/internal/templates/anime.templ +++ b/internal/templates/anime.templ @@ -253,17 +253,25 @@ func formatStatus(status string) string { templ AnimeRelationsList(relations []jikan.RelationEntry) { if len(relations) > 1 { -
+
+ + +
+
for _, rel := range relations { @ui.AnimeCard(ui.AnimeCardProps{ ID: rel.Anime.MalID, Title: rel.Anime.DisplayTitle(), ImageURL: rel.Anime.ImageURL(), - Class: "relation-card" + func() string { if rel.IsCurrent { return " current" }; return "" }(), + Class: relationCardClass(rel), ImgClass: "relation-thumb", TitleClass: "relation-title", CurrentNode: rel.IsCurrent, - }) + }) { + if rel.Relation != "" && rel.Relation != "Current" { +
{ rel.Relation }
+ } + } }
} else { @@ -271,6 +279,17 @@ templ AnimeRelationsList(relations []jikan.RelationEntry) { } } +func relationCardClass(rel jikan.RelationEntry) string { + base := "relation-card" + if rel.IsCurrent { + base += " current" + } + if rel.IsExtra { + base += " relation-extra is-hidden" + } + return base +} + templ AnimeRecommendations(recs []jikan.Anime) { if len(recs) > 0 {
diff --git a/static/css/style.css b/static/css/style.css index 0a9f716..5111e52 100644 --- a/static/css/style.css +++ b/static/css/style.css @@ -257,7 +257,7 @@ main { } .is-hidden { - display: none; + display: none !important; } .grid-full-width { @@ -347,6 +347,12 @@ main { overflow: hidden; } +.relation-type { + margin-top: var(--space-1); + color: var(--text-faint); + font-size: 0.76rem; +} + .catalog-item:hover .catalog-title, .relation-card:hover .relation-title, .notification-card:hover .notification-title { @@ -782,6 +788,12 @@ main { line-height: 1.2; } +.relations-controls { + display: flex; + gap: var(--space-2); + margin-bottom: var(--space-3); +} + .anime-side-section h3 { margin: 0 0 var(--space-2); color: var(--text-faint); diff --git a/static/js/anime.js b/static/js/anime.js index c10c5a1..6ebc6db 100644 --- a/static/js/anime.js +++ b/static/js/anime.js @@ -10,6 +10,36 @@ window.toggleDropdown = toggleDropdown + const toggleRelationExtras = (showExtras) => { + const extras = document.querySelectorAll('.relation-extra') + extras.forEach((item) => { + item.classList.toggle('is-hidden', !showExtras) + }) + + const mainTab = document.getElementById('relations-main-tab') + const extraTab = document.getElementById('relations-extra-tab') + if (mainTab) { + mainTab.classList.toggle('active', !showExtras) + } + if (extraTab) { + extraTab.classList.toggle('active', showExtras) + } + } + + window.toggleRelationExtras = toggleRelationExtras + window.addEventListener('load', () => toggleRelationExtras(false)) + + document.body.addEventListener('htmx:afterSwap', (event) => { + const target = event.target + if (!(target instanceof HTMLElement)) { + return + } + + if (target.querySelector('#relations-grid') || target.id === 'relations-grid') { + toggleRelationExtras(false) + } + }) + document.addEventListener('click', (event) => { const dropdown = document.getElementById('watchlist-dropdown') if (!dropdown) {