Skip to content

Commit e4198f8

Browse files
vladjercatrakt-bot[bot]
authored andcommitted
feat(profile): add Pantheon follows leaderboard behind a flag
Rank the people you follow by watch time, in a drawer opened from an avatar pill on your own profile. Reuses the social-activity pill (AvatarPill, fed by the already-loaded following list, so the leaderboard query only fires when the drawer opens) and the flair-chip decoration for the top-3 medals. - VIP follows rank by minutes; free follows are unranked, blurred with a placeholder, and grouped under a "Free members" divider. - The viewer is always woven in: VIP viewers match on minutes from their own stats, free viewers on locally computed plays, with a "go VIP" CTA and an "unlock your watch-time rank" hint. - Top-3 get gold/silver/bronze medals; extra glow when the podium spot is the viewer. - Gated behind the Leaderboard preview flag (Pantheon).
1 parent 7208935 commit e4198f8

19 files changed

Lines changed: 1066 additions & 7 deletions

projects/client/i18n/meta/en.json

Lines changed: 40 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -4395,6 +4395,38 @@
43954395
"default": "All time",
43964396
"description": "Text shown to indicate the users all-time stats."
43974397
},
4398+
"stat_label_plays": {
4399+
"default": "plays",
4400+
"description": "Label for the number of plays in a stat row."
4401+
},
4402+
"stat_label_hours": {
4403+
"default": "hours",
4404+
"description": "Label for a watch-time value expressed in hours."
4405+
},
4406+
"text_following": {
4407+
"default": "Following",
4408+
"description": "Label for the follows pill on a profile that opens the leaderboard."
4409+
},
4410+
"text_leaderboard_free_section": {
4411+
"default": "Free members",
4412+
"description": "Divider label separating ranked VIP entries from unranked free members on the leaderboard."
4413+
},
4414+
"text_leaderboard_upsell_footer": {
4415+
"default": "Upgrade to VIP to see where you rank amongst your friends",
4416+
"description": "Upsell text on the pinned free-viewer card at the bottom of the leaderboard drawer."
4417+
},
4418+
"header_leaderboard": {
4419+
"default": "Pantheon",
4420+
"description": "Product feature name for the drawer that ranks the people a user follows by watch time. Keep as the loanword 'Pantheon' (transliterate if needed); do not translate literally to 'temple'."
4421+
},
4422+
"button_label_open_leaderboard": {
4423+
"default": "Open follows leaderboard",
4424+
"description": "Accessible label for the button that opens the leaderboard drawer."
4425+
},
4426+
"text_leaderboard_you": {
4427+
"default": "You",
4428+
"description": "Label marking the viewer's own entry in the leaderboard."
4429+
},
43984430
"text_view_all": {
43994431
"default": "View all",
44004432
"description": "Text for the button that allows users to view all items in a list."
@@ -7983,6 +8015,14 @@
79838015
"default": "Start rewatching sessions for shows you've already seen, check in or mark watched episodes directly from the session drawer.",
79848016
"description": "Description for the Rewatch preview feature."
79858017
},
8018+
"preview_feature_title_leaderboard": {
8019+
"default": "Pantheon",
8020+
"description": "Product feature name for the follows watch-time ranking preview feature. Keep as the loanword 'Pantheon' (transliterate if needed); do not translate literally to 'temple'."
8021+
},
8022+
"preview_feature_description_leaderboard": {
8023+
"default": "Rank the people you follow by watch time and see where you land among them.",
8024+
"description": "Description for the Pantheon (follows watch-time ranking) preview feature."
8025+
},
79868026
"header_official_links": {
79878027
"default": "Official Links",
79888028
"description": "Header for the official links section, with official links for a show or movie, like the official site, instagram, etc.."

projects/client/src/lib/components/avatar-pill/AvatarPill.svelte

Lines changed: 46 additions & 3 deletions
Original file line numberDiff line numberDiff line change
@@ -79,11 +79,19 @@
7979
8080
.trakt-avatar-pill {
8181
--height-pill-label: calc(var(--font-size-text) + var(--ni-2));
82+
--pill-avatar-size: var(--ni-28);
83+
84+
// Preferred avatar overlap. Tightened by the container query below when the
85+
// pill's container (opt-in via `container: avatar-pill / inline-size`) is
86+
// too narrow to fit the pill at this spacing.
87+
--avatar-overlap: calc(var(--pill-avatar-size) * -0.5);
8288
8389
display: inline-flex;
8490
align-items: center;
8591
gap: var(--ni-4);
8692
height: var(--ni-36);
93+
max-width: 100%;
94+
min-width: 0;
8795
padding: 0 var(--ni-8);
8896
box-sizing: border-box;
8997
border-radius: var(--border-radius-xxl);
@@ -110,6 +118,7 @@
110118
display: inline-flex;
111119
align-items: center;
112120
pointer-events: none;
121+
flex: 0 0 auto;
113122
114123
margin-inline-end: var(--ni-4);
115124
@@ -121,16 +130,43 @@
121130
}
122131
}
123132
124-
.avatar {
125-
--pill-avatar-size: var(--ni-28);
133+
// When the pill sits in an opted-in container, size it to the space
134+
// available: `100cqi` is the container's inline size, minus any inline space
135+
// the container reserves for siblings the pill must not sit under
136+
// (`--avatar-pill-reserved-inline`). The avatar overlap then scales from the
137+
// preferred 50% while there's room toward ~78% as the space narrows, so all
138+
// avatars stay visible without overflowing. Inert when no `avatar-pill`
139+
// container ancestor exists.
140+
@container avatar-pill (min-width: 0px) {
141+
.trakt-avatar-pill {
142+
--pill-available: calc(
143+
100cqi - var(--avatar-pill-reserved-inline, 0px)
144+
);
145+
146+
max-width: var(--pill-available);
147+
}
126148
149+
.avatar-stack {
150+
--avatar-overlap: calc(
151+
-1 *
152+
clamp(
153+
var(--pill-avatar-size) * 0.5,
154+
var(--pill-avatar-size) * 0.78 -
155+
(var(--pill-available) - 210px) * 0.5,
156+
var(--pill-avatar-size) * 0.78
157+
)
158+
);
159+
}
160+
}
161+
162+
.avatar {
127163
position: relative;
128164
129165
width: var(--pill-avatar-size);
130166
height: var(--pill-avatar-size);
131167
flex: 0 0 var(--pill-avatar-size);
132168
133-
margin-inline-start: calc(var(--pill-avatar-size) * -0.5);
169+
margin-inline-start: var(--avatar-overlap);
134170
135171
border-radius: 50%;
136172
overflow: hidden;
@@ -155,6 +191,8 @@
155191
align-items: center;
156192
gap: var(--ni-4);
157193
height: var(--height-pill-label);
194+
min-width: 0;
195+
flex: 0 1 auto;
158196
padding-inline-end: var(--ni-4);
159197
160198
&.is-text-only {
@@ -164,15 +202,20 @@
164202
165203
.pill-text {
166204
opacity: 0.85;
205+
overflow: hidden;
206+
text-overflow: ellipsis;
207+
white-space: nowrap;
167208
}
168209
169210
.pill-count {
211+
flex: 0 0 auto;
170212
font-variant-numeric: tabular-nums;
171213
}
172214
173215
.pill-caret {
174216
display: inline-flex;
175217
align-items: center;
218+
flex: 0 0 auto;
176219
177220
// Cancel the pill's flex gap so the caret sits flush against the label.
178221
margin-inline-start: calc(-1 * var(--ni-4));
Lines changed: 45 additions & 4 deletions
Original file line numberDiff line numberDiff line change
@@ -1,8 +1,49 @@
11
<script lang="ts">
2+
import SparkleStarIcon from "$lib/components/icons/SparkleStarIcon.svelte";
23
import * as m from "$lib/features/i18n/messages";
3-
import VipBadgeContent from "./_internal/VipBadgeContent.svelte";
44
</script>
55

6-
<VipBadgeContent>
7-
{m.badge_text_get_vip()}
8-
</VipBadgeContent>
6+
<div class="trakt-vip-upsell-badge">
7+
<span class="upsell-sparkle" aria-hidden="true">
8+
<SparkleStarIcon />
9+
</span>
10+
<p class="uppercase">{m.badge_text_get_vip()}</p>
11+
</div>
12+
13+
<style lang="scss">
14+
.trakt-vip-upsell-badge {
15+
display: inline-flex;
16+
align-items: center;
17+
gap: var(--gap-xs);
18+
19+
padding: var(--ni-8) var(--ni-12);
20+
height: var(--ni-28);
21+
box-sizing: border-box;
22+
border-radius: var(--border-radius-xl);
23+
24+
// Inviting purple gradient + soft glow instead of the flat red - the upsell
25+
// should feel aspirational, not like a paywall.
26+
background: linear-gradient(
27+
135deg,
28+
var(--purple-400),
29+
var(--purple-600)
30+
);
31+
color: var(--color-foreground-vip-badge);
32+
box-shadow: 0 var(--ni-2) var(--ni-12)
33+
color-mix(in srgb, var(--purple-500) 45%, transparent);
34+
35+
p {
36+
font-weight: 700;
37+
white-space: nowrap;
38+
}
39+
40+
.upsell-sparkle {
41+
display: inline-flex;
42+
43+
:global(svg) {
44+
width: var(--ni-16);
45+
height: var(--ni-16);
46+
}
47+
}
48+
}
49+
</style>
Lines changed: 12 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,12 @@
1+
<svg
2+
xmlns="http://www.w3.org/2000/svg"
3+
width="24"
4+
height="24"
5+
viewBox="0 0 256 256"
6+
fill="none"
7+
>
8+
<path
9+
fill="currentColor"
10+
d="M197.58,129.06,146,110l-19-51.62a15.92,15.92,0,0,0-29.88,0L78,110l-51.62,19a15.92,15.92,0,0,0,0,29.88L78,178l19,51.62a15.92,15.92,0,0,0,29.88,0L146,178l51.62-19a15.92,15.92,0,0,0,0-29.88ZM137,164.22a8,8,0,0,0-4.74,4.74L112,223.85,91.78,169A8,8,0,0,0,87,164.22L32.15,144,87,123.78A8,8,0,0,0,91.78,119L112,64.15,132.22,119a8,8,0,0,0,4.74,4.74L191.85,144Z"
11+
/>
12+
</svg>
Lines changed: 12 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,12 @@
1+
<svg
2+
xmlns="http://www.w3.org/2000/svg"
3+
width="24"
4+
height="24"
5+
viewBox="0 0 256 256"
6+
fill="none"
7+
>
8+
<path
9+
fill="currentColor"
10+
d="M240,56v64a8,8,0,0,1-16,0V75.31l-82.34,82.35a8,8,0,0,1-11.32,0L96,123.31,29.66,189.66a8,8,0,0,1-11.32-11.32l72-72a8,8,0,0,1,11.32,0L136,140.69,212.69,64H168a8,8,0,0,1,0-16h64A8,8,0,0,1,240,56Z"
11+
/>
12+
</svg>

projects/client/src/lib/features/feature-flag/models/FeatureFlag.ts

Lines changed: 1 addition & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -7,4 +7,5 @@ export enum FeatureFlag {
77
SmartRelated = 'smart-related',
88
SmartRecommendations = 'smart-recommendations',
99
Rewatching = 'rewatching',
10+
Leaderboard = 'leaderboard',
1011
}

projects/client/src/lib/features/feature-flag/models/featureFlagDefinitions.ts

Lines changed: 6 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -3,6 +3,7 @@ import CalendarIcon from '$lib/components/icons/CalendarIcon.svelte';
33
import EditModeIcon from '$lib/components/icons/EditModeIcon.svelte';
44
import FastRewindIcon from '$lib/components/icons/FastRewindIcon.svelte';
55
import FavoriteIcon from '$lib/components/icons/FavoriteIcon.svelte';
6+
import PeopleIcon from '$lib/components/icons/PeopleIcon.svelte';
67
import PlexLibraryIcon from '$lib/components/icons/PlexLibraryIcon.svelte';
78
import { m } from '$lib/features/i18n/messages.ts';
89
import { UrlBuilder } from '$lib/utils/url/UrlBuilder.ts';
@@ -98,4 +99,9 @@ export const featureFlagDefinitions: FeatureFlagDefinitions = {
9899
title: () => m.preview_feature_title_rewatch(),
99100
description: () => m.preview_feature_description_rewatch(),
100101
},
102+
[FeatureFlag.Leaderboard]: {
103+
icon: PeopleIcon,
104+
title: () => m.preview_feature_title_leaderboard(),
105+
description: () => m.preview_feature_description_leaderboard(),
106+
},
101107
};

projects/client/src/lib/sections/profile-banner/ProfilePageBanner.svelte

Lines changed: 21 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -2,10 +2,13 @@
22
import VipBadge from "$lib/components/badge/VipBadge.svelte";
33
import SettingsButton from "$lib/components/buttons/settings/SettingsButton.svelte";
44
import ShareButton from "$lib/components/buttons/share/ShareButton.svelte";
5+
import { FeatureFlag } from "$lib/features/feature-flag/models/FeatureFlag.ts";
6+
import { useFeatureFlag } from "$lib/features/feature-flag/useFeatureFlag.ts";
57
import { useIsMe } from "$lib/features/auth/stores/useIsMe";
68
import { useUser } from "$lib/features/auth/stores/useUser";
79
import * as m from "$lib/features/i18n/messages.ts";
810
import RenderFor from "$lib/guards/RenderFor.svelte";
11+
import LeaderboardPill from "$lib/sections/profile/leaderboard/LeaderboardPill.svelte";
912
import MatchPill from "$lib/sections/profile/components/MatchPill.svelte";
1013
import ProfileAbout from "$lib/sections/profile/components/ProfileAbout.svelte";
1114
import { toDisplayableName } from "$lib/utils/profile/toDisplayableName";
@@ -31,6 +34,9 @@
3134
const { isMe } = $derived(useIsMe(slug));
3235
const { followStatus } = $derived(useFollowUserRequest(slug));
3336
37+
const { isEnabled } = useFeatureFlag();
38+
const leaderboardEnabled = isEnabled(FeatureFlag.Leaderboard);
39+
3440
const shareableSlug = $derived($isMe ? $user.slug : slug);
3541
const isBlocked = $derived($blocked.has(slug));
3642
const isPending = $derived($followStatus === "pending");
@@ -74,6 +80,8 @@
7480
<RenderFor audience="authenticated">
7581
<MatchPill {slug} />
7682
</RenderFor>
83+
{:else if $isMe && $leaderboardEnabled}
84+
<LeaderboardPill />
7785
{/if}
7886
</div>
7987
<div class="profile-actions">
@@ -137,6 +145,13 @@
137145
align-items: center;
138146
gap: var(--gap-s);
139147
148+
// Query container for the leaderboard pill. Its width is driven by layout
149+
// (not the pill's content), so the pill can size to the space available
150+
// without a feedback loop. The pill may use the whole row except the
151+
// profile image and its gap, so reserve that inline space.
152+
container: avatar-pill / inline-size;
153+
--avatar-pill-reserved-inline: calc(var(--ni-64) + var(--gap-s));
154+
140155
:global(.trakt-profile-image) {
141156
display: flex;
142157
flex-direction: column;
@@ -154,6 +169,8 @@
154169
gap: var(--gap-xs);
155170
flex-wrap: wrap;
156171
172+
--avatar-pill-reserved-inline: calc(var(--ni-40) + var(--gap-xs));
173+
157174
span.ellipsis {
158175
white-space: normal;
159176
}
@@ -184,6 +201,10 @@
184201
gap: var(--gap-s);
185202
flex-shrink: 0;
186203
204+
// Pin to the top so the icons clear the taller details column (name +
205+
// location + leaderboard/match pill) instead of overlapping it.
206+
align-self: flex-start;
207+
187208
position: relative;
188209
z-index: var(--layer-raised);
189210

projects/client/src/lib/sections/profile/ProfileDrawer.svelte

Lines changed: 8 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -1,19 +1,25 @@
11
<script lang="ts">
22
import { page } from "$app/state";
3+
import { FeatureFlag } from "$lib/features/feature-flag/models/FeatureFlag.ts";
4+
import { useFeatureFlag } from "$lib/features/feature-flag/useFeatureFlag.ts";
35
import ScreenTimeDrawerHost from "../stats/ScreenTimeDrawerHost.svelte";
46
import MatchDrawerHost from "./_internal/MatchDrawerHost.svelte";
57
import {
68
ProfileDrawers,
79
profileDrawerNavigation,
810
} from "./_internal/profileDrawerNavigation.ts";
911
import ActivityDrawerHost from "./components/_internal/drawers/ActivityDrawerHost.svelte";
12+
import LeaderboardDrawerHost from "./leaderboard/LeaderboardDrawerHost.svelte";
1013
import type { DisplayableProfileProps } from "./DisplayableProfileProps.ts";
1114
1215
const { slug, profile }: DisplayableProfileProps = $props();
1316
1417
const { drawer, sourceCommentId, close } = $derived(
1518
profileDrawerNavigation(page.url.searchParams),
1619
);
20+
21+
const { isEnabled } = useFeatureFlag();
22+
const leaderboardEnabled = isEnabled(FeatureFlag.Leaderboard);
1723
</script>
1824

1925
{#if drawer === ProfileDrawers.ScreenTime}
@@ -22,4 +28,6 @@
2228
<ActivityDrawerHost {sourceCommentId} onClose={close} />
2329
{:else if drawer === ProfileDrawers.Match}
2430
<MatchDrawerHost {slug} {profile} onClose={close} />
31+
{:else if drawer === ProfileDrawers.Leaderboard && $leaderboardEnabled}
32+
<LeaderboardDrawerHost {slug} onClose={close} />
2533
{/if}

projects/client/src/lib/sections/profile/_internal/profileDrawerNavigation.ts

Lines changed: 5 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -7,6 +7,7 @@ export enum ProfileDrawers {
77
Activity = 'activity',
88
ScreenTime = 'screen-time',
99
Match = 'match',
10+
Leaderboard = 'leaderboard',
1011
}
1112

1213
const profileDrawerParams = {
@@ -21,6 +22,8 @@ function mapToDrawer(value: string | Nil) {
2122
return ProfileDrawers.ScreenTime;
2223
case ProfileDrawers.Match:
2324
return ProfileDrawers.Match;
25+
case ProfileDrawers.Leaderboard:
26+
return ProfileDrawers.Leaderboard;
2427
default:
2528
return null;
2629
}
@@ -43,5 +46,7 @@ export function profileDrawerNavigation(searchParams?: URLSearchParams) {
4346
),
4447
buildScreenTimeDrawerLink: () => buildDrawerLink(ProfileDrawers.ScreenTime),
4548
buildMatchDrawerLink: () => buildDrawerLink(ProfileDrawers.Match),
49+
buildLeaderboardDrawerLink: () =>
50+
buildDrawerLink(ProfileDrawers.Leaderboard),
4651
};
4752
}

0 commit comments

Comments
 (0)