@playpilot/tpi 5.15.0 → 5.16.2

This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
Files changed (40) hide show
  1. package/dist/link-injections.js +11 -10
  2. package/package.json +2 -1
  3. package/release.js +3 -1
  4. package/src/lib/enums/SplitTest.ts +4 -0
  5. package/src/lib/fakeData.ts +70 -0
  6. package/src/lib/linkInjection.ts +11 -29
  7. package/src/lib/modal.ts +97 -0
  8. package/src/lib/playlink.ts +4 -1
  9. package/src/lib/types/participant.d.ts +14 -0
  10. package/src/lib/types/title.d.ts +3 -1
  11. package/src/routes/components/Description.svelte +1 -0
  12. package/src/routes/components/Icons/IconArrow.svelte +22 -0
  13. package/src/routes/components/Icons/IconClose.svelte +1 -1
  14. package/src/routes/components/Icons/IconIMDb.svelte +9 -1
  15. package/src/routes/components/ListTitle.svelte +204 -0
  16. package/src/routes/components/Modal.svelte +63 -13
  17. package/src/routes/components/Participant.svelte +92 -0
  18. package/src/routes/components/ParticipantModal.svelte +31 -0
  19. package/src/routes/components/PlaylinkIcon.svelte +41 -0
  20. package/src/routes/components/PlaylinkLabel.svelte +37 -0
  21. package/src/routes/components/Playlinks.svelte +1 -3
  22. package/src/routes/components/Rails/ParticipantsRail.svelte +56 -0
  23. package/src/routes/components/Rails/Rail.svelte +91 -0
  24. package/src/routes/components/Rails/SimilarRail.svelte +16 -0
  25. package/src/routes/components/Rails/TitlesRail.svelte +95 -0
  26. package/src/routes/components/Tabs.svelte +47 -0
  27. package/src/routes/components/Title.svelte +20 -17
  28. package/src/routes/components/TitleModal.svelte +3 -3
  29. package/src/routes/components/TitlePoster.svelte +30 -0
  30. package/src/tests/lib/linkInjection.test.js +10 -22
  31. package/src/tests/lib/modal.test.js +148 -0
  32. package/src/tests/lib/playlink.test.js +25 -10
  33. package/src/tests/routes/components/ListTitle.test.js +84 -0
  34. package/src/tests/routes/components/Modal.test.js +51 -19
  35. package/src/tests/routes/components/PlaylinkIcon.test.js +27 -0
  36. package/src/tests/routes/components/PlaylinkLabel.test.js +19 -0
  37. package/src/tests/routes/components/Rails/ParticipantsRail.test.js +41 -0
  38. package/src/tests/routes/components/Rails/TitleRail.test.js +38 -0
  39. package/src/tests/routes/components/Title.test.js +6 -0
  40. package/src/tests/routes/components/TitlePoster.test.js +20 -0
@@ -1,6 +1,7 @@
1
1
  <script lang="ts">
2
2
  import { fade, fly, scale, type TransitionConfig } from 'svelte/transition'
3
3
  import IconClose from './Icons/IconClose.svelte'
4
+ import IconArrow from './Icons/IconArrow.svelte'
4
5
  import RoundButton from './RoundButton.svelte'
5
6
  import SwipeHandle from './SwipeHandle.svelte'
6
7
  import { onMount, setContext, type Snippet } from 'svelte'
@@ -8,23 +9,35 @@
8
9
  import { isInSplitTestVariant } from '$lib/splitTest'
9
10
  import { SplitTest } from '$lib/enums/SplitTest'
10
11
  import { mobileBreakpoint } from '$lib/constants'
12
+ import { destroyAllModals, getPreviousModal, goBackToPreviousModal } from '$lib/modal'
11
13
 
12
14
  interface Props {
13
15
  children: Snippet
14
16
  bubble?: Snippet | null
15
17
  prepend?: Snippet | null
16
18
  tall?: boolean
17
- onclose?: () => void
19
+ closeButtonStyle?: 'shadow' | 'flat'
20
+ initialScrollPosition?: number
18
21
  onscroll?: () => void
19
22
  }
20
23
 
21
- const { children, bubble, prepend, tall = false, onclose = () => null, onscroll = () => null }: Props = $props()
24
+ const {
25
+ children,
26
+ bubble,
27
+ prepend,
28
+ tall = false,
29
+ closeButtonStyle = 'shadow',
30
+ initialScrollPosition = 0,
31
+ onscroll = () => null,
32
+ }: Props = $props()
22
33
 
