-
- {#if data.item.poster}
-

- {/if}
-
-
-
- {data.item.title}
-
+
+
+ {#each mediaFacts as fact, index (fact)}
+ {#if index > 0}
+ •
+ {/if}
+ {fact}
+ {/each}
+
-
- {#each mediaFacts as fact, index (fact)}
- {#if index > 0}
- •
- {/if}
- {fact}
- {/each}
-
+
+ 0 ? averageReviewRating : null}
+ popfeedRatingCount={data.reviews.length}
+ imdbId={data.imdb_id}
+ imdbVotes={data.imdb_votes}
+ ratings={data.ratings}
+ />
+
-
-
0 ? averageReviewRating : null}
- popfeedRatingCount={data.reviews.length}
- imdbId={data.imdb_id}
- imdbVotes={data.imdb_votes}
- ratings={data.ratings}
- />
+
+
+ {@render reviewButton()}
-
- {@render reviewButton()}
-
-
-
-
- {#if data.trailer_url}
-
- {/if}
-
-
+
+ {#if data.trailer_url}
+
+ {/if}
+
-
- {#if data.item.overview}
-
- {/if}
+
+ {#if data.item.overview}
+
+ {/if}
@@ -314,9 +254,16 @@
/>
{/each}
-
+
{#each displayedReviews as review (review.uri)}
-
+ showFullReview(review)}
+ class="rounded-xl border border-white/10 bg-black/25 backdrop-blur-xl"
+ />
{/each}
@@ -367,7 +314,7 @@
- {#if !showAllRecommendations && data.recommendations.length > 10}
+ {#if !showAllRecommendations && data.recommendations.length > 5}
- {#if !expanded && items.length > 10}
+ {#if !expanded && items.length > 5}
(expanded = true)}
diff --git a/src/lib/components/MediaHero.svelte b/src/lib/components/MediaHero.svelte
new file mode 100644
index 0000000..f4747ea
--- /dev/null
+++ b/src/lib/components/MediaHero.svelte
@@ -0,0 +1,126 @@
+
+
+{#snippet titleContent()}
+ {#if titleIsHeading}
+
+ {title}
+
+ {:else}
+
+ {title}
+
+ {/if}
+
+ {#if titleLogoUrl && titleLogoOriginalUrl}
+
+
+
+
+ {/if}
+{/snippet}
+
+
diff --git a/src/lib/components/MenuItem.svelte b/src/lib/components/MenuItem.svelte
index d887e68..bf03f19 100644
--- a/src/lib/components/MenuItem.svelte
+++ b/src/lib/components/MenuItem.svelte
@@ -29,8 +29,12 @@
'group relative flex transition-colors',
variant === 'bottom'
? 'h-10 w-full items-center justify-center rounded-xl'
- : 'h-10 w-full items-center gap-3 rounded-lg p-2.5 text-sm font-semibold md:size-10',
- active ? 'text-accent-300' : 'text-base-300 hover:bg-white/10 hover:text-white',
+ : 'h-10 w-full items-center gap-3 rounded-lg bg-base-950/60 p-2.5 text-sm font-semibold shadow-lg shadow-black/20 backdrop-blur-md md:size-10',
+ active
+ ? 'text-accent-300'
+ : variant === 'sidebar'
+ ? 'text-base-200 hover:bg-base-950/80 hover:text-white'
+ : 'text-base-300 hover:bg-white/10 hover:text-white',
className
)
);
diff --git a/src/lib/components/Review.svelte b/src/lib/components/Review.svelte
index 3c13a42..086e71b 100644
--- a/src/lib/components/Review.svelte
+++ b/src/lib/components/Review.svelte
@@ -15,6 +15,7 @@
viewerDid = null,
showItem = true,
compact = false,
+ revealSpoilers = false,
onOpen,
class: className
}: {
@@ -22,6 +23,7 @@
viewerDid?: string | null;
showItem?: boolean;
compact?: boolean;
+ revealSpoilers?: boolean;
onOpen?: () => void;
class?: string;
} = $props();
@@ -46,7 +48,7 @@
let text = $derived(review.text.trim());
let hasSpoilerText = $derived(review.containsSpoilers && Boolean(text));
let spoilerRevealed = $state(false);
- let spoilerHidden = $derived(hasSpoilerText && !spoilerRevealed);
+ let spoilerHidden = $derived(hasSpoilerText && !revealSpoilers && !spoilerRevealed);
let liking = $state(false);
let interactionError = $state('');
// These are local snapshots so likes can update optimistically.
@@ -72,6 +74,14 @@
onOpen();
}
+ function handleSpoilerClick() {
+ if (onOpen) {
+ onOpen();
+ return;
+ }
+ spoilerRevealed = true;
+ }
+
async function toggleLike() {
interactionError = '';
if (!viewerDid) {
@@ -211,12 +221,14 @@
{#if spoilerHidden}
(spoilerRevealed = true)}
+ onclick={handleSpoilerClick}
class="absolute inset-0 flex size-full flex-col items-center justify-center gap-1 rounded-lg bg-base-950/65 px-4 text-center backdrop-blur-[2px] transition-colors hover:bg-base-950/55 focus-visible:outline-2 focus-visible:outline-offset-2 focus-visible:outline-accent-400"
>
This review may contain spoilers
- Click to show review
+
+ {onOpen ? 'Click to open review' : 'Click to show review'}
+
{/if}
diff --git a/src/lib/tmdb.server.ts b/src/lib/tmdb.server.ts
index dda2ec4..97d4965 100644
--- a/src/lib/tmdb.server.ts
+++ b/src/lib/tmdb.server.ts
@@ -196,7 +196,8 @@ function toMediaDetails(
function toMediaCredit(source: PersonCombinedCastCredit): MediaCredit {
return {
...toMediaSummary(source, fromTmdbMediaType(source.media_type)),
- order: 'order' in source ? source.order : 0
+ order: 'order' in source ? source.order : 0,
+ popularity: source.popularity
};
}
@@ -577,9 +578,19 @@ export async function getDetails(
return toMediaDetails(details, creativeWorkType);
}
+export async function getMediaHeader(tmdbId: number, creativeWorkType: SupportedCreativeWorkType) {
+ const cache = getPublicDataCache(MEDIA_DATA_TTL);
+ const details = await getMediaSource(tmdbId, creativeWorkType, cache);
+ return {
+ item: toMediaDetails(details, creativeWorkType),
+ logo: getTitleLogo(details)
+ };
+}
+
export async function getTvSeasonPage(tmdbId: number, seasonNumber: number) {
const cache = getPublicDataCache(TV_SCHEDULE_TTL);
- const [show, seasonSource] = await Promise.all([
+ const mediaCache = getPublicDataCache(MEDIA_DATA_TTL);
+ const [show, seasonSource, mediaDetails] = await Promise.all([
getTvScheduleSource(tmdbId, cache),
cachePublicData(cache, `tmdb:tv-season:v2:${tmdbId}:${seasonNumber}`, () =>
getClient().tv_seasons.details({
@@ -587,7 +598,8 @@ export async function getTvSeasonPage(tmdbId: number, seasonNumber: number) {
season_number: seasonNumber,
append_to_response: TV_SEASON_APPENDS
})
- )
+ ),
+ getMediaSource(tmdbId, 'tv_show', mediaCache)
]);
const seasons = (show.seasons ?? []).map(toTvSeason);
const navigableSeasons = seasons
@@ -608,6 +620,7 @@ export async function getTvSeasonPage(tmdbId: number, seasonNumber: number) {
return {
show: toMediaDetails(show, 'tv_show'),
+ logo: getTitleLogo(mediaDetails),
season,
episodes: seasonSource.episodes.flatMap((source) => {
const episode = toTvEpisode(source);
diff --git a/src/lib/types.ts b/src/lib/types.ts
index 684725b..f9aa6f6 100644
--- a/src/lib/types.ts
+++ b/src/lib/types.ts
@@ -55,6 +55,7 @@ export type TvSeasonSummary = {
export type MediaCredit = MediaSummary & {
order: number;
+ popularity: number;
};
export type ActorSummary = {
diff --git a/src/routes/[kind]/[id]/season/[season]/+page.svelte b/src/routes/[kind]/[id]/season/[season]/+page.svelte
index 06a866b..38e335f 100644
--- a/src/routes/[kind]/[id]/season/[season]/+page.svelte
+++ b/src/routes/[kind]/[id]/season/[season]/+page.svelte
@@ -2,6 +2,7 @@
import { resolve } from '$app/paths';
import { page } from '$app/state';
import Container from '$lib/components/Container.svelte';
+ import MediaHero from '$lib/components/MediaHero.svelte';
import TrailerDialog from '$lib/components/TrailerDialog.svelte';
import { calendarDayDifference, formatCalendarDate, relativeCalendarDate } from '$lib/dates';
import { backdropUrl, posterUrl, stillUrl } from '$lib/images';
@@ -13,6 +14,8 @@
let showId = $derived(`${data.show.tmdbId}-${slugify(data.show.title)}`);
let showUrl = $derived(resolve('/[kind]/[id]', { kind: 'tv', id: showId }));
let seasonPoster = $derived(data.season.poster ?? data.show.poster);
+ let episodeBackdrop = $derived(data.episodes.find((episode) => episode.still)?.still ?? null);
+ let seasonBackdrop = $derived(episodeBackdrop ?? data.show.backdrop);
let canonicalUrl = $derived(`${page.url.origin}${page.url.pathname}`);
let seasonStatus = $derived(getSeasonStatus());
let seasonDateRange = $derived(getSeasonDateRange());
@@ -81,55 +84,46 @@
{#if data.show.backdrop}
+ {:else if seasonPoster}
+
{/if}
-
+
-
-
+ {#if seasonPoster}
+
+
+ {/if}
- {#if seasonPoster || data.trailer_url}
+
+
+
+
+
+ {data.season.name}
+
- {#if seasonPoster}
-

- {/if}
-
- {#if data.trailer_url}
-
-
-
- {/if}
-
- {/if}
-
-
-
{#if seasonStatus}{seasonStatus}{/if}
{#if seasonStatus && seasonDateRange}
•
@@ -145,14 +139,18 @@
{data.network}
{/if}
-
- {data.season.name}
-
- {data.show.title}
+
+ {#if data.trailer_url}
+
+
+
+ {/if}
+
+
{#if data.season.overview}
-
+
Overview
{data.season.overview}
@@ -160,7 +158,7 @@
{/if}
-
+
Episodes
{#if data.episodes.length > 0}
diff --git a/src/routes/cast/[id]/+page.server.ts b/src/routes/cast/[id]/+page.server.ts
index 580ff97..bc4c652 100644
--- a/src/routes/cast/[id]/+page.server.ts
+++ b/src/routes/cast/[id]/+page.server.ts
@@ -21,7 +21,7 @@ export const load: PageServerLoad = async ({ params }) => {
seen.add(key);
return true;
})
- .sort((a, b) => b.order - a.order);
+ .sort((a, b) => b.popularity - a.popularity);
return { combinedCredits, personDetails };
} catch (cause) {
diff --git a/src/routes/cast/[id]/+page.svelte b/src/routes/cast/[id]/+page.svelte
index 8744e5e..b7d4ece 100644
--- a/src/routes/cast/[id]/+page.svelte
+++ b/src/routes/cast/[id]/+page.svelte
@@ -43,7 +43,8 @@
diff --git a/src/routes/profile/[actor]/review/[rkey]/+page.server.ts b/src/routes/profile/[actor]/review/[rkey]/+page.server.ts
index 8d327af..862be07 100644
--- a/src/routes/profile/[actor]/review/[rkey]/+page.server.ts
+++ b/src/routes/profile/[actor]/review/[rkey]/+page.server.ts
@@ -3,6 +3,7 @@ import type { ResourceUri } from '@atcute/lexicons';
import { isRecordKey } from '@atcute/lexicons/syntax';
import { contrail } from '$lib/contrail-client.server';
import { getReviewInteractions, toReview } from '$lib/reviews.server';
+import { getMediaHeader } from '$lib/tmdb.server';
import type { PageServerLoad } from './$types';
export const load: PageServerLoad = async ({ locals, params, parent }) => {
@@ -24,15 +25,21 @@ export const load: PageServerLoad = async ({ locals, params, parent }) => {
const review = toReview(reviewResponse.data, profile.handle);
if (!review) error(404, 'Review not found');
- const interactions = await getReviewInteractions(reviewUri, locals.did).catch((cause) => {
- console.error('Could not load review interactions from Contrail', cause);
- return {
- likeCount: 0,
- commentCount: 0,
- viewerLikeUri: null,
- comments: []
- };
- });
+ const [interactions, mediaHeader] = await Promise.all([
+ getReviewInteractions(reviewUri, locals.did).catch((cause) => {
+ console.error('Could not load review interactions from Contrail', cause);
+ return {
+ likeCount: 0,
+ commentCount: 0,
+ viewerLikeUri: null,
+ comments: []
+ };
+ }),
+ getMediaHeader(review.media.tmdbId, review.media.creativeWorkType).catch((cause) => {
+ console.error('Could not load media header from TMDB', cause);
+ return null;
+ })
+ ]);
return {
review: {
@@ -44,6 +51,7 @@ export const load: PageServerLoad = async ({ locals, params, parent }) => {
},
createdAt: reviewResponse.data.value.createdAt
},
+ mediaHeader,
...interactions
};
};
diff --git a/src/routes/profile/[actor]/review/[rkey]/+page.svelte b/src/routes/profile/[actor]/review/[rkey]/+page.svelte
index fff517f..640a35b 100644
--- a/src/routes/profile/[actor]/review/[rkey]/+page.svelte
+++ b/src/routes/profile/[actor]/review/[rkey]/+page.svelte
@@ -4,12 +4,12 @@
import { oauthLogin } from '$lib/atproto/oauth.remote';
import Avatar from '$lib/components/Avatar.svelte';
import Container from '$lib/components/Container.svelte';
+ import MediaHero from '$lib/components/MediaHero.svelte';
import Rating from '$lib/components/Rating.svelte';
- import { posterUrl } from '$lib/images';
import { likeReview, unlikeReview } from '$lib/review-interactions.remote';
import { slugify, toMediaRouteKind } from '$lib/utils';
import { AtprotoLoginModal } from '@foxui/social';
- import { Heart, Image as ImageIcon, MessageCircle } from '@lucide/svelte';
+ import { Heart, MessageCircle } from '@lucide/svelte';
import type { PageData } from './$types';
let { data }: { data: PageData } = $props();
@@ -26,7 +26,19 @@
let likeCount = $state(data.likeCount);
let spoilerRevealed = $state(false);
- let imageUrl = $derived(posterUrl(data.review.media.poster, 'w342'));
+ let releaseYear = $derived(data.mediaHeader?.item.releaseDate?.slice(0, 4) || null);
+ let seasonLabel = $derived(
+ data.mediaHeader?.item.numberOfSeasons
+ ? `${data.mediaHeader.item.numberOfSeasons} ${data.mediaHeader.item.numberOfSeasons === 1 ? 'season' : 'seasons'}`
+ : null
+ );
+ let mediaFacts = $derived(
+ [
+ releaseYear,
+ formatRuntime(data.mediaHeader?.item.runtime ?? null),
+ data.review.media.creativeWorkType === 'tv_show' ? seasonLabel : null
+ ].filter((fact): fact is string => fact !== null)
+ );
let reviewText = $derived(data.review.text.trim());
let spoilerHidden = $derived(
data.review.containsSpoilers && Boolean(reviewText) && !spoilerRevealed
@@ -50,6 +62,15 @@
return Number.isNaN(date.getTime()) ? '' : dateFormatter.format(date);
}
+ function formatRuntime(runtime: number | null) {
+ if (!runtime) return null;
+
+ const hours = Math.floor(runtime / 60);
+ const minutes = runtime % 60;
+ if (!hours) return `${minutes}m`;
+ return minutes ? `${hours}h ${minutes}m` : `${hours}h`;
+ }
+
async function login(handle: string) {
const returnTo = page.url.pathname + page.url.search + page.url.hash;
const { url } = await oauthLogin({ handle: handle.trim(), returnTo });
@@ -102,41 +123,35 @@
-
-
-
+
+
+ {#if mediaFacts.length > 0}
+
- {#if imageUrl}
-

- {:else}
-
-
-
- {/if}
-
+ {#each mediaFacts as fact, index (fact)}
+ {#if index > 0}
+
•
+ {/if}
+
{fact}
+ {/each}
+
+ {/if}
+
+
+
-
+
{#if reviewText}
-
+
{#if data.comments.length > 0}