@playpilot/tpi 3.0.1 → 3.1.0-beta.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 (30) hide show
  1. package/dist/link-injections.js +7 -7
  2. package/package.json +1 -1
  3. package/src/lib/api.ts +21 -1
  4. package/src/lib/enums/TrackingEvent.ts +2 -0
  5. package/src/lib/linkInjection.ts +8 -8
  6. package/src/lib/text.ts +24 -0
  7. package/src/lib/types/config.d.ts +3 -0
  8. package/src/routes/+page.svelte +18 -3
  9. package/src/routes/components/Editorial/AIIndicator.svelte +43 -2
  10. package/src/routes/components/Editorial/DragHandle.svelte +3 -3
  11. package/src/routes/components/Editorial/Editor.svelte +58 -28
  12. package/src/routes/components/Editorial/EditorItem.svelte +52 -24
  13. package/src/routes/components/Editorial/ManualInjection.svelte +69 -16
  14. package/src/routes/components/Editorial/ResizeHandle.svelte +111 -0
  15. package/src/routes/components/Editorial/Search/TitleSearch.svelte +17 -90
  16. package/src/routes/components/Editorial/Search/TitleSearchItem.svelte +107 -0
  17. package/src/routes/components/Title.svelte +28 -4
  18. package/src/tests/helpers.js +2 -1
  19. package/src/tests/lib/api.test.js +30 -1
  20. package/src/tests/lib/linkInjection.test.js +39 -11
  21. package/src/tests/lib/text.test.js +23 -1
  22. package/src/tests/routes/+page.test.js +52 -5
  23. package/src/tests/routes/components/Editorial/AiIndicator.test.js +5 -2
  24. package/src/tests/routes/components/Editorial/DragHandle.test.js +5 -5
  25. package/src/tests/routes/components/Editorial/Editor.test.js +69 -0
  26. package/src/tests/routes/components/Editorial/EditorItem.test.js +34 -10
  27. package/src/tests/routes/components/Editorial/ManualInjection.test.js +5 -1
  28. package/src/tests/routes/components/Editorial/ResizeHandle.test.js +45 -0
  29. package/src/tests/routes/components/Editorial/Search/TitleSearch.test.js +6 -4
  30. package/src/tests/routes/components/Editorial/Search/TitleSearchItem.test.js +44 -0
@@ -44,6 +44,13 @@
44
44
 
45
45
  error = ''
46
46
  currentSelection = selectionText
47
+
48
+ // Selection spanned more than 1 element or contained another element. This will likely result in a failed injection.
49
+ if (selection.anchorNode !== selection.focusNode) {
50
+ error = 'Selection is broken up by multiple elements. Please select the text more directly.'
51
+ return
52
+ }
53
+
47
54
  query = currentSelection
48
55
  selectionSentence = findSentenceForSelection(selection, selectionText)
49
56
 
@@ -65,23 +72,63 @@
65
72
  // This is meant for content that is inside of other elements such as <p>Some <strong>word</strong> in a sentence</p>
66
73
  // If we selected "word", we'd still want the full sentence, rather than just the "word".
67
74
  let node = range.startContainer