23
34
  const inlineBubble = isInSplitTestVariant(SplitTest.TopScrollFormat, 1)
24
35
 
25
36
  let windowWidth = $state(0)
37
+ let modalElement: HTMLElement | null = $state(null)
26
38
  let dialogElement: HTMLElement | null = $state(null)
27
39
  let dialogOffset: number = $state(0)
40
+ let hasPreviousModal = $state(false)
28
41
 
29
42
  const isMobile = $derived(windowWidth < mobileBreakpoint)
30
43
 
@@ -37,9 +50,18 @@
37
50
  const baseOverflowStyle = document.body.style.overflowY
38
51
  document.body.style.overflowY = 'hidden'
39
52
 
53
+ hasPreviousModal = !!getPreviousModal()
54
+
55
+ requestAnimationFrame(setInitialScrollPosition)
56
+
40
57
  return () => document.body.style.overflowY = baseOverflowStyle || ''
41
58
  })
42
59
 
60
+ function setInitialScrollPosition(): void {
61
+ const scrollableElement = window.innerWidth > mobileBreakpoint ? modalElement : dialogElement
62
+ scrollableElement?.scrollTo(0, initialScrollPosition)
63
+ }
64
+
43
65
  function scaleOrFly(node: Element, options: { y: number } = { y: 0 }): TransitionConfig {
44
66
  if (prefersReducedMotion.current) return fade(node, { duration: 0 })
45
67
 
@@ -48,9 +70,9 @@
48
70
  }
49
71
  </script>
50
72
 
51
- <svelte:window onkeydown={({ key }) => { if (key === 'Escape') onclose() }} bind:innerWidth={windowWidth} />
73
+ <svelte:window onkeydown={({ key }) => { if (key === 'Escape') destroyAllModals() }} bind:innerWidth={windowWidth} />
52
74
 
