@playpilot/tpi 5.15.0 → 5.16.0

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 (38) hide show
  1. package/dist/link-injections.js +11 -10
  2. package/package.json +2 -1
  3. package/src/lib/enums/SplitTest.ts +4 -0
  4. package/src/lib/fakeData.ts +70 -0
  5. package/src/lib/linkInjection.ts +11 -29
  6. package/src/lib/modal.ts +97 -0
  7. package/src/lib/playlink.ts +4 -1
  8. package/src/lib/types/participant.d.ts +14 -0
  9. package/src/lib/types/title.d.ts +2 -0
  10. package/src/routes/+page.svelte +1 -0
  11. package/src/routes/components/Icons/IconArrow.svelte +22 -0
  12. package/src/routes/components/Icons/IconClose.svelte +1 -1
  13. package/src/routes/components/Icons/IconIMDb.svelte +9 -1
  14. package/src/routes/components/ListTitle.svelte +204 -0
  15. package/src/routes/components/Modal.svelte +63 -13
  16. package/src/routes/components/Participant.svelte +92 -0
  17. package/src/routes/components/ParticipantModal.svelte +31 -0
  18. package/src/routes/components/PlaylinkIcon.svelte +41 -0
  19. package/src/routes/components/PlaylinkLabel.svelte +37 -0
  20. package/src/routes/components/Playlinks.svelte +1 -3
  21. package/src/routes/components/Rails/ParticipantsRail.svelte +56 -0
  22. package/src/routes/components/Rails/Rail.svelte +91 -0
  23. package/src/routes/components/Rails/SimilarRail.svelte +16 -0
  24. package/src/routes/components/Rails/TitlesRail.svelte +95 -0
  25. package/src/routes/components/Tabs.svelte +47 -0
  26. package/src/routes/components/Title.svelte +19 -16
  27. package/src/routes/components/TitleModal.svelte +3 -3
  28. package/src/routes/components/TitlePoster.svelte +30 -0
  29. package/src/tests/lib/linkInjection.test.js +10 -22
  30. package/src/tests/lib/modal.test.js +148 -0
  31. package/src/tests/lib/playlink.test.js +25 -10
  32. package/src/tests/routes/components/ListTitle.test.js +84 -0
  33. package/src/tests/routes/components/Modal.test.js +51 -19
  34. package/src/tests/routes/components/PlaylinkIcon.test.js +27 -0
  35. package/src/tests/routes/components/PlaylinkLabel.test.js +19 -0
  36. package/src/tests/routes/components/Rails/ParticipantsRail.test.js +41 -0
  37. package/src/tests/routes/components/Rails/TitleRail.test.js +38 -0
  38. package/src/tests/routes/components/TitlePoster.test.js +20 -0
