@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.
- package/dist/link-injections.js +7 -7
- package/package.json +1 -1
- package/src/lib/api.ts +21 -1
- package/src/lib/enums/TrackingEvent.ts +2 -0
- package/src/lib/linkInjection.ts +8 -8
- package/src/lib/text.ts +24 -0
- package/src/lib/types/config.d.ts +3 -0
- package/src/routes/+page.svelte +18 -3
- package/src/routes/components/Editorial/AIIndicator.svelte +43 -2
- package/src/routes/components/Editorial/DragHandle.svelte +3 -3
- package/src/routes/components/Editorial/Editor.svelte +58 -28
- package/src/routes/components/Editorial/EditorItem.svelte +52 -24
- package/src/routes/components/Editorial/ManualInjection.svelte +69 -16
- package/src/routes/components/Editorial/ResizeHandle.svelte +111 -0
- package/src/routes/components/Editorial/Search/TitleSearch.svelte +17 -90
- package/src/routes/components/Editorial/Search/TitleSearchItem.svelte +107 -0
- package/src/routes/components/Title.svelte +28 -4
- package/src/tests/helpers.js +2 -1
- package/src/tests/lib/api.test.js +30 -1
- package/src/tests/lib/linkInjection.test.js +39 -11
- package/src/tests/lib/text.test.js +23 -1
- package/src/tests/routes/+page.test.js +52 -5
- package/src/tests/routes/components/Editorial/AiIndicator.test.js +5 -2
- package/src/tests/routes/components/Editorial/DragHandle.test.js +5 -5
- package/src/tests/routes/components/Editorial/Editor.test.js +69 -0
- package/src/tests/routes/components/Editorial/EditorItem.test.js +34 -10
- package/src/tests/routes/components/Editorial/ManualInjection.test.js +5 -1
- package/src/tests/routes/components/Editorial/ResizeHandle.test.js +45 -0
- package/src/tests/routes/components/Editorial/Search/TitleSearch.test.js +6 -4
- 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
|
-
|
|
69
|
-
node =
|
|
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
|
|
79
|
-
const
|
|
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
|
|
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
|
-
|
|
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
|
-
|
|
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-
|
|
232
|
+
background: var(--playpilot-green);
|
|
181
233
|
transition: opacity 100ms;
|
|
182
234
|
font-family: inherit;
|
|
183
|
-
color: var(--playpilot-text-color
|
|
235
|
+
color: var(--playpilot-text-color);
|
|
184
236
|
|
|
185
237
|
&:not([disabled]):hover {
|
|
186
|
-
|
|
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
|
-
<
|
|
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
|
-
.
|
|
165
|
-
margin
|
|
166
|
-
|
|
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
|
|
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={
|
|
29
|
+
class:loaded={posterLoaded}
|
|
28
30
|
src={title.standing_poster}
|
|
29
31
|
alt="Movie poster for '{title.title}'"
|
|
30
|
-
onload={() =>
|
|
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
|
-
|
|
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
|
|
package/src/tests/helpers.js
CHANGED
|
@@ -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
|
})
|