68
- if ((node.textContent || '').length <= selectionText.length * 2 && range.startContainer.parentNode) {
69
- node = range.startContainer.parentNode
75
+ while((node.textContent || '').length <= selectionText.length * 3 && node.parentNode) {
76
+ node = node.parentNode
70
77
  }
71
78
 
72
- if (!node) return ''
79
+ if (!node || !node.textContent) return ''
73
80
 
74
- const fullText = node.textContent || ''
75
- const startOffset = range.startOffset
76
- const endOffset = range.endOffset
81
+ const fullText = node.textContent
77
82
 
78
- const before = fullText.slice(0, startOffset).lastIndexOf('.') // Character at start of the sentence
79
- const after = fullText.slice(endOffset).search(/[.!?]/) // Character at end of the sentence
83
+ const absoluteStart = getAbsoluteOffset(node, range.startContainer, range.startOffset)
84
+ const absoluteEnd = getAbsoluteOffset(node, range.endContainer, range.endOffset)
85
+
86
+ // Find sentence boundaries
87
+ const before = fullText.slice(0, absoluteStart).lastIndexOf('.')
88
+ const afterMatch = fullText.slice(absoluteEnd).match(/[.!?]/)
89
+ const after = afterMatch ? absoluteEnd + afterMatch.index! : fullText.length
80
90
 
81
91
  const sentenceStart = before === -1 ? 0 : before + 1
82
- const sentenceEnd = after === -1 ? fullText.length : endOffset + after + 1
92
+ const sentenceEnd = after
83
93
 
84
- return fullText.slice(sentenceStart, sentenceEnd).trim()
94
+ return fullText.slice(sentenceStart, sentenceEnd + 1).trim()
95
+ }
96
+
97
+ /**
98
+ * Get the offset of text in a sentence based on the node it is in. When we select text that is
99
+ * inside of another node, range.startOffset only returns the offset inside of it's original node.
100
+ * We need to get the offset based on the full sentence it is in. We traverse each node inside of
101
+ * the parent and add up the total offset.
102
+ */
103
+ function getAbsoluteOffset(node: Node, container: Node, offset: number): number {
104
+ let total = 0
105
+ const parent = node
106
+
107
+ const traverse = (current: Node): boolean => {
108
+ if (current === container) {
109
+ total += offset
110
+ return true
111
+ }
112
+
113
+ if (current.nodeType === Node.TEXT_NODE) total += current.textContent?.length || 0
114
+
115
+ for (let child = current.firstChild; child; child = child.nextSibling) {
116
+ if (traverse(child)) return true
117
+ }
118
+
119
+ return false
120
+ }
121
+
122
+ traverse(parent)
123
+ return total
124
+ }
125
+
126
+ function onselect(title: TitleData) {
127
+ selectedTitle = title
128
+
129
+ // Remove selection that was made anywhere on the page. This is to avoid the input from being overwritten
130
+ // by the selection right after selecting a result.
131
+ window.getSelection()?.removeAllRanges?.()
85
132
  }
86
133
 