53
- <div class="modal" style:--dialog-offset="{dialogOffset}px" transition:fade|global={{ duration: 150 }} class:has-bubble={!!bubble && inlineBubble} class:has-prepend={!!prepend}>
75
+ <div class="modal" style:--dialog-offset="{dialogOffset}px" transition:fade|global={{ duration: 150 }} bind:this={modalElement} class:has-bubble={!!bubble && inlineBubble} class:has-prepend={!!prepend}>
54
76
  {#if prepend}
55
77
  <div class="prepend" transition:scaleOrFly|global={{ y: -10 }}>
56
78
  {@render prepend()}
@@ -65,13 +87,21 @@
65
87
 
66
88
  {#if isMobile}
67
89
  <div class="swipe-handle" transition:scaleOrFly|global>
68
- <SwipeHandle target={dialogElement!} onpassed={() => onclose()} />
90
+ <SwipeHandle target={dialogElement!} onpassed={() => destroyAllModals()} />
69
91
  </div>
70
92
  {/if}
71
93
 
72
94
  <div class="dialog" class:tall {onscroll} bind:this={dialogElement} role="dialog" aria-labelledby="title" transition:scaleOrFly|global={{ y: window.innerHeight }} data-view-transition-new>
73
- <div class="close">
74
- <RoundButton onclick={() => onclose()}>
95
+ {#if hasPreviousModal}
96
+ <div class="close back {closeButtonStyle}">
97
+ <RoundButton onclick={() => goBackToPreviousModal()} aria-label="Back">
98
+ <IconArrow direction="left" />
99
+ </RoundButton>
100
+ </div>
101
+ {/if}
102
+
103
+ <div class="close {closeButtonStyle}">
104
+ <RoundButton onclick={() => destroyAllModals()} aria-label="Close">
75
105
  <IconClose />
76
106
  </RoundButton>
77
107
  </div>
@@ -81,7 +111,7 @@
81
111
 
82
112
  <!-- svelte-ignore a11y_click_events_have_key_events -->
83
113
  <!-- svelte-ignore a11y_no_static_element_interactions -->
84
- <div class="backdrop" onclick={() => onclose()}></div>
114
+ <div class="backdrop" onclick={() => destroyAllModals()}></div>
85
115
  </div>
86
116
 
87
117
  <style lang="scss">
@@ -98,9 +128,9 @@
98
128
  align-items: center;
99
129
  overflow: auto;
100
130
  top: 0;
131
+ right: 0;
132
+ bottom: 0;
101
133
  left: 0;
102
- width: 100%;
103
- height: 100%;
104
134
  background: var(--playpilot-detail-backdrop, rgba(0, 0, 0, 0.65));
105
135
 
106
136
  @media (min-width: $max-width) {
@@ -118,8 +148,9 @@
118
148
  width: 100%;
119
149
  max-width: $max-width;
120
150
  max-height: 80vh;
121
- overflow: auto;
122
151
  margin-top: auto;
152
+ padding-bottom: env(safe-area-inset-bottom);
153
+ overflow: auto;
123
154
  border-radius: var(--playpilot-detail-border-radius, margin(1) margin(1) 0 0);
124
155
  background: var(--playpilot-detail-background, var(--playpilot-light));
125
156
  transition: transform 200ms;
@@ -129,10 +160,11 @@
129
160
  }
130
161
 
131
162
  @media (min-width: $max-width) {
163
+ max-height: unset;
132
164
  margin-top: 0;
133
- border-radius: var(--playpilot-detail-border-radius, margin(1));
165
+ padding-bottom: 0;
134
166
  overflow: visible;
135
- max-height: unset;
167
+ border-radius: var(--playpilot-detail-border-radius, margin(1));
136
168
  }
137
169
 
138
170
  &.tall {
@@ -193,6 +225,24 @@
193
225
  &:hover {
194
226
  filter: brightness(1.1);
195
227
  }
228
+
229
+ &.flat {
230
+ --playpilot-button-background: transparent;
231
+ --playpilot-button-shadow: none;
232
+ }
233
+
234
+ &.back {
235
+ right: auto;
236
+ left: margin(1);
237
+
238
+ &.flat {
239
+ left: margin(0.5);
240
+
241
+ :global(svg) {
242
+ margin: 0;
243
+ }
244
+ }
245
+ }
196
246
  }
197
247
 
198
248
  .prepend {
@@ -0,0 +1,92 @@
1
+ <script lang="ts">
2
+ import { heading } from '$lib/actions/heading'
3
+ import { SplitTest } from '$lib/enums/SplitTest'
4
+ import { openModal } from '$lib/modal'
5
+ import { trackSplitTestView } from '$lib/splitTest'
6
+ import type { ParticipantData } from '$lib/types/participant'
7
+ import ListTitle from './ListTitle.svelte'
8
+
9
+ interface Props {
10
+ participant: ParticipantData
11
+ }
12
+
13
+ const { participant }: Props = $props()
14
+
15
+ const { name, birth_date, death_date } = $derived(participant)
16
+
17
+ trackSplitTestView(SplitTest.ParticipantPlaylinkFormat)
18
+
19
+ function formatDate(dateString: string): string {
20
+ const date = new Date(dateString)
21
+ return date.toLocaleDateString(undefined, { year: 'numeric', month: 'long', day: 'numeric' })
22
+ }
23
+ </script>
24
+
25
+ <div class="header">
26
+ <div class="heading" use:heading={2} id="name">{name}</div>
27
+
28
+ {#if birth_date}
29
+ <p class="dates">
30
+ Born on <strong>{formatDate(birth_date)}</strong>{#if death_date}, died on <strong>{formatDate(death_date)}</strong>{/if}
31
+ </p>
32
+ {/if}
33
+ </div>
34
+
35
+ <div class="content">
36
+ <div class="heading small" use:heading={3} id="credits">Credits</div>
37
+
38
+ <div class="list">
39
+ {#each window.PlayPilotLinkInjections?.evaluated_link_injections?.map(i => i.title_details) || [] as title}
40
+ {#if title}
41
+ <ListTitle {title} onclick={(event) => openModal({ event, data: title })} />
42
+ {/if}
43
+ {/each}
44
+ </div>
45
+ </div>
46
+
47
+ <style lang="scss">
48
+ .header {
49
+ padding: margin(4) margin(1) margin(2);
50
+ background: linear-gradient(to bottom, var(--playpilot-detail-background-light, var(--playpilot-lighter)), transparent);
51
+ border-radius: var(--playpilot-detail-border-radius, margin(1) margin(1) 0 0);
52
+ font-family: var(--playpilot-detail-font-family, var(--playpilot-font-family));
53
+ font-weight: var(--playpilot-detail-font-weight, normal);
54
+ font-size: var(--playpilot-detail-font-size, 14px);
55
+ line-height: var(--playpilot-participant-description-line-height, normal);
56
+ color: var(--playpilot-detail-text-color, var(--playpilot-text-color));
57
+ }
58
+
59
+ .heading {
60
+ margin: 0;
61
+ font-family: var(--playpilot-detail-title-font-family, var(--playpilot-font-family));
62
+ font-weight: var(--playpilot-detail-title-font-weight, lighter);
63
+ font-size: var(--playpilot-detail-title-font-size, margin(1.5));
64
+ line-height: normal;
65
+ font-style: var(--playpilot-detail-title-font-style, normal);
66
+
67
+ &.small {
68
+ margin: 0 0 margin(0.5);
69
+ font-size: var(--playpilot-detail-title-small-font-size, margin(1.25));
70
+ }
71
+ }
72
+
73
+ .dates {
74
+ margin: margin(0.5) 0 0;
75
+ }
76
+
77
+ .content {
78
+ padding: 0 margin(1) margin(1);
79
+ color: var(--playpilot-detail-text-color, var(--playpilot-text-color));
80
+ font-family: var(--playpilot-detail-font-family, var(--playpilot-font-family));
81
+ font-weight: var(--playpilot-detail-font-weight, normal);
82
+ font-size: var(--playpilot-detail-font-size, 14px);
83
+ line-height: normal;
84
+ font-style: normal;
85
+ }
86
+
87
+ .list {
88
+ display: flex;
89
+ flex-direction: column;
90
+ gap: margin(0.5);
91
+ }
92
+ </style>
@@ -0,0 +1,31 @@
1
+
2
+ <script lang="ts">
3
+ import Modal from './Modal.svelte'
4
+ import Participant from './Participant.svelte'
5
+ import type { ParticipantData } from '$lib/types/participant'
6
+
7
+ interface Props {
8
+ participant: ParticipantData
9
+ initialScrollPosition?: number
10
+ }
11
+
12
+ const { participant = {
13
+ sid: 'pr5C5W',
14
+ name: 'James Franco',
15
+ birth_date: '1978-04-19',
16
+ death_date: null,
17
+ jobs: ['actor'],
18
+ image: 'https://hips.hearstapps.com/hmg-prod/images/gettyimages-161098947-square.jpg',
19
+ image_uuid: '08ed2fac357011eb87470aff12c0f5c9',
20
+ gender: 'Male',
21
+ bio: 'Aenean feugiat nec odio at venenatis. Integer porta neque metus, a sollicitudin dolor dapibus et. In sollicitudin nulla eget ultricies porttitor. Nulla facilisi. Sed turpis orci, facilisis efficitur neque in, ultrices ultricies purus. Mauris nec augue a nisi imperdiet semper ut nec tellus. Donec at tristique odio. Etiam luctus eget metus non mattis. Integer imperdiet in elit eu varius. Donec ornare, nibh vitae accumsan consequat, lacus nulla elementum sapien, a scelerisque tellus augue ac erat. Aenean finibus fringilla magna, ac laoreet nisl convallis vel. Proin laoreet ex ac augue maximus, nec gravida tortor pharetra.\nCurabitur maximus dui sed risus placerat pharetra vitae ut orci. Proin sodales enim a elit euismod, a varius sem suscipit. Vivamus eu magna cursus, fringilla est in, mollis nunc. Mauris fringilla eleifend nibh, eget auctor lectus bibendum non. Praesent sed elit ipsum. Donec nunc dolor, sagittis hendrerit gravida et, lacinia sed metus. Morbi tempus mi massa. In hac habitasse platea dictumst. Suspendisse aliquet tincidunt lectus ut elementum.',
22
+ }, initialScrollPosition = 0 }: Props = $props()
23
+
24
+ let windowWidth = $state(0)
25
+ </script>
26
+
27
+ <svelte:window bind:innerWidth={windowWidth} />
28
+
29
+ <Modal {initialScrollPosition} closeButtonStyle="flat">
30
+ <Participant {participant} />
31
+ </Modal>
@@ -0,0 +1,41 @@
1
+ <script lang="ts">
2
+ import { removeImageUrlPrefix } from '$lib/image'
3
+ import type { PlaylinkData } from '$lib/types/playlink'
4
+
5
+ interface Props {
6
+ playlink: PlaylinkData
7
+ size?: number
8
+ // eslint-disable-next-line no-unused-vars
9
+ onclick?: (event: MouseEvent) => void
10
+ }
11
+
12
+ const { playlink, size = 30, onclick = () => null }: Props = $props()
13
+
14
+ const { name, url, logo_url } = $derived(playlink)
15
+ </script>
16
+
17
+ <a href={url} target="_blank" class="playlink" data-playlink={name} rel="sponsored" {onclick}>
18
+ <img src={removeImageUrlPrefix(logo_url)} alt={name} height={size} width={size} />
19
+ </a>
20
+
21
+ <style lang="scss">
22
+ .playlink {
23
+ display: inline-block;
24
+ background: var(--playpilot-playlink-background, var(--playpilot-light));
25
+ border-radius: var(--playpilot-playlink-border-radius, margin(0.5));
26
+ overflow: hidden;
27
+
28
+ &:hover,
29
+ &:active {
30
+ filter: var(--playpilot-playlink-hover-filter, brightness(1.1));
31
+ background: var(--playpilot-playlink-hover-background, var(--playpilot-playlink-background, var(--playpilot-lighter))) !important;
32
+ text-decoration: none !important;
33
+ outline: 2px solid var(--playpilot-detail-text-color, var(--playpilot-text-color));
34
+ }
35
+
36
+ img {
37
+ display: block;
38
+ margin: 0;
39
+ }
40
+ }
41
+ </style>
@@ -0,0 +1,37 @@
1
+ <script lang="ts">
2
+ import type { PlaylinkData } from '$lib/types/playlink'
3
+
4
+ interface Props {
5
+ playlink: PlaylinkData
6
+ size?: number
7
+ // eslint-disable-next-line no-unused-vars
8
+ onclick?: (event: MouseEvent) => void
9
+ }
10
+
11
+ const { playlink, onclick = () => null }: Props = $props()
12
+
13
+ const { name, url } = $derived(playlink)
14
+ </script>
15
+
16
+ <a href={url} target="_blank" class="playlink" data-playlink={name} rel="sponsored" {onclick}>
17
+ {name}
18
+ </a>
19
+
20
+ <style lang="scss">
21
+ .playlink {
22
+ display: inline-block;
23
+ background: var(--playpilot-playlink-label-background, var(--playpilot-light));
24
+ border-radius: var(--playpilot-playlink-label-border-radius, margin(0.25));
25
+ padding: margin(0.25) margin(0.5);
26
+ font-size: var(--playpilot-playlink-label-font-size, 12px);
27
+ color: var(--playpilot-playlink-label-text-color, var(--playpilot-text-color-alt)) !important;
28
+ text-decoration: none;
29
+ font-style: normal !important;
30
+ overflow: hidden;
31
+
32
+ &:hover,
33
+ &:active {
34
+ background: var(--playpilot-playlink-label-hover-background, var(--playpilot-content-light));
35
+ }
36
+ }
37
+ </style>
@@ -27,9 +27,7 @@
27
27
  // otherwise break the layout in ways that don't make sense to fix.
28
28
  const list = $derived(outerWidth < 500 || !!displayAd)
29
29
 
30
- // Remove any playlinks without logos, these are likely sub providers.
31
- const filteredPlaylinks = $derived(playlinks.filter(playlink => !!playlink.logo_url))
32
- const mergedPlaylink = $derived(mergePlaylinks(filteredPlaylinks))
30
+ const mergedPlaylink = $derived(mergePlaylinks(playlinks))
33
31
 
34
32
  function onclick(playlink: string): void {
35
33
  track(isModal ? TrackingEvent.TitleModalPlaylinkClick : TrackingEvent.TitlePopoverPlaylinkClick, title, { playlink })
@@ -0,0 +1,56 @@
1
+ <script lang="ts">
2
+ import { openModal } from '$lib/modal'
3
+ import type { ParticipantData } from '$lib/types/participant'
4
+ import Rail from './Rail.svelte'
5
+
6
+ interface Props {
7
+ participants: ParticipantData[]
8
+ }
9
+
10
+ const { participants }: Props = $props()
11
+ </script>
12
+
13
+ <Rail heading="Cast">
14
+ {#each participants.slice(0, 15) as participant}
15
+ <button class="participant" onclick={event => openModal({ event, type: 'participant', data: participant })}>
16
+ <span class="truncate">{participant.name}</span>
17
+
18
+ <div class="character truncate">{participant.character}</div>
19
+ </button>
20
+ {/each}
21
+ </Rail>
22
+
23
+ <style lang="scss">
24
+ .participant {
25
+ display: block;
26
+ flex: 0 0 10rem;
27
+ width: 10rem;
28
+ padding: margin(0.5);
29
+ border: 0;
30
+ border-radius: var(--playpilot-cast-border-radius, var(--playpilot-playlink-border-radius, margin(0.5)));
31
+ background: var(--playpilot-cast-background, var(--playpilot-playlink-background, var(--playpilot-lighter)));
32
+ cursor: pointer;
33
+ font-family: inherit;
34
+ text-align: left;
35
+ color: inherit;
36
+ font-size: var(--playpilot-cast-font-size, var(--playpilot-playlinks-font-size, margin(0.75)));
37
+ white-space: nowrap;
38
+
39
+ &:hover,
40
+ &:active {
41
+ filter: var(--playpilot-cast-hover-filter, var(--playpilot-playlink-hover-filter, brightness(1.1)));
42
+ text-decoration: none !important;
43
+ }
44
+ }
45
+
46
+ .character {
47
+ color: var(--playpilot-cast-character-text-color, var(--playpilot-text-color-alt));
48
+ font-style: italic;
49
+ }
50
+
51
+ .truncate {
52
+ overflow: hidden;
53
+ text-overflow: ellipsis;
54
+ white-space: nowrap;
55
+ }
56
+ </style>
@@ -0,0 +1,91 @@
1
+ <script lang="ts">
2
+ import type { Snippet } from 'svelte'
3
+ import TinySlider from 'svelte-tiny-slider'
4
+ import IconArrow from '../Icons/IconArrow.svelte'
5
+ import { heading as _heading } from '$lib/actions/heading'
6
+
7
+ interface Props {
8
+ heading?: string
9
+ children: Snippet
10
+ }
11
+
12
+ const { heading = '', children }: Props = $props()
13
+ </script>
14
+
15
+ {#if heading}
16
+ <div class="heading" use:_heading={2}>{heading}</div>
17
+ {/if}
18
+
19
+ <div class="rail">
20
+ <TinySlider allowWheel>
21
+ {@render children()}
22
+
23
+ {#snippet controls({ setIndex, currentIndex, reachedEnd })}
24
+ {#if currentIndex > 0}
25
+ <button class="arrow left" onclick={() => setIndex(currentIndex - 1)}><IconArrow direction="left" /></button>
26
+ {/if}
27
+
28
+ {#if !reachedEnd}
29
+ <button class="arrow right" onclick={() => setIndex(currentIndex + 1)}><IconArrow /></button>
30
+ {/if}
31
+ {/snippet}
32
+ </TinySlider>
33
+ </div>
34
+
35
+ <style lang="scss">
36
+ .heading {
37
+ margin: margin(1) 0 margin(0.5);
38
+ font-size: var(--playpilot-rail-title-font-size, 18px);
39
+ line-height: normal;
40
+ font-weight: inherit;
41
+ color: var(--playpilot-rail-title-text-color, var(--playpilot-text-color));
42
+ }
43
+
44
+ .rail {
45
+ --gap: #{margin(0.5)};
46
+ position: relative;
47
+ width: calc(100% + margin(2));
48
+ margin: 0 margin(-1);
49
+
50
+ :global(.slider) {
51
+ padding: 0 margin(1);
52
+ }
53
+
54
+ :global(.slider-content > :last-child) {
55
+ margin-right: margin(2);
56
+ }
57
+ }
58
+
59
+ .arrow {
60
+ display: none;
61
+ align-items: center;
62
+ justify-content: center;
63
+ position: absolute;
64
+ left: margin(2);
65
+ top: 50%;
66
+ width: margin(2);
67
+ height: margin(2);
68
+ padding: 0;
69
+ margin: 0;
70
+ border: 0;
71
+ border-radius: 50%;
72
+ transform: translateX(-50%) translateY(-50%);
73
+ background: var(--playpilot-rails-arrow-background, var(--playpilot-content-light));
74
+ z-index: 2;
75
+ cursor: pointer;
76
+ color: var(--playpilot-rails-arrow-color, var(--playpilot-detail-text-color, white));
77
+
78
+ @media (hover: hover) {
79
+ display: flex;
80
+ }
81
+
82
+ &:hover {
83
+ filter: brightness(1.2);
84
+ }
85
+
86
+ &.right {
87
+ left: auto;
88
+ right: 0;
89
+ }
90
+ }
91
+ </style>
@@ -0,0 +1,16 @@
1
+ <script lang="ts">
2
+ import type { TitleData } from '$lib/types/title'
3
+ import TitlesRail from './TitlesRail.svelte'
4
+
5
+ const titles = fetchTitles()
6
+
7
+ async function fetchTitles(): Promise<TitleData[]> {
8
+ // This is just a fake loading state for now
9
+ await new Promise(res => setTimeout(res, 500))
10
+
11
+ // Imagine this being a fetch request that returns titles instead.
12
+ return (window.PlayPilotLinkInjections?.evaluated_link_injections?.map(i => i.title_details) || []) as TitleData[]
13
+ }
14
+ </script>
15
+
16
+ <TitlesRail {titles} heading="Similar movies & shows" />
@@ -0,0 +1,95 @@
1
+ <script lang="ts">
2
+ import TitlePoster from '../TitlePoster.svelte'
3
+ import Rail from './Rail.svelte'
4
+ import { playPilotBaseUrl } from '$lib/constants'
5
+ import type { TitleData } from '$lib/types/title'
6
+ import { openModal } from '$lib/modal'
7
+
8
+ interface Props {
9
+ titles: Promise<TitleData[]> | TitleData[]
10
+ heading?: string
11
+ }
12
+
13
+ const { titles, heading = '' }: Props = $props()
14
+ </script>
15
+
16
+ <div class="titles">
17
+ <Rail {heading}>
18
+ {#await titles}
19
+ {#each { length: 8 }}
20
+ <div class="title" data-testid="skeleton">
21
+ <div class="poster"></div>
22
+
23
+ <div class="heading">
24
+ <span class="skeleton">&nbsp;</span>
25
+ <span class="skeleton">&nbsp;</span>
26
+ </div>
27
+ </div>
28
+ {/each}
29
+ {:then titles}
30
+ {#each titles as title}
31
+ {@const href = `${playPilotBaseUrl}/${title.type}/${title.slug}`}
32
+
33
+ <div class="title" data-testid="title">
34
+ <a class="poster" {href} onclick={(event) => openModal({ event, data: title })}>
35
+ <TitlePoster {title} width={96} height={144} />
36
+ </a>
37
+
38
+ <a {href} class="heading" onclick={(event) => openModal({ event, data: title })}>
39
+ {title.title}
40
+ </a>
41
+ </div>
42
+ {/each}
43
+ {/await}
44
+ </Rail>
45
+ </div>
46
+
47
+ <style lang="scss">
48
+ $width: margin(6);
49
+
50
+ .title {
51
+ width: $width;
52
+
53
+ &:hover,
54
+ &:active {
55
+ filter: brightness(1.1);
56
+ }
57
+ }
58
+
59
+ .poster {
60
+ display: block;
61
+ border-radius: var(--playpilot-rail-border-radius, var(--playpilot-playlink-border-radius, margin(0.5)));
62
+ overflow: hidden;
63
+ width: 100%;
64
+ aspect-ratio: 2 / 3;
65
+ background: var(--playpilot-detail-background-light, var(--playpilot-lighter));
66
+ }
67
+
68
+ .heading {
69
+ display: -webkit-box;
70
+ padding-top: margin(0.5);
71
+ overflow: hidden;
72
+ text-decoration: none;
73
+ color: var(--playpilot-detail-text-color, var(--playpilot-text-color-alt)) !important;
74
+ font-size: var(--playpilot-rail-font-size, var(--playpilot-detail-font-size-small, 12px));
75
+ font-style: normal !important;
76
+ line-height: 1.2;
77
+ line-clamp: 2;
78
+ -webkit-line-clamp: 2;
79
+ -webkit-box-orient: vertical;
80
+ text-overflow: ellipsis;
81
+ }
82
+
83
+ .skeleton {
84
+ display: block;
85
+ height: 1em;
86
+ width: 100%;
87
+ background: var(--playpilot-detail-background-light, var(--playpilot-lighter));
88
+ border-radius: margin(2);
89
+
90
+ &:last-child {
91
+ margin-top: margin(0.3);
92
+ width: 50%;
93
+ }
94
+ }
95
+ </style>