@playpilot/tpi 5.20.2 → 5.21.0-beta.share.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.
package/package.json CHANGED
@@ -1,6 +1,6 @@
1
1
  {
2
2
  "name": "@playpilot/tpi",
3
- "version": "5.20.2",
3
+ "version": "5.21.0-beta.share.2",
4
4
  "type": "module",
5
5
  "scripts": {
6
6
  "dev": "vite dev",
@@ -0,0 +1,14 @@
1
+ export function copyToClipboard(text: string): void {
2
+ const textarea = document.createElement("textarea")
3
+
4
+ textarea.value = text
5
+ textarea.style.position = "fixed"
6
+ textarea.style.left = "-9999px"
7
+
8
+ document.body.appendChild(textarea)
9
+
10
+ textarea.select()
11
+
12
+ document.execCommand("copy")
13
+ document.body.removeChild(textarea)
14
+ }
@@ -91,6 +91,21 @@ export const translations = {
91
91
  [Language.Swedish]: 'Se',
92
92
  [Language.Danish]: 'Se',
93
93
  },
94
+ 'Share': {
95
+ [Language.English]: 'Share',
96
+ [Language.Swedish]: 'Dela',
97
+ [Language.Danish]: 'Del',
98
+ },
99
+ 'Copy URL': {
100
+ [Language.English]: 'Copy URL',
101
+ [Language.Swedish]: 'Kopiera URL',
102
+ [Language.Danish]: 'Kopiér URL',
103
+ },
104
+ 'Email': {
105
+ [Language.English]: 'Email',
106
+ [Language.Swedish]: 'E-post',
107
+ [Language.Danish]: 'E-mail',
108
+ },
94
109
 
95
110
  // Genres
96
111
  'All': {
@@ -50,4 +50,7 @@ export const TrackingEvent = Object.freeze({
50
50
  // Split tests
51
51
  SplitTestView: 'ali_split_test_view',
52
52
  SplitTestAction: 'ali_split_test_action',
53
+
54
+ // Share
55
+ ShareTitle: 'ali_share_title'
53
56
  })
@@ -254,7 +254,7 @@ export function injectLinksInDocument(elements: HTMLElement[], injections: LinkI
254
254
  }
255
255
  }
256
256
 
257
- addLinkInjectionEventListeners(foundInjections)
257
+ addLinkInjectionEventListeners(validInjections)
258
258
  addCSSVariablesToLinks()
259
259
 
260
260
  const afterArticleInjections = filterInvalidAfterArticleInjections(mergedInjections)
@@ -269,7 +269,7 @@ export function injectLinksInDocument(elements: HTMLElement[], injections: LinkI
269
269
 
270
270
  const matchingElement = document.querySelector(`[${keyDataAttribute}="${injection.key}"]`)
271
271
  const failed = isValidPlaylinkType(injection) && !injection.inactive && !injection.removed && !injection.after_article && !matchingElement
272
- const containsSentence = foundInjections.find(i => i.key === injection.key)
272
+ const containsSentence = !!elements.find(element => cleanPhrase(element.innerText).includes(cleanPhrase(injection.sentence)))
273
273
  const failedMessage =
274
274
  !failed ? '' :
275
275
  failedMessages[injection.key] ||
package/src/lib/text.ts CHANGED
@@ -61,11 +61,7 @@ export function replaceBetween(text: string, replacement: string, startIndex: nu
61
61
  export function cleanPhrase(phrase: string): string {
62
62
  return decodeHtmlEntities(phrase)
63
63
  .toLowerCase()
64
- .normalize('NFD')
65
- .replace(/\p{Diacritic}/gu, '') // Replace accents and other diacritic symbols (https://stackoverflow.com/a/51874002/1665157)
66
64
  .replace(/\s+/g, '') // Replace any number of white space with nothing
67
- .replace(/['"‘’“”]/g, '"') // Replace all qoutes with the same type of quote
68
- .replace(/^['"]+|['"]+$/g, '') // Remove leading and trailing quotes
69
65
  .replace(/\.*$/, '') // Remove trailing periods
70
66
  .replaceAll('…', '...') // Replace ellipsis character with regular characters
71
67
  }
@@ -47,4 +47,8 @@
47
47
  background: var(--playpilot-genre-hover-background, var(--playpilot-genre-background, var(--playpilot-content)));
48
48
  }
49
49
  }
50
+
51
+ .expand {
52
+ margin-left: margin(-0.5);
53
+ }
50
54
  </style>
@@ -0,0 +1,3 @@
1
+ <svg viewBox="0 -960 960 960" width="18px" height="18px">
2
+ <path fill="currentColor" d="M160-160q-33 0-56.5-23.5T80-240v-480q0-33 23.5-56.5T160-800h640q33 0 56.5 23.5T880-720v480q0 33-23.5 56.5T800-160H160Zm320-280L160-640v400h640v-400L480-440Zm0-80 320-200H160l320 200ZM160-640v-80 480-400Z"/>
3
+ </svg>
@@ -0,0 +1,3 @@
1
+ <svg viewBox="0 -960 960 960" width="18px" height="18px">
2
+ <path fill="currentColor" d="M440-280H280q-83 0-141.5-58.5T80-480q0-83 58.5-141.5T280-680h160v80H280q-50 0-85 35t-35 85q0 50 35 85t85 35h160v80ZM320-440v-80h320v80H320Zm200 160v-80h160q50 0 85-35t35-85q0-50-35-85t-85-35H520v-80h160q83 0 141.5 58.5T880-480q0 83-58.5 141.5T680-280H520Z"/>
3
+ </svg>
@@ -0,0 +1,3 @@
1
+ <svg viewBox="0 -960 960 960" width="18px" height="18px">
2
+ <path d="M680-80q-50 0-85-35t-35-85q0-6 3-28L282-392q-16 15-37 23.5t-45 8.5q-50 0-85-35t-35-85q0-50 35-85t85-35q24 0 45 8.5t37 23.5l281-164q-2-7-2.5-13.5T560-760q0-50 35-85t85-35q50 0 85 35t35 85q0 50-35 85t-85 35q-24 0-45-8.5T598-672L317-508q2 7 2.5 13.5t.5 14.5q0 8-.5 14.5T317-452l281 164q16-15 37-23.5t45-8.5q50 0 85 35t35 85q0 50-35 85t-85 35Zm0-80q17 0 28.5-11.5T720-200q0-17-11.5-28.5T680-240q-17 0-28.5 11.5T640-200q0 17 11.5 28.5T680-160ZM200-440q17 0 28.5-11.5T240-480q0-17-11.5-28.5T200-520q-17 0-28.5 11.5T160-480q0 17 11.5 28.5T200-440Zm480-280q17 0 28.5-11.5T720-760q0-17-11.5-28.5T680-800q-17 0-28.5 11.5T640-760q0 17 11.5 28.5T680-720Zm0 520ZM200-480Zm480-280Z" fill="currentColor" />
3
+ </svg>
@@ -0,0 +1,138 @@
1
+ <script lang="ts">
2
+ import { scale } from 'svelte/transition'
3
+ import { mobileBreakpoint } from '$lib/constants'
4
+ import { copyToClipboard } from '$lib/clipboard'
5
+ import { track } from '$lib/tracking'
6
+ import { TrackingEvent } from '$lib/enums/TrackingEvent'
7
+ import { t } from '$lib/localization'
8
+ import IconShare from './Icons/IconShare.svelte'
9
+ import IconLink from './Icons/IconLink.svelte'
10
+ import IconEmail from './Icons/IconEmail.svelte'
11
+
12
+ interface Props {
13
+ title: string
14
+ url: string
15
+ }
16
+
17
+ const { title, url }: Props = $props()
18
+
19
+ const isMobile = window.innerWidth < mobileBreakpoint
20
+ const useShareApi = isMobile && typeof navigator.share !== 'undefined'
21
+
22
+ let showContextMenu = $state(false)
23
+
24
+ async function toggle(event: MouseEvent): Promise<void> {
25
+ event.stopPropagation()
26
+
27
+ if (useShareApi) {
28
+ await navigator.share({ title, url })
29
+
30
+ track(TrackingEvent.ShareTitle, null, { title, url, method: 'native' })
31
+
32
+ return
33
+ }
34
+
35
+ showContextMenu = !showContextMenu
36
+ }
37
+
38
+ function copy() {
39
+ copyToClipboard(url)
40
+ track(TrackingEvent.ShareTitle, null, { title, url, method: 'copy' })
41
+ }
42
+
43
+ function email() {
44
+ window.location.href = `mailto:?body=${url}`
45
+ track(TrackingEvent.ShareTitle, null, { title, url, method: 'email' })
46
+ }
47
+ </script>
48
+
49
+ <svelte:window onclick={() => showContextMenu = false} />
50
+
51
+ <div class="share">
52
+ <button class="button" onclick={toggle} aria-label={t('Share')}>
53
+ <IconShare />
54
+ </button>
55
+
56
+ {#if showContextMenu}
57
+ <div class="context-menu" transition:scale={{ duration: 50, start: 0.85 }}>
58
+ <button class="item" onclick={copy}>
59
+ <IconLink /> {t('Copy URL')}
60
+ </button>
61
+
62
+ <button class="item" onclick={email}>
63
+ <IconEmail /> {t('Email')}
64
+ </button>
65
+ </div>
66
+ {/if}
67
+ </div>
68
+
69
+ <style lang="scss">
70
+ .share {
71
+ position: relative;
72
+ }
73
+
74
+ .button {
75
+ display: flex;
76
+ align-items: center;
77
+ justify-content: center;
78
+ cursor: pointer;
79
+ appearance: none;
80
+ border: 0;
81
+ border-radius: margin(3);
82
+ aspect-ratio: 1 / 1;
83
+ background: transparent;
84
+ color: var(--playpilot-detail-text-color-alt, var(--playpilot-text-color-alt));
85
+
86
+ &:hover {
87
+ color: var(--playpilot-detail-text-color, var(--playpilot-text-color));
88
+ box-shadow: 0 0 0 2px currentColor;
89
+ }
90
+ }
91
+
92
+ .context-menu {
93
+ --border-radius: var(--playpilot-context-menu-border-radius, #{margin(0.5)});
94
+ z-index: 10;
95
+ position: absolute;
96
+ bottom: calc(100% + margin(0.5));
97
+ right: 0;
98
+ max-width: margin(15);
99
+ border-radius: var(--border-radius);
100
+ background: var(--playpilot-context-menu-background, var(--playpilot-detail-background-light, var(--playpilot-lighter)));
101
+ box-shadow: var(--playpilot-shadow);
102
+ }
103
+
104
+ .item {
105
+ cursor: pointer;
106
+ appearance: none;
107
+ display: flex;
108
+ align-items: center;
109
+ gap: margin(0.5);
110
+ border: 0;
111
+ padding: margin(0.75) margin(1);
112
+ width: 100%;
113
+ border-bottom: 1px solid var(--playpilot-context-menu-border, rgba(255, 255, 255, 0.1));
114
+ background: transparent;
115
+ white-space: nowrap;
116
+ color: var(--playpilot-detail-text-color, var(--playpilot-text-color));
117
+ font-family: inherit;
118
+ font-weight: normal;
119
+ text-align: left;
120
+
121
+ &:hover {
122
+ box-shadow: inset 0 0 0 2px currentColor;
123
+ }
124
+
125
+ &:first-child {
126
+ border-radius: var(--border-radius) var(--border-radius) 0 0;
127
+ }
128
+
129
+ &:last-child {
130
+ border-radius: 0 0 var(--border-radius) var(--border-radius);
131
+ border-bottom: 0;
132
+ }
133
+
134
+ &:not(:hover) :global(svg) {
135
+ opacity: 0.75;
136
+ }
137
+ }
138
+ </style>
@@ -6,10 +6,12 @@
6
6
  import ParticipantsRail from './Rails/ParticipantsRail.svelte'
7
7
  import SimilarRail from './Rails/SimilarRail.svelte'
8
8
  import TitlePoster from './TitlePoster.svelte'
9
+ import Share from './Share.svelte'
9
10
  import { t } from '$lib/localization'
10
11
  import type { TitleData } from '$lib/types/title'
11
12
  import { heading } from '$lib/actions/heading'
12
13
  import { removeImageUrlPrefix } from '$lib/image'
14
+ import { titleUrl } from '$lib/routes'
13
15
 
14
16
  interface Props {
15
17
  title: TitleData
@@ -31,20 +33,26 @@
31
33
 
32
34
  <div class="heading" use:heading={2} class:truncate={small} id="title">{title.title}</div>
33
35
 
34
- <div class="info">
35
- <div class="imdb">
36
- <IconIMDb />
37
- {title.imdb_score}
38
- </div>
36
+ <div class="row">
37
+ <div class="info">
38
+ <div class="imdb">
39
+ <IconIMDb />
40
+ {title.imdb_score}
41
+ </div>
42
+
43
+ <Genres genres={title.genres} />
39
44
 
40
- <Genres genres={title.genres} />
45
+ <div>{title.year}</div>
46
+ <div class="capitalize">{t(`Type: ${title.type}`)}</div>
41
47
 
42
- <div>{title.year}</div>
43
- <div class="capitalize">{t(`Type: ${title.type}`)}</div>
48
+ {#if !small && title.length}
49
+ <div>{title.length} {t('Minutes')}</div>
50
+ {/if}
51
+ </div>
44
52
 
45
- {#if !small && title.length}
46
- <div>{title.length} {t('Minutes')}</div>
47
- {/if}
53
+ <div class="action">
54
+ <Share title={title.title} url={titleUrl(title)} />
55
+ </div>
48
56
  </div>
49
57
  </div>
50
58
 
@@ -138,11 +146,20 @@
138
146
  }
139
147
  }
140
148
 
149
+ .row {
150
+ display: flex;
151
+ align-items: flex-start;
152
+ }
153
+
141
154
  .info {
142
155
  display: flex;
143
156
  flex-wrap: wrap;
144
157
  align-items: center;
145
- gap: margin(0.5) margin(1);
158
+ gap: margin(0.5) margin(0.75);
159
+
160
+ @include desktop() {
161
+ gap: margin(0.5) margin(1);
162
+ }
146
163
 
147
164
  .small & {
148
165
  gap: margin(0.5) margin(0.75);
@@ -165,6 +182,10 @@
165
182
  }
166
183
  }
167
184
 
185
+ .action {
186
+ margin: margin(-0.125) 0 0 auto;
187
+ }
188
+
168
189
  .background {
169
190
  position: absolute;
170
191
  top: 0;
@@ -312,50 +312,6 @@ describe('linkInjection.js', () => {
312
312
  expect(document.querySelectorAll('[data-playpilot-injection-key]')).toHaveLength(2)
313
313
  })
314
314
 
315
- it('Should disregard missing trailing periods', () => {
316
- document.body.innerHTML = '<p>This is a sentence with a phrase in it</p>'
317
-
318
- const elements = Array.from(document.body.querySelectorAll('p'))
319
- const injection = generateInjection('This is a sentence with a phrase in it.', 'a phrase')
320
-
321
- injectLinksInDocument(elements, { aiInjections: [injection], manualInjections: [] })
322
-
323
- expect(document.querySelectorAll('[data-playpilot-injection-key]')).toHaveLength(1)
324
- })
325
-
326
- it('Should disregard additional quotes', () => {
327
- document.body.innerHTML = '<p>This is part of a paragraph. This is a sentence with a phrase in it."</p>'
328
-
329
- const elements = Array.from(document.body.querySelectorAll('p'))
330
- const injection = generateInjection('"This is a sentence with a phrase in it."', 'a phrase')
331
-
332
- injectLinksInDocument(elements, { aiInjections: [injection], manualInjections: [] })
333
-
334
- expect(document.querySelectorAll('[data-playpilot-injection-key]')).toHaveLength(1)
335
- })
336
-
337
- it('Should disregard differences in accents', () => {
338
- document.body.innerHTML = '<p>This is à sentence with a phràse in it."</p>'
339
-
340
- const elements = Array.from(document.body.querySelectorAll('p'))
341
- const injection = generateInjection('This is a sentence with a phrase in it.', 'a phràse')
342
-
343
- injectLinksInDocument(elements, { aiInjections: [injection], manualInjections: [] })
344
-
345
- expect(document.querySelectorAll('[data-playpilot-injection-key]')).toHaveLength(1)
346
- })
347
-
348
- it('Should treat different types of quotes as the same', () => {
349
- document.body.innerHTML = '<p>This is a “sentence” with a "phrase" in it.</p>'
350
-
351
- const elements = Array.from(document.body.querySelectorAll('p'))
352
- const injection = generateInjection('This is a "sentence" with a \'phrase’ in it.', 'phrase')
353
-
354
- injectLinksInDocument(elements, { aiInjections: [injection], manualInjections: [] })
355
-
356
- expect(document.querySelectorAll('[data-playpilot-injection-key]')).toHaveLength(1)
357
- })
358
-
359
315
  it('Should leave the text intact if no injections were found', () => {
360
316
  document.body.innerHTML = '<p>This is a sentence with an injection.</p>'
361
317
 
@@ -112,32 +112,6 @@ describe('text.js', () => {
112
112
  it('Should return given phrase without trailing period', () => {
113
113
  expect(cleanPhrase('Some phrase.')).toBe('somephrase')
114
114
  })
115
-
116
- it('Should return given phrase without leading or trailing quotes', () => {
117
- expect(cleanPhrase('"Some phrase"')).toBe('somephrase')
118
- expect(cleanPhrase('Some phrase"')).toBe('somephrase')
119
- expect(cleanPhrase('"Some phrase')).toBe('somephrase')
120
-
121
- expect(cleanPhrase('\'Some phrase\'')).toBe('somephrase')
122
- expect(cleanPhrase('Some phrase\'')).toBe('somephrase')
123
- expect(cleanPhrase('\'Some phrase')).toBe('somephrase')
124
- })
125
-
126
- it('Should return given phrase without leading or trailing quotes and trailing period', () => {
127
- expect(cleanPhrase('"Some phrase."')).toBe('somephrase')
128
- })
129
-
130
- it('Should disregard accents and diacritics', () => {
131
- expect(cleanPhrase('María')).toBe(cleanPhrase('Maria'))
132
- expect(cleanPhrase('Göteborg')).toBe(cleanPhrase('Goteborg'))
133
- expect(cleanPhrase('über')).toBe(cleanPhrase('uber'))
134
- })
135
-
136
- it('Should disregard differences in quotes', () => {
137
- expect(cleanPhrase('Some "phrase"')).toBe(cleanPhrase('Some \'phrase\''))
138
- expect(cleanPhrase('Some ”phrase“')).toBe(cleanPhrase('Some "phrase"'))
139
- expect(cleanPhrase('Some ’phrase\'')).toBe(cleanPhrase('Some "phrase"'))
140
- })
141
115
  })
142
116
 
143
117
  describe('truncateAroundPhrase', () => {
@@ -0,0 +1,74 @@
1
+ import { render, fireEvent } from '@testing-library/svelte'
2
+ import { describe, expect, it, vi } from 'vitest'
3
+
4
+ import Share from '../../../routes/components/Share.svelte'
5
+ import { copyToClipboard } from '$lib/clipboard'
6
+ import { track } from '$lib/tracking'
7
+ import { TrackingEvent } from '$lib/enums/TrackingEvent'
8
+
9
+ vi.mock('$lib/clipboard', () => ({
10
+ copyToClipboard: vi.fn(),
11
+ }))
12
+
13
+ vi.mock('$lib/tracking', () => ({
14
+ track: vi.fn(),
15
+ }))
16
+
17
+ describe('Share.svelte', () => {
18
+ it('Should open context menu on click', async () => {
19
+ const { getByLabelText, queryByText } = render(Share, { title: 'Some title', url: 'some-url' })
20
+
21
+ expect(queryByText('Copy URL')).not.toBeTruthy()
22
+ expect(queryByText('Email')).not.toBeTruthy()
23
+
24
+ await fireEvent.click(getByLabelText('Share'))
25
+
26
+ expect(queryByText('Copy URL')).toBeTruthy()
27
+ expect(queryByText('Email')).toBeTruthy()
28
+ })
29
+
30
+ it('Should close context menu on click of items', async () => {
31
+ const { getByLabelText, getByText, queryByText } = render(Share, { title: 'Some title', url: 'some-url' })
32
+
33
+ await fireEvent.click(getByLabelText('Share'))
34
+ await fireEvent.click(getByText('Copy URL'))
35
+
36
+ expect(queryByText('Copy URL')).not.toBeTruthy()
37
+ })
38
+
39
+ it('Should close context menu on click of body', async () => {
40
+ const { getByLabelText, queryByText } = render(Share, { title: 'Some title', url: 'some-url' })
41
+
42
+ await fireEvent.click(getByLabelText('Share'))
43
+ await fireEvent.click(document.body)
44
+
45
+ expect(queryByText('Copy URL')).not.toBeTruthy()
46
+ })
47
+
48
+ it('Should fire copyToClipboard on click of button', async () => {
49
+ const { getByLabelText, getByText } = render(Share, { title: 'Some title', url: 'some-url' })
50
+
51
+ await fireEvent.click(getByLabelText('Share'))
52
+ await fireEvent.click(getByText('Copy URL'))
53
+
54
+ expect(copyToClipboard).toHaveBeenCalledWith('some-url')
55
+ })
56
+
57
+ it('Should fire track function on click of copy URL button', async () => {
58
+ const { getByLabelText, getByText } = render(Share, { title: 'Some title', url: 'some-url' })
59
+
60
+ await fireEvent.click(getByLabelText('Share'))
61
+ await fireEvent.click(getByText('Copy URL'))
62
+
63
+ expect(track).toHaveBeenCalledWith(TrackingEvent.ShareTitle, null, { title: 'Some title', url: 'some-url', method: 'copy' })
64
+ })
65
+
66
+ it('Should fire track function on click of email button', async () => {
67
+ const { getByLabelText, getByText } = render(Share, { title: 'Some title', url: 'some-url' })
68
+
69
+ await fireEvent.click(getByLabelText('Share'))
70
+ await fireEvent.click(getByText('Email'))
71
+
72
+ expect(track).toHaveBeenCalledWith(TrackingEvent.ShareTitle, null, { title: 'Some title', url: 'some-url', method: 'email' })
73
+ })
74
+ })