87
134
  function save(): void {
@@ -126,7 +173,9 @@
126
173
  {/if}
127
174
 
128
175
  <label for="text">Paired title</label>
129
- <TitleSearch onselect={(title) => selectedTitle = title} bind:query />
176
+ {#key selectionSentence + currentSelection}
177
+ <TitleSearch {onselect} bind:query />
178
+ {/key}
130
179
 
131
180
  <button class="save" onclick={save} disabled={!currentSelection || !selectedTitle}>
132
181
  Add playlink
@@ -136,8 +185,10 @@
136
185
  <style lang="scss">
137
186
  h2 {
138
187
  margin: 0;
188
+ color: var(--playpilot-text-color);
139
189
  font-size: margin(1);
140
- font-weight: normal
190
+ line-height: normal;
191
+ font-weight: normal;
141
192
  }
142
193
 
143
194
  p,
@@ -156,6 +207,7 @@
156
207
  height: 100%;
157
208
  display: flex;
158
209
  flex-direction: column;
210
+ margin: 0;
159
211
  }
160
212
 
161
213
  .header {
@@ -177,19 +229,20 @@
177
229
  padding: margin(0.5);
178
230
  border: 0;
179
231
  border-radius: margin(2);
180
- background: var(--playpilot-content);
232
+ background: var(--playpilot-green);
181
233
  transition: opacity 100ms;
182
234
  font-family: inherit;
183
- color: var(--playpilot-text-color-alt);
235
+ color: var(--playpilot-text-color);
184
236
 
185
237
  &:not([disabled]):hover {
186
- background: var(--playpilot-content-light);
187
- color: var(--playpilot-text-color);
238
+ outline: 2px solid currentColor;
188
239
  }
189
240
 
190
241
  &[disabled] {
191
242
  cursor: default;
243
+ background: var(--playpilot-light);
192
244
  opacity: 0.5;
245
+ color: var(--playpilot-text-color-alt);
193
246
  }
194
247
  }
195
248
  </style>
@@ -0,0 +1,111 @@
1
+ <script lang="ts">
2
+ import { onMount } from 'svelte'
3
+
4
+ interface Props {
5
+ element: HTMLElement,
6
+ height: number,
7
+ // eslint-disable-next-line no-unused-vars
8
+ onchange?: (height: number) => void
9
+ }
10
+
11
+ let { element, height = 200, onchange = () => null }: Props = $props()
12
+
13
+ const offset = 16
14
+
15
+ let isResizing = false
16
+ let startY = 0
17
+ let startHeight = height
18
+
19
+ onMount(() => {
20
+ setHeight(height)
21
+ })
22
+
23
+ function setHeight(newHeight: number): void {
24
+ const minHeight = window.getComputedStyle(element).minHeight || '0'
25
+ const clampedHeight = Math.max(parseInt(minHeight), Math.min(newHeight, getMaxHeight()))
26
+
27
+ element.style.height = `${clampedHeight}px`
28
+
29
+ height = clampedHeight
30
+ onchange(clampedHeight)
31
+ }
32
+
33
+ function start(event: MouseEvent | TouchEvent): void {
34
+ isResizing = true
35
+ // @ts-ignore
36
+ startY = event.pageY || event.touches?.[0]?.pageY || 0
37
+ startHeight = element.clientHeight
38
+ }
39
+
40
+ function move(event: MouseEvent | TouchEvent): void {
41
+ if (!isResizing) return
42
+
43
+ // @ts-ignore
44
+ const currentY = event.pageY || event.touches?.[0]?.pageY || 0
45
+ const difference = startY - currentY
46
+ const newHeight = startHeight + difference
47
+
48
+ setHeight(newHeight)
49
+ }
50
+
51
+ function end() {
52
+ isResizing = false
53
+ }
54
+
55
+ function getMaxHeight(): number {
56
+ const elementBottom = window.innerHeight - element.getBoundingClientRect().bottom
57
+ return Math.max(window.innerHeight - (offset * 2) - elementBottom, 0)
58
+ }
59
+ </script>
60
+
61
+ <svelte:window
62
+ onmousemove={move}
63
+ ontouchmove={move}
64
+ onmouseup={end}
65
+ ontouchend={end}
66
+ onresize={() => setHeight(height)} />
67
+
68
+ <button class="resize-handle" onmousedown={start} ontouchstart={start} aria-label="Move editor"></button>
69
+
70
+ <style lang="scss">
71
+ .resize-handle {
72
+ z-index: 10;
73
+ appearance: none;
74
+ position: absolute;
75
+ top: 0;
76
+ left: 20%;
77
+ width: 60%;
78
+ height: margin(0.5);
79
+ background: transparent;
80
+ border: 0;
81
+ cursor: ns-resize;
82
+
83
+ &:hover::before {
84
+ opacity: 0.65;
85
+ transform: scale(1.05);
86
+ }
87
+
88
+ &:active::before {
89
+ opacity: 0.9;
90
+ transform: scale(0.95);
91
+ }
92
+
93
+ &:active {
94
+ cursor: grabbing;
95
+ }
96
+
97
+ &::before {
98
+ display: block;
99
+ content: "";
100
+ position: absolute;
101
+ top: 0;
102
+ right: 0;
103
+ left: 0;
104
+ bottom: 80%;
105
+ border-radius: 0 0 margin(1) margin(1);
106
+ background: var(--playpilot-text-color);
107
+ opacity: 0.15;
108
+ transition: opacity 100ms, transform 50ms;
109
+ }
110
+ }
111
+ </style>
@@ -1,14 +1,12 @@
1
1
  <script lang="ts">
2
- import { playPilotBaseUrl } from '$lib/constants'
3
2
  import { searchTitles } from '$lib/search'
4
3
  import type { TitleData } from '$lib/types/title'
5
- import IconIMDb from '../../Icons/IconIMDb.svelte'
6
- import IconNewTab from '../../Icons/IconNewTab.svelte'
7
4
  import TextInput from '../TextInput.svelte'
5
+ import TitleSearchItem from './TitleSearchItem.svelte'
8
6
 
9
7
  interface Props {
10
8
  // eslint-disable-next-line no-unused-vars
11
- onselect?: (title: TitleData) => void,
9
+ onselect?: (title: TitleData) => void
12
10
  query: string
13
11
  }
14
12
 
@@ -22,9 +20,10 @@
22
20
  if (query) search(query)
23
21
  })