package/package.json CHANGED
@@ -1,6 +1,6 @@
1
1
  {
2
2
  "name": "@playpilot/tpi",
3
- "version": "5.15.0",
3
+ "version": "5.16.0",
4
4
  "type": "module",
5
5
  "scripts": {
6
6
  "dev": "vite dev",
@@ -32,6 +32,7 @@
32
32
  "svelte": "^5.0.0",
33
33
  "svelte-check": "^4.0.0",
34
34
  "svelte-preprocess": "^6.0.3",
35
+ "svelte-tiny-slider": "^2.3.0",
35
36
  "typescript": "^5.0.0",
36
37
  "typescript-eslint": "^8.32.1",
37
38
  "vite": "^5.0.3",
@@ -4,4 +4,8 @@ export const SplitTest = {
4
4
  numberOfVariants: 2,
5
5
  variantNames: ['Separated', 'Inline'] as string[]
6
6
  },
7
+ ParticipantPlaylinkFormat: {
8
+ key: 'participant_playlink_format',
9
+ numberOfVariants: 2,
10
+ }
7
11
  } as const
@@ -1,5 +1,6 @@
1
1
  import type { Campaign } from "./types/campaign"
2
2
  import type { LinkInjection } from "./types/injection"
3
+ import type { ParticipantData } from "./types/participant"
3
4
  import type { TitleData } from "./types/title"
4
5
 
5
6
  export const title: TitleData = {
@@ -71,6 +72,75 @@ export const linkInjections: LinkInjection[] = [{
71
72
  key: 'some-key-4',
72
73
  }]
73
74
 
75
+ export const participants: ParticipantData[] = [
76
+ {
77
+ sid: 'pr5C5W',
78
+ name: 'James Franco',
79
+ birth_date: '1978-04-19',
80
+ death_date: null,
81
+ jobs: ['actor'],
82
+ image: null,
83
+ image_uuid: null,
84
+ gender: 'Male',
85
+ character: 'Will Rodman (archive footage) (uncredited)',
86
+ },
87
+ {
88
+ sid: 'pr8bZm',
89
+ name: 'Thomas Rosales Jr.',
90
+ birth_date: '1948-02-03',
91
+ death_date: null,
92
+ jobs: ['actor'],
93
+ image: null,
94
+ image_uuid: null,
95
+ gender: 'Male',
96
+ character: 'Old Man',
97
+ },
98
+ {
99
+ sid: 'pr45Dp',
100
+ name: 'Barack Obama',
101
+ birth_date: '1961-08-04',
102
+ death_date: null,
103
+ jobs: ['actor'],
104
+ image: null,
105
+ image_uuid: null,
106
+ gender: 'Male',
107
+ character: 'Self (archive footage) (uncredited)',
108
+ },
109
+ {
110
+ sid: 'pr6DnN',
111
+ name: 'Gary Oldman',
112
+ birth_date: '1958-03-21',
113
+ death_date: null,
114
+ jobs: ['actor'],
115
+ image: null,
116
+ image_uuid: null,
117
+ gender: 'Male',
118
+ character: 'Dreyfus',
119
+ },
120
+ {
121
+ sid: 'pr7GK8',
122
+ name: 'Michael Papajohn',
123
+ birth_date: '1964-11-07',
124
+ death_date: null,
125
+ jobs: ['actor'],
126
+ image: null,
127
+ image_uuid: null,
128
+ gender: 'Male',
129
+ character: 'Cannon-Gunner',
130
+ },
131
+ {
132
+ sid: 'pr88KG',
133
+ name: 'Judy Greer',
134
+ birth_date: '1975-07-20',
135
+ death_date: null,
136
+ jobs: ['actor'],
137
+ image: null,
138
+ image_uuid: null,
139
+ gender: 'Female',
140
+ character: 'Cornelia',
141
+ },
142
+ ]
143
+
74
144
  export const campaign: Campaign = {
75
145
  campaign_format: 'card',
76
146
  campaign_type: 'image',
@@ -1,5 +1,4 @@
1
1
  import { mount, unmount } from 'svelte'
2
- import TitleModal from '../routes/components/TitleModal.svelte'
3
2
  import TitlePopover from '../routes/components/TitlePopover.svelte'
4
3
  import AfterArticlePlaylinks from '../routes/components/AfterArticlePlaylinks.svelte'
5
4
  import { cleanPhrase, findNumberOfMatchesInString, findShortestMatchBetweenPhrases, findTextNodeContaining, getIndexOfPhraseInElement, getNumberOfLeadingAndTrailingSpaces, isNodeInLink, replaceBetween, replaceStartingFrom } from './text'
@@ -9,6 +8,7 @@ import { playFallbackViewTransition } from './viewTransition'
9
8
  import { prefersReducedMotion } from 'svelte/motion'
10
9
  import { getNumberOfOccurrencesInArray } from './array'
11
10
  import { mobileBreakpoint } from './constants'
11
+ import { destroyAllModals, openModal } from './modal'
12
12
  import { isEditorialModeEnabled } from './auth'
13
13
  import { track } from './tracking'
14
14
  import { TrackingEvent } from './enums/TrackingEvent'
@@ -21,7 +21,6 @@ const linksIntersectionObserver = typeof window !== 'undefined' ? new Intersecti
21
21
  let currentlyHoveredInjection: EventTarget | null = null
22
22
  let activePopoverInsertedComponent: object | null = null
23
23
  let afterArticlePlaylinkInsertedComponent: object | null = null
24
- let activeModalInsertedComponent: object | null = null
25
24
 
26
25
  /**
27
26
  * Return a list of all valid text containing elements that may get injected into.
@@ -353,7 +352,7 @@ function addLinkInjectionEventListeners(injections: LinkInjection[]): void {
353
352
 
354
353
  playFallbackViewTransition(() => {
355
354
  destroyLinkPopover(false)
356
- openLinkModal(event, injection)
355
+ openModal({ event, injection, data: injection.title_details })
357
356
  }, !prefersReducedMotion.current && window.innerWidth >= mobileBreakpoint && !window.matchMedia("(pointer: coarse)").matches)
358
357
  })
359
358
 
@@ -403,29 +402,6 @@ export function trackLinkIntersection(entries: IntersectionObserverEntry[]): voi
403
402
  })
404
403
  }
405
404
 
406
- /**
407
- * Open modal for the corresponding injection by mounting the component and saving it to a variable.
408
- * Ignore clicks that used modifier keys or that were not left click.
409
- */
410
- function openLinkModal(event: MouseEvent, injection: LinkInjection): void {
411
- if (isHoldingSpecialKey(event)) return
412
- if (activeModalInsertedComponent) return
413
-
414
- event.preventDefault()
415
-
416
- activeModalInsertedComponent = mount(TitleModal, { target: getPlayPilotWrapperElement(), props: { title: injection.title_details!, onclose: destroyLinkModal } })
417
- }
418
-
419
- /**
420
- * Unmount the modal, removing it from the dom
421
- */
422
- function destroyLinkModal(outro: boolean = true): void {
423
- if (!activeModalInsertedComponent) return
424
-
425
- unmount(activeModalInsertedComponent, { outro })
426
- activeModalInsertedComponent = null
427
- }
428
-
429
405
  /**
430
406
  * When a link is hovered, it is shown as a popover. The component is mounted when a mouse enters the link,
431
407
  * and removed when clicked or on mouseleave.
@@ -483,7 +459,13 @@ export function insertAfterArticlePlaylinks(elements: HTMLElement[], injections:
483
459
  target.dataset.playpilotAfterArticlePlaylinks = 'true'
484
460
  insertElement.insertAdjacentElement(insertPosition, target)
485
461
 
486
- afterArticlePlaylinkInsertedComponent = mount(AfterArticlePlaylinks, { target, props: { linkInjections: injections, onclickmodal: (event, injection) => openLinkModal(event, injection) } })
462
+ afterArticlePlaylinkInsertedComponent = mount(AfterArticlePlaylinks, {
463
+ target,
464
+ props: {
465
+ linkInjections: injections,
466
+ onclickmodal: (event, injection) => openModal({ event, injection, data: injection.title_details })
467
+ }
468
+ })
487
469
  }
488
470
 
489
471
  function clearAfterArticlePlaylinks(): void {
@@ -502,7 +484,7 @@ export function clearLinkInjections(): void {
502
484
  elements.forEach((element) => clearLinkInjection(element.getAttribute(keyDataAttribute) || ''))
503
485
 
504
486
  clearAfterArticlePlaylinks()
505
- destroyLinkModal(false)
487
+ destroyAllModals(false)
506
488
  destroyLinkPopover(false)
507
489
 
508
490
  linksIntersectionObserver?.disconnect()
@@ -611,6 +593,6 @@ export function isEquivalentInjection(injection1: LinkInjection, injection2: Lin
611
593
  return injection1.title === injection2.title && cleanPhrase(injection1.sentence) === cleanPhrase(injection2.sentence)
612
594
  }
613
595
 
614
- function getPlayPilotWrapperElement(): Element {
596
+ export function getPlayPilotWrapperElement(): Element {
615
597
  return document.querySelector('[data-playpilot-link-injections]') || document.body
616
598
  }
@@ -0,0 +1,97 @@
1
+ import { mount, unmount } from "svelte"
2
+ import { isHoldingSpecialKey } from "./event"
3
+ import TitleModal from "../routes/components/TitleModal.svelte"
4
+ import type { LinkInjection } from "./types/injection"
5
+ import { getPlayPilotWrapperElement } from "./linkInjection"
6
+ import ParticipantModal from "../routes/components/ParticipantModal.svelte"
7
+ import type { TitleData } from "./types/title"
8
+ import type { ParticipantData } from "./types/participant"
9
+ import { mobileBreakpoint } from "./constants"
10
+
11
+ type ModalType = 'title' | 'participant'
12
+
13
+ type Modal = {
14
+ injection?: LinkInjection | null
15
+ component: object
16
+ type: ModalType
17
+ data: TitleData | ParticipantData | null
18
+ scrollPosition?: number
19
+ }
20
+
21
+ const modals: Modal[] = []
22
+
23
+ /**
24
+ * Open modal for the corresponding injection by mounting the component and saving it to a variable.
25
+ * Ignore clicks that used modifier keys or that were not left click.
26
+ */
27
+ export function openModal(
28
+ { type = 'title', event = null, injection = null, data = null, scrollPosition = 0 }:
29
+ { type?: ModalType, event?: MouseEvent | null, injection?: LinkInjection | null, data?: TitleData | ParticipantData | null, scrollPosition?: number } = {}): void {
30
+ if (event && isHoldingSpecialKey(event)) return
31
+
32
+ event?.preventDefault()
33
+
34
+ if (modals?.length) closeCurrentModal()
35
+
36
+ const target = getPlayPilotWrapperElement()
37
+ const sharedProps = { initialScrollPosition: scrollPosition }
38
+ const component = type === 'title' ?
39
+ mount(TitleModal, { target, props: { title: data as TitleData, ...sharedProps } }) :
40
+ mount(ParticipantModal, { target, props: { participant: data as ParticipantData, ...sharedProps } })
41
+
42
+ modals.push({ type, injection, data, scrollPosition, component })
43
+ }
44
+
45
+ /** Unmount the last modal is the list of modals but keep it in the list of active modals */
46
+ export function closeCurrentModal(outro: boolean = true): void {
47
+ if (!modals.length) return
48
+
49
+ saveCurrentModalScrollPosition()
50
+
51
+ unmount(modals[modals.length - 1].component, { outro })
52
+ }
53
+
54
+ /** Unmount the last modal is the list of modals and remove it from the list */
55
+ export function destroyCurrentModal(outro: boolean = true): void {
56
+ closeCurrentModal(outro)
57
+
58
+ modals.pop()
59
+ }
60
+
61
+ export function destroyAllModals(outro: boolean = true): void {
62
+ closeCurrentModal(outro)
63
+
64
+ while (modals.length > 0) modals.pop()
65
+ }
66
+
67
+ /** @returns The modal before the currently active modal, if any */
68
+ export function getPreviousModal(): Modal | null {
69
+ return modals[modals.length - 2] || null
70
+ }
71
+
72
+ export function goBackToPreviousModal(): void {
73
+ if (modals.length < 2) return
74
+
75
+ destroyCurrentModal()
76
+
77
+ // Get the previous modal from the array by removing it, we're re-adding it when calling openModal
78
+ const previousModal = modals.pop()
79
+ if (!previousModal) return
80
+
81
+ openModal({ ...previousModal })
82
+ }
83
+
84
+ export function getAllModals(): Modal[] {
85
+ return modals
86
+ }
87
+
88
+ function saveCurrentModalScrollPosition(): void {
89
+ // The scrollable element differs on mobile and desktop.
90
+ // On desktop you scroll the entire parent but on mobile you scroll the dialog itself.
91
+ const selector = `[data-playpilot-link-injections] ${window.innerWidth > mobileBreakpoint ? '.modal' : '.dialog'}`
92
+ const element = document.querySelector(selector)
93
+
94
+ if (!element?.scrollTop) return
95
+
96
+ modals[modals.length - 1].scrollPosition = element.scrollTop
97
+ }
@@ -2,11 +2,14 @@ import type { PlaylinkData } from "./types/playlink"
2
2
 
3
3
  /**
4
4
  * Merge playlinks of the same provider of BUY and RENT categories into a shared TVOD category.
5
+ * Also remove playlinks without logos, as these are likely sub providers.
5
6
  */
6
7
  export function mergePlaylinks(playlinks: PlaylinkData[]): PlaylinkData[] {
8
+ const filtered = playlinks.filter(playlink => !!playlink.logo_url)
9
+
7
10
  let merged: PlaylinkData[] = []
8
11
 
9
- for (const playlink of playlinks) {
12
+ for (const playlink of filtered) {
10
13
  let newPlaylink = playlink
11
14
  const existingPlaylink = merged.find(p => p.name === newPlaylink.name)
12
15
 
@@ -0,0 +1,14 @@
1
+ export type ParticipantData = {
2
+ sid: string
3
+ name: string
4
+ birth_date: string
5
+ death_date: string | null
6
+ jobs: Job[]
7
+ image: string | null
8
+ image_uuid: string | null
9
+ gender: string
10
+ character?: string | null
11
+ bio?: string | null
12
+ }
13
+
14
+ export type Job = 'actor' | 'writer' | 'director'
@@ -1,3 +1,4 @@
1
+ import type { ParticipantData } from "./participant"
1
2
  import type { PlaylinkData } from "./playlink"
2
3
 
3
4
  export type TitleData = {
@@ -18,4 +19,5 @@ export type TitleData = {
18
19
  original_title: string
19
20
  length?: number
20
21
  blurb?: string
22
+ participants?: ParticipantData[]
21
23
  }
@@ -15,6 +15,7 @@
15
15
  import Consent from './components/Consent.svelte'
16
16
  import Debugger from './components/Debugger.svelte'
17
17
  import { fetchAds } from '$lib/ads'
18
+ import ParticipantModal from './components/ParticipantModal.svelte';
18
19
 
19
20
  let parentElement: HTMLElement | null = $state(null)
20
21
  let elements: HTMLElement[] = $state([])
@@ -0,0 +1,22 @@
1
+ <script lang="ts">
2
+ interface Props {
3
+ direction?: 'left' | 'right'
4
+ }
5
+
6
+ const { direction = 'right' }: Props = $props()
7
+ </script>
8
+
9
+ <svg class="{direction}" height="16px" viewBox="0 -960 960 960" width="16px">
10
+ <path d="m288-96-68-68 316-316-316-316 68-68 384 384L288-96Z" fill="currentColor" />
11
+ </svg>
12
+
13
+ <style lang="scss">
14
+ .right {
15
+ margin-left: margin(0.125);
16
+ }
17
+
18
+ .left {
19
+ margin-right: margin(0.125);
20
+ transform: rotate(180deg);
21
+ }
22
+ </style>
@@ -1,3 +1,3 @@
1
1
  <svg width="16" height="16" viewBox="0 0 16 16" fill="none">
2
- <path d="M14 2L2 14M2 2L14 14" stroke="currentColor" stroke-width="1.33" stroke-linecap="round" stroke-linejoin="round"/>
2
+ <path d="M14 2L2 14M2 2L14 14" stroke="currentColor" stroke-width="2" stroke-linecap="round" stroke-linejoin="round"/>
3
3
  </svg>
@@ -1,3 +1,11 @@
1
- <svg width="14" height="14" viewBox="0 0 14 14" fill="none" xmlns="http://www.w3.org/2000/svg">
1
+ <script lang="ts">
2
+ interface Props {
3
+ size?: number
4
+ }
5
+
6
+ const { size = 14 }: Props = $props()
7
+ </script>
8
+
9
+ <svg width={size} height={size} viewBox="0 0 14 14" fill="none" xmlns="http://www.w3.org/2000/svg">
2
10
  <path d="M7 0.351929L9.163 4.72803L14 5.43408L10.5 8.8385L11.326 13.648L7 11.3761L2.674 13.648L3.5 8.8385L0 5.43408L4.837 4.72803L7 0.351929Z" fill="#F6C045"/>
3
11
  </svg>
@@ -0,0 +1,204 @@
1
+ <script lang="ts">
2
+ import { playPilotBaseUrl } from '$lib/constants'
3
+ import { SplitTest } from '$lib/enums/SplitTest'
4
+ import { t } from '$lib/localization'
5
+ import { mergePlaylinks } from '$lib/playlink'
6
+ import { isInSplitTestVariant, trackSplitTestAction } from '$lib/splitTest'
7
+ import type { TitleData } from '$lib/types/title'
8
+ import IconArrow from './Icons/IconArrow.svelte'
9
+ import IconIMDb from './Icons/IconIMDb.svelte'
10
+ import PlaylinkIcon from './PlaylinkIcon.svelte'
11
+ import PlaylinkLabel from './PlaylinkLabel.svelte'
12
+ import TitlePoster from './TitlePoster.svelte'
13
+
14
+ interface Props {
15
+ title: TitleData
16
+ // eslint-disable-next-line no-unused-vars
17
+ onclick?: (event: MouseEvent) => void
18
+ }
19
+
20
+ const { title, onclick = () => null }: Props = $props()
21
+
22
+ const playlinks = $derived(mergePlaylinks(title.providers))
23
+ const limitedPlaylinks = $derived(playlinks.slice(0, 3))
24
+ const playlinksAsLabels = isInSplitTestVariant(SplitTest.ParticipantPlaylinkFormat, 1)
25
+
26
+ function onPlaylinkClick(event: MouseEvent) {
27
+ event.stopPropagation()
28
+ trackSplitTestAction(SplitTest.ParticipantPlaylinkFormat, 'click')
29
+ }
30
+ </script>
31
+
32
+ <a class="title" href="{playPilotBaseUrl}/{title.type}/{title.slug}" {onclick}>
33
+ <div class="poster">
34
+ <TitlePoster {title} width={30} height={43} />
35
+ </div>
36
+
37
+ <div class="content">
38
+ <div class="heading">{title.title}</div>
39
+
40
+ <div class="meta">
41
+ <div class="imdb">
42
+ <IconIMDb size={11} />
43
+ {title.imdb_score}
44
+ </div>
45
+
46
+ <div>{title.year}</div>
47
+ <div>{title.type}</div>
48
+
49
+ {#if title.length}
50
+ <div>{title.length} min</div>
51
+ {/if}
52
+ </div>
53
+
54
+ <div class="description">
55
+ {title.description}
56
+ </div>
57
+
58
+ <div class="playlinks">
59
+ {#each limitedPlaylinks as playlink}
60
+ {#if playlinksAsLabels}
61
+ <PlaylinkLabel {playlink} onclick={onPlaylinkClick} />
62
+ {:else}
63
+ <PlaylinkIcon {playlink} onclick={onPlaylinkClick} />
64
+ {/if}
65
+ {/each}
66
+
67
+ {#if playlinks.length > limitedPlaylinks.length}
68
+ <span class="more">
69
+ +{playlinks.length - limitedPlaylinks.length}
70
+ </span>
71
+ {/if}
72
+
73
+ {#if !playlinks.length}
74
+ <div class="empty" data-testid="playlinks-empty">
75
+ {t('Title Unavailable')}
76
+ </div>
77
+ {/if}
78
+ </div>
79
+ </div>
80
+
81
+ <div class="action">
82
+ <IconArrow />
83
+ </div>
84
+ </a>
85
+
86
+ <style lang="scss">
87
+ .title {
88
+ appearance: none;
89
+ display: flex;
90
+ align-items: center;
91
+ width: 100%;
92
+ background: var(--playpilot-list-item-background, var(--playpilot-lighter));
93
+ padding: margin(0.5);
94
+ border: 0;
95
+ border-radius: var(--playpilot-list-item-border-radius, margin(0.5));
96
+ text-decoration: none;
97
+ font-style: normal !important;
98
+
99
+ &:hover {
100
+ background: var(--playpilot-list-item-hover-background, var(--playpilot-content));
101
+ }
102
+
103
+ &:last-child {
104
+ border-bottom: 0;
105
+ }
106
+ }
107
+
108
+ .poster {
109
+ flex: 0 0 auto;
110
+ height: auto;
111
+ align-self: start;
112
+ width: margin(4.125);
113
+ aspect-ratio: 2/3;
114
+ border-radius: var(--playpilot-detail-image-border-radius, margin(0.5));
115
+ background: var(--playpilot-detail-image-background, var(--playpilot-content));
116
+ overflow: hidden;
117
+ }
118
+
119
+ .content {
120
+ padding-left: margin(1);
121
+ font-family: inherit;
122
+ text-align: left;
123
+ color: var(--playpilot-list-item-text-color, var(--playpilot-text-color));
124
+ font-size: var(--playpilot-detail-font-size, 14px);
125
+ line-height: normal;
126
+
127
+ @media (min-width: 600px) {
128
+ padding-right: margin(1);
129
+ }
130
+ }
131
+
132
+ .heading {
133
+ color: var(--playpilot-list-item-title-text-color, var(--playpilot-detail-title-text-color, var(--playpilot-text-color)));
134
+ font-weight: var(--playpilot-list-item-title-font-weight, var(--playpilot-detail-title-font-weight, 500));
135
+ }
136
+
137
+ .meta {
138
+ display: flex;
139
+ flex-wrap: wrap;
140
+ gap: 0 margin(0.5);
141
+ margin: margin(0.125) 0;
142
+ font-size: var(--playpilot-detail-font-size-small, 12px);
143
+ font-weight: var(--playpilot-list-item-meta-font-weight, var(--playpilot-detail-title-font-weight, 500));
144
+ color: var(--playpilot-list-item-meta-text-color, var(--playpilot-text-color-alt));
145
+
146
+ > div {
147
+ text-transform: capitalize;
148
+ }
149
+ }
150
+
151
+ .imdb {
152
+ display: flex;
153
+ align-items: center;
154
+ gap: margin(0.25);
155
+ }
156
+
157
+ .description {
158
+ margin-bottom: margin(0.375);
159
+ overflow: hidden;
160
+ text-overflow: ellipsis;
161
+ display: -webkit-box;
162
+ line-clamp: 1;
163
+ -webkit-line-clamp: 1;
164
+ -webkit-box-orient: vertical;
165
+ font-size: var(--playpilot-detail-font-size-small, 12px);
166
+ color: var(--playpilot-list-item-description-text-color, var(--playpilot-text-color));
167
+ }
168
+
169
+ .playlinks {
170
+ display: flex;
171
+ flex-wrap: wrap;
172
+ gap: margin(0.25);
173
+ margin-top: auto;
174
+ }
175
+
176
+ .more {
177
+ display: flex;
178
+ align-items: center;
179
+ padding: 0 margin(0.25);
180
+ color: var(--playpilot-list-item-more-text-color, var(--playpilot-text-color-alt));
181
+ font-size: var(--playpilot-detail-font-size-small, 12px);
182
+ }
183
+
184
+ .empty {
185
+ margin-top: margin(0.5);
186
+ opacity: 0.65;
187
+ font-style: italic;
188
+ font-size: 0.85em;
189
+ white-space: initial;
190
+ }
191
+
192
+ .action {
193
+ display: flex;
194
+ margin: 0 margin(0.5) 0 auto;
195
+ padding-left: margin(0.5);
196
+ align-self: center;
197
+ color: var(--playpilot-list-item-action-color, var(--playpilot-detail-text-color, var(--playpilot-text-color-alt)));
198
+
199
+ &:hover,
200
+ &:active {
201
+ color: var(--playpilot-list-item-action-hover-color, var(--playpilot-detail-text-color, var(--playpilot-text-color)));
202
+ }
203
+ }
204
+ </style>