24
22
 
23
+ $inspect(selectedResult)
24
+
25
25
  async function search(query: string): Promise<void> {
26
26
  loading = true
27
- selectedResult = null
28
27
 
29
28
  try {
30
29
  results = await searchTitles(query)
@@ -44,41 +43,14 @@
44
43
  <div class="search">
45
44
  <TextInput
46
45
  bind:value={query}
46
+ oninput={() => selectedResult = null}
47
47
  name="search"
48
48
  label="Search..." />
49
49
 
50
50
  {#if query && !selectedResult}
51
- <div class="results">
51
+ <div class="results" data-testid="search-results">
52
52
  {#each results as title (title.sid)}
53
- <button class="item" onclick={() => select(title)}>
54
- <img class="poster" src={title.standing_poster} alt="" width="28" height="42" />
55
-
56
- <div class="content">
57
- <div class="name">{title.title}</div>
58
-
59
- <div class="meta">
60
- <div class="imdb">
61
- <IconIMDb />
62
- {title.imdb_score}
63
- </div>
64
-
65
- <div>{title.year}</div>
66
- <div>{title.type}</div>
67
-
68
- {#if title.length}
69
- <div>{title.length} min</div>
70
- {/if}
71
- </div>
72
- </div>
73
-
74
- <a
75
- href="{playPilotBaseUrl}/{title.type}/{title.slug}"
76
- target="_blank"
77
- class="open-in-new-tab"
78
- onclick={event => event.stopImmediatePropagation()}>
79
- <IconNewTab />
80
- </a>
81
- </button>
53
+ <TitleSearchItem {title} onclick={() => select(title)} />
82
54
  {:else}
83
55
  {#if !loading}
84
56
  <em class="empty">No results found</em>
@@ -86,6 +58,12 @@
86
58
  {/each}
87
59
  </div>
88
60
  {/if}
61
+
62
+ {#if selectedResult}
63
+ <div class="selected">
64
+ <TitleSearchItem title={selectedResult} hoverable={false} />
65
+ </div>
66
+ {/if}
89
67
  </div>
90
68
 
91
69
  <style lang="scss">
@@ -107,66 +85,15 @@
107
85
  overflow: auto;
108
86
  }
109
87
 
110
- .item {
111
- cursor: pointer;
112
- appearance: none;
113
- display: flex;
114
- align-items: flex-start;
115
- gap: margin(1);
116
- width: 100%;
117
- background: transparent;
118
- border: 0;
119
- padding: margin(0.5);
120
- border-bottom: 1px solid var(--playpilot-content);
121
- color: var(--playpilot-text-color-alt);
122
- font-family: inherit;
123
- text-align: left;
124
-
125
- &:hover {
126
- background: var(--playpilot-lighter);
127
- }
128
-
129
- &:last-child {
130
- border-bottom: 0;
131
- }
132
- }
133
-
134
- .poster {
135
- width: margin(1.75);
136
- border-radius: margin(0.25);
137
- height: auto;
138
- background: var(--playpilot-dark);
139
- }
140
-
141
- .name {
142
- color: var(--playpilot-text-color);
143
- }
144
-
145
- .meta {
146
- display: flex;
147
- flex-wrap: wrap;
148
- gap: 0 margin(0.5);
149
- font-size: margin(0.75);
150
- }
151
-
152
- .imdb {
153
- display: flex;
154
- align-items: center;
155
- gap: margin(0.25);
156
- }
157
-
158
88
  .empty {
159
89
  padding: margin(0.5);
160
90
  font-size: margin(0.75);
161
91
  color: var(--playpilot-text-color-alt);
162
92
  }
163
93
 
164
- .open-in-new-tab {
165
- margin-left: auto;
166
- color: var(--playpilot-text-color-alt);
167
-
168
- &:hover {
169
- color: vaR(--playpilot-text-color);
170
- }
94
+ .selected {
95
+ margin: margin(0.5) 0;
96
+ border-radius: 0.5rem;
97
+ border: 2px solid var(--playpilot-green);
171
98
  }
172
99
  </style>
@@ -0,0 +1,107 @@
1
+ <script lang="ts">
2
+ import { playPilotBaseUrl } from '$lib/constants'
3
+ import type { TitleData } from '$lib/types/title'
4
+ import IconIMDb from '../../Icons/IconIMDb.svelte'
5
+ import IconNewTab from '../../Icons/IconNewTab.svelte'
6
+
7
+ interface Props {
8
+ title: TitleData
9
+ hoverable?: boolean
10
+ onclick?: () => void
11
+ }
12
+
13
+ const { title, hoverable = true, onclick = () => null }: Props = $props()
14
+ </script>
15
+
16
+ <button class="item" class:hoverable {onclick}>
17
+ <img class="poster" src={title.standing_poster} alt="" width="28" height="42" />
18
+
19
+ <div class="content">
20
+ <div class="name">{title.title}</div>
21
+
22
+ <div class="meta">
23
+ <div class="imdb">
24
+ <IconIMDb />
25
+ {title.imdb_score}
26
+ </div>
27
+
28
+ <div>{title.year}</div>
29
+ <div>{title.type}</div>
30
+
31
+ {#if title.length}
32
+ <div>{title.length} min</div>
33
+ {/if}
34
+ </div>
35
+ </div>
36
+
37
+ <a
38
+ href="{playPilotBaseUrl}/{title.type}/{title.slug}"
39
+ target="_blank"
40
+ class="open-in-new-tab"
41
+ onclick={event => event.stopImmediatePropagation()}>
42
+ <IconNewTab />
43
+ </a>
44
+ </button>
45
+
46
+ <style lang="scss">
47
+ .item {
48
+ appearance: none;
49
+ display: flex;
50
+ align-items: flex-start;
51
+ gap: margin(1);
52
+ width: 100%;
53
+ background: transparent;
54
+ border: 0;
55
+ padding: margin(0.5);
56
+ border-bottom: 1px solid var(--playpilot-content);
57
+ color: var(--playpilot-text-color-alt);
58
+ font-family: inherit;
59
+ text-align: left;
60
+ font-size: 0.85rem;
61
+
62
+ &.hoverable {
63
+ cursor: pointer;
64
+
65
+ &:hover {
66
+ background: var(--playpilot-lighter);
67
+ }
68
+ }
69
+
70
+ &:last-child {
71
+ border-bottom: 0;
72
+ }
73
+ }
74
+
75
+ .poster {
76
+ width: margin(1.75);
77
+ border-radius: margin(0.25);
78
+ height: auto;
79
+ background: var(--playpilot-dark);
80
+ }
81
+
82
+ .name {
83
+ color: var(--playpilot-text-color);
84
+ }
85
+
86
+ .meta {
87
+ display: flex;
88
+ flex-wrap: wrap;
89
+ gap: 0 margin(0.5);
90
+ font-size: margin(0.75);
91
+ }
92
+
93
+ .imdb {
94
+ display: flex;
95
+ align-items: center;
96
+ gap: margin(0.25);
97
+ }
98
+
99
+ .open-in-new-tab {
100
+ margin-left: auto;
101
+ color: var(--playpilot-text-color-alt);
102
+
103
+ &:hover {
104
+ color: vaR(--playpilot-text-color);
105
+ }
106
+ }
107
+ </style>
@@ -15,7 +15,9 @@
15
15
 
16
16
  const { title, small = false, compact = false }: Props = $props()
17
17
 
18
- let imageLoaded = $state(false)
18
+ let posterLoaded = $state(false)
19
+ let backgroundLoaded = $state(false)
20
+ let useBackgroundFallback = $state(false)
19
21
  </script>
20
22
 
21
23
  <div class="content" class:small class:compact data-playpilot-link-injections-title data-playpilot-original-title={title.original_title}>
@@ -24,10 +26,10 @@
24
26
  <div class="top">
25
27
  <img
26
28
  class="poster"
27
- class:loaded={imageLoaded}
29
+ class:loaded={posterLoaded}
28
30
  src={title.standing_poster}
29
31
  alt="Movie poster for '{title.title}'"
30
- onload={() => imageLoaded = true} />
32
+ onload={() => posterLoaded = true} />
31
33
  </div>
32
34
  {/if}
33
35
 
@@ -65,7 +67,15 @@
65
67
  </div>
66
68
 
67
69
  <div class="background" class:faded={compact}>
68
- <img src={title.medium_poster} alt="" />
70
+ {#key useBackgroundFallback}
71
+ <img
72
+ src={useBackgroundFallback ? title.standing_poster : title.medium_poster}
73
+ alt=""
74
+ class:loaded={backgroundLoaded}
75
+ class:blur={useBackgroundFallback}
76
+ onload={() => backgroundLoaded = true}
77
+ onerror={() => useBackgroundFallback = true} />
78
+ {/key}
69
79
  </div>
70
80
 
71
81
  <style lang="scss">
@@ -173,6 +183,7 @@
173
183
  height: margin(12);
174
184
  overflow: hidden;
175
185
  background: var(--playpilot-detail-background, var(--playpilot-lighter));
186
+ z-index: 0;
176
187
 
177
188
  &::before {
178
189
  content: "";
@@ -183,6 +194,7 @@
183
194
  bottom: 0;
184
195
  left: 0;
185
196
  background: linear-gradient(to top, var(--playpilot-detail-background, var(--playpilot-light)), transparent 40%);
197
+ z-index: 1;
186
198
  }
187
199
 
188
200
  img {
@@ -191,6 +203,18 @@
191
203
  object-fit: cover;
192
204
  object-position: center;
193
205
  margin: 0;
206
+ opacity: 0;
207
+ z-index: 0;
208
+
209
+ &.loaded {
210
+ opacity: 1;
211
+ }
212
+
213
+ &.blur {
214
+ opacity: 0.5;
215
+ transform: scale(1.1);
216
+ filter: blur(0.75rem);
217
+ }
194
218
  }
195
219
  }
196
220
 
@@ -14,6 +14,7 @@ export function fakeFetch({ response = '', status = 200, ok = true } = {}) {
14
14
  ok,
15
15
  status,
16
16
  json: () => response,
17
+ text: () => response,
17
18
  }),
18
19
  )
19
20
  }
@@ -21,7 +22,7 @@ export function fakeFetch({ response = '', status = 200, ok = true } = {}) {
21
22
  /**
22
23
  * @param {string} sentence
23
24
  * @param {string} title
24
- * @returns {LinkInjection}
25
+ * @returns {import('$lib/types/injection').LinkInjection}
25
26
  */
26
27
  export function generateInjection(sentence, title) {
27
28
  return {
@@ -1,5 +1,5 @@
1
1
  import { describe, it, expect, vi, afterEach, beforeEach } from 'vitest'
2
- import { fetchLinkInjections, pollLinkInjections } from '$lib/api'
2
+ import { fetchConfig, fetchLinkInjections, getApiToken, pollLinkInjections } from '$lib/api'
3
3
  import { fakeFetch } from '../helpers'
4
4
  import { authorize, isEditorialModeEnabled } from '$lib/auth'
5
5
  import { Language } from '$lib/enums/Language'
@@ -180,4 +180,33 @@ describe('$lib/api', () => {
180
180
  expect(result).toEqual(expect.objectContaining({ link_injections: [], ai_injections: [] }))
181
181
  })
182
182
  })
183
+
184
+ describe('fetchConfig', () => {
185
+ it('Should call fetch to expected endpoint with api token', async () => {
186
+ fakeFetch({ response: { some_key: 'some-value' } })
187
+
188
+ const result = await fetchConfig()
189
+
190
+ expect(result).toEqual({ some_key: 'some-value' })
191
+ expect(global.fetch).toHaveBeenCalledWith(
192
+ expect.stringContaining(`/domains/config?api-token=${getApiToken()}`),
193
+ expect.any(Object),
194
+ )
195
+ })
196
+
197
+ it('Should return error if no api token was provided', async () => {
198
+ // @ts-ignore
199
+ window.PlayPilotLinkInjections = { token: '' }
200
+
201
+ await expect(async () => await fetchConfig()).rejects.toThrowError()
202
+ })
203
+
204
+ it('Should return null if fetch returned nothing', async () => {
205
+ fakeFetch({ response: '' })
206
+
207
+ const result = await fetchConfig()
208
+
209
+ expect(result).toEqual(null)
210
+ })
211
+ })
183
212
  })