@playpilot/tpi 1.0.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.
- package/.github/workflows/tests.yml +22 -0
- package/.prettierignore +4 -0
- package/.prettierrc +16 -0
- package/README.md +38 -0
- package/dist/link-injections.js +7 -0
- package/eslint.config.js +33 -0
- package/index.html +11 -0
- package/jsconfig.json +19 -0
- package/package.json +35 -0
- package/src/app.d.ts +13 -0
- package/src/app.html +12 -0
- package/src/demo.spec.js +7 -0
- package/src/lib/api.js +160 -0
- package/src/lib/array.js +15 -0
- package/src/lib/auth.js +84 -0
- package/src/lib/constants.js +2 -0
- package/src/lib/enums/TrackingEvent.js +15 -0
- package/src/lib/fakeData.js +140 -0
- package/src/lib/genres.json +420 -0
- package/src/lib/global.css +37 -0
- package/src/lib/hash.js +15 -0
- package/src/lib/html.js +21 -0
- package/src/lib/index.js +1 -0
- package/src/lib/linkInjection.js +275 -0
- package/src/lib/search.js +24 -0
- package/src/lib/text.js +61 -0
- package/src/lib/tracking.js +32 -0
- package/src/lib/variables.css +16 -0
- package/src/main.js +45 -0
- package/src/routes/+layout.svelte +54 -0
- package/src/routes/+page.svelte +96 -0
- package/src/routes/components/AfterArticlePlaylinks.svelte +90 -0
- package/src/routes/components/ContextMenu.svelte +67 -0
- package/src/routes/components/Description.svelte +47 -0
- package/src/routes/components/Editorial/Alert.svelte +18 -0
- package/src/routes/components/Editorial/DragHandle.svelte +134 -0
- package/src/routes/components/Editorial/Editor.svelte +277 -0
- package/src/routes/components/Editorial/EditorItem.svelte +260 -0
- package/src/routes/components/Editorial/ManualInjection.svelte +192 -0
- package/src/routes/components/Editorial/PlaylinkTypeSelect.svelte +132 -0
- package/src/routes/components/Editorial/Search/TitleSearch.svelte +176 -0
- package/src/routes/components/Editorial/Switch.svelte +76 -0
- package/src/routes/components/Editorial/TextInput.svelte +29 -0
- package/src/routes/components/Genres.svelte +41 -0
- package/src/routes/components/Icons/IconAlign.svelte +12 -0
- package/src/routes/components/Icons/IconBack.svelte +3 -0
- package/src/routes/components/Icons/IconBookmark.svelte +3 -0
- package/src/routes/components/Icons/IconChevron.svelte +18 -0
- package/src/routes/components/Icons/IconClose.svelte +3 -0
- package/src/routes/components/Icons/IconContinue.svelte +3 -0
- package/src/routes/components/Icons/IconDots.svelte +5 -0
- package/src/routes/components/Icons/IconEnlarge.svelte +12 -0
- package/src/routes/components/Icons/IconIMDb.svelte +3 -0
- package/src/routes/components/Icons/IconNewTab.svelte +3 -0
- package/src/routes/components/Modal.svelte +106 -0
- package/src/routes/components/Participants.svelte +44 -0
- package/src/routes/components/Playlinks.svelte +155 -0
- package/src/routes/components/Popover.svelte +95 -0
- package/src/routes/components/RoundButton.svelte +38 -0
- package/src/routes/components/SkeletonText.svelte +33 -0
- package/src/routes/components/Title.svelte +180 -0
- package/src/routes/components/TitleModal.svelte +24 -0
- package/src/routes/components/TitlePopover.svelte +17 -0
- package/src/tests/helpers.js +18 -0
- package/src/tests/lib/api.test.js +162 -0
- package/src/tests/lib/array.test.js +14 -0
- package/src/tests/lib/auth.test.js +115 -0
- package/src/tests/lib/hash.test.js +28 -0
- package/src/tests/lib/html.test.js +16 -0
- package/src/tests/lib/linkInjection.test.js +754 -0
- package/src/tests/lib/search.test.js +42 -0
- package/src/tests/lib/text.test.js +94 -0
- package/src/tests/lib/tracking.test.js +71 -0
- package/src/tests/routes/+page.test.js +109 -0
- package/src/tests/routes/components/AfterArticlePlaylinks.test.js +115 -0
- package/src/tests/routes/components/ContextMenu.test.js +37 -0
- package/src/tests/routes/components/Description.test.js +58 -0
- package/src/tests/routes/components/Editorial/Alert.test.js +17 -0
- package/src/tests/routes/components/Editorial/DragHandle.test.js +55 -0
- package/src/tests/routes/components/Editorial/Editor.test.js +64 -0
- package/src/tests/routes/components/Editorial/EditorItem.test.js +142 -0
- package/src/tests/routes/components/Editorial/ManualInjection.test.js +114 -0
- package/src/tests/routes/components/Editorial/PlaylinkTypeSelect.test.js +63 -0
- package/src/tests/routes/components/Editorial/Search/TitleSearch.test.js +58 -0
- package/src/tests/routes/components/Editorial/Switch.test.js +60 -0
- package/src/tests/routes/components/Editorial/TextInput.test.js +30 -0
- package/src/tests/routes/components/Genres.test.js +37 -0
- package/src/tests/routes/components/Modal.test.js +84 -0
- package/src/tests/routes/components/Participants.test.js +33 -0
- package/src/tests/routes/components/Playlinks.test.js +101 -0
- package/src/tests/routes/components/Popover.test.js +66 -0
- package/src/tests/routes/components/RoundButton.test.js +35 -0
- package/src/tests/routes/components/SkeletonText.test.js +12 -0
- package/src/tests/routes/components/Title.test.js +82 -0
- package/src/tests/routes/components/TitleModal.test.js +33 -0
- package/src/tests/routes/components/TitlePopover.test.js +23 -0
- package/src/tests/setup.js +53 -0
- package/src/typedefs.js +72 -0
- package/static/favicon.png +0 -0
- package/svelte.config.js +13 -0
- package/vite.config.js +61 -0
|
@@ -0,0 +1,37 @@
|
|
|
1
|
+
[data-playpilot-injection-key] {
|
|
2
|
+
position: relative;
|
|
3
|
+
}
|
|
4
|
+
|
|
5
|
+
[data-playpilot-injection-key].injection-highlight {
|
|
6
|
+
outline: 0.25rem solid var(--playpilot-primary) !important;
|
|
7
|
+
outline-offset: 0.5rem !important;
|
|
8
|
+
border-radius: 0.05rem;
|
|
9
|
+
scroll-margin: 5rem;
|
|
10
|
+
}
|
|
11
|
+
|
|
12
|
+
.playpilot-styled-scrollbar {
|
|
13
|
+
scrollbar-color: var(--playpilot-content-light) var(--playpilot-lighter);
|
|
14
|
+
scrollbar-width: thin;
|
|
15
|
+
}
|
|
16
|
+
|
|
17
|
+
.playpilot-styled-scrollbed::-webkit-scrollbar {
|
|
18
|
+
width: 0.75rem;
|
|
19
|
+
}
|
|
20
|
+
|
|
21
|
+
.playpilot-styled-scrollbed::-webkit-scrollbar-track {
|
|
22
|
+
background: var(--playpilot-light);
|
|
23
|
+
}
|
|
24
|
+
|
|
25
|
+
.playpilot-styled-scrollbed::-webkit-scrollbar-thumb {
|
|
26
|
+
border: 2px solid var(--playpilot-light);
|
|
27
|
+
border-radius: 1rem;
|
|
28
|
+
background: var(--playpilot-lighter)
|
|
29
|
+
}
|
|
30
|
+
|
|
31
|
+
.playpilot-styled-scrollbed::-webkit-scrollbar-thumb:hover {
|
|
32
|
+
background: var(--playpilot-content-light);
|
|
33
|
+
}
|
|
34
|
+
|
|
35
|
+
.playpilot-styled-scrollbed::-webkit-scrollbar-thumb:active {
|
|
36
|
+
background: var(--playpilot-text-color-alt);
|
|
37
|
+
}
|
package/src/lib/hash.js
ADDED
|
@@ -0,0 +1,15 @@
|
|
|
1
|
+
/**
|
|
2
|
+
* Turns any string into a very short hash. This is super basic and not super reliable, but it's good enough for our purpose.
|
|
3
|
+
* @param {string} string
|
|
4
|
+
* @returns {string}
|
|
5
|
+
*/
|
|
6
|
+
export function stringToHash(string) {
|
|
7
|
+
let hash = 0
|
|
8
|
+
|
|
9
|
+
for (let i = 0; i < string.length; i++) {
|
|
10
|
+
const char = string.charCodeAt(i)
|
|
11
|
+
hash = (hash * 31 + char) >>> 0
|
|
12
|
+
}
|
|
13
|
+
|
|
14
|
+
return hash.toString(16)
|
|
15
|
+
}
|
package/src/lib/html.js
ADDED
|
@@ -0,0 +1,21 @@
|
|
|
1
|
+
/**
|
|
2
|
+
* Returns a string with decoded html characters. For instance & -> &
|
|
3
|
+
* @param {string} string
|
|
4
|
+
*/
|
|
5
|
+
export function encodeHtmlEntities(string) {
|
|
6
|
+
const tempElement = document.createElement('div')
|
|
7
|
+
tempElement.textContent = string
|
|
8
|
+
|
|
9
|
+
return tempElement.innerHTML
|
|
10
|
+
}
|
|
11
|
+
|
|
12
|
+
/**
|
|
13
|
+
* Returns a string with encoded html characters. For instance & -> &
|
|
14
|
+
* @param {string} string
|
|
15
|
+
*/
|
|
16
|
+
export function decodeHtmlEntities(string) {
|
|
17
|
+
const tempElement = document.createElement('div')
|
|
18
|
+
tempElement.innerHTML = string
|
|
19
|
+
|
|
20
|
+
return tempElement.textContent || ''
|
|
21
|
+
}
|
package/src/lib/index.js
ADDED
|
@@ -0,0 +1 @@
|
|
|
1
|
+
// place files you want to import through the `$lib` alias in this folder.
|
|
@@ -0,0 +1,275 @@
|
|
|
1
|
+
import { mount, unmount } from 'svelte'
|
|
2
|
+
import TitlePopover from '../routes/components/TitlePopover.svelte'
|
|
3
|
+
import AfterArticlePlaylinks from '../routes/components/AfterArticlePlaylinks.svelte'
|
|
4
|
+
import { findTextNodeContaining, isNodeInLink, replaceStartingFrom } from './text'
|
|
5
|
+
import { getLargestValueInArray } from './array'
|
|
6
|
+
|
|
7
|
+
/** @type {Record<string, { injection: LinkInjection, component: object }>} */
|
|
8
|
+
const activePopovers = {}
|
|
9
|
+
/** @type {object | null} */
|
|
10
|
+
let afterArticlePlaylinkInsertedComponent = null
|
|
11
|
+
|
|
12
|
+
/**
|
|
13
|
+
* Return a list of all valid text containing elements that may get injected into.
|
|
14
|
+
* This excludes duplicates, empty elements, links, buttons, and header tags.
|
|
15
|
+
* @param {HTMLElement} parentElement
|
|
16
|
+
* @returns {HTMLElement[]} A list of all HTMLElements that contain text, without repeating the same text in
|
|
17
|
+
*/
|
|
18
|
+
export function getLinkInjectionElements(parentElement) {
|
|
19
|
+
let validElements = []
|
|
20
|
+
|
|
21
|
+
let remainingChildren = [...parentElement.children]
|
|
22
|
+
|
|
23
|
+
while (remainingChildren.length > 0) {
|
|
24
|
+
const element = /** @type {HTMLElement} */ (remainingChildren.shift())
|
|
25
|
+
|
|
26
|
+
// Ignore links, buttons, and headers
|
|
27
|
+
if (/^(A|BUTTON|SCRIPT|NOSCRIPT|STYLE|IFRAME|H[1-6])$/.test(element.tagName)) continue
|
|
28
|
+
|
|
29
|
+
// Check if this element has a direct text node
|
|
30
|
+
const hasTextNode = Array.from(element.childNodes).some(
|
|
31
|
+
node => node.nodeType === Node.TEXT_NODE && node.nodeValue?.trim() !== '',
|
|
32
|
+
)
|
|
33
|
+
|
|
34
|
+
// If this element has a text node we add it to the valid elements and stop there
|
|
35
|
+
// Otherwise we add all children to be checked in this same loop.
|
|
36
|
+
if (hasTextNode) validElements.push(element)
|
|
37
|
+
else remainingChildren.push(...element.children)
|
|
38
|
+
}
|
|
39
|
+
|
|
40
|
+
return validElements
|
|
41
|
+
}
|
|
42
|
+
|
|
43
|
+
/**
|
|
44
|
+
* Get the parent selector that will be used to find the link injections in.
|
|
45
|
+
* This selector is passed when the script is initialized.
|
|
46
|
+
* If no selector is passed a default is returned instead.
|
|
47
|
+
* @returns {HTMLElement}
|
|
48
|
+
*/
|
|
49
|
+
export function getLinkInjectionsParentElement() {
|
|
50
|
+
// @ts-ignore
|
|
51
|
+
const selector = window.PlayPilotLinkInjections?.selector
|
|
52
|
+
|
|
53
|
+
if (selector) {
|
|
54
|
+
// Replace : with \\: because js selectors need some characters escaped. We only care about : at the moment.
|
|
55
|
+
const escaped = selector.replace(/(:)/g, '\\$1')
|
|
56
|
+
const element = document.querySelector(escaped)
|
|
57
|
+
|
|
58
|
+
if (element) return element
|
|
59
|
+
}
|
|
60
|
+
|
|
61
|
+
return document.querySelector('article') || document.querySelector('main') || document.body
|
|
62
|
+
}
|
|
63
|
+
|
|
64
|
+
/**
|
|
65
|
+
* Replace all found injections within all given elements on the page
|
|
66
|
+
* @param {HTMLElement[]} elements
|
|
67
|
+
* @param {LinkInjection[]} injections
|
|
68
|
+
* @param {(LinkInjection: LinkInjection) => void} onclick
|
|
69
|
+
* @returns {LinkInjection[]} Returns an array of injections with injections that failed to be inserted marked as `failed`.
|
|
70
|
+
*/
|
|
71
|
+
export function injectLinksInDocument(elements, injections, onclick) {
|
|
72
|
+
// Find injection in text content of all elements together, ignore potential HTML elements.
|
|
73
|
+
// This is to filter out injections that can't be injected anyway.
|
|
74
|
+
const fullText = elements.map(element => element.innerText).join(' ')
|
|
75
|
+
|
|
76
|
+
// Filter out injections meant to be displayed only after the article, rather than in-text.
|
|
77
|
+
// Also filter out injections that are marked as inactive
|
|
78
|
+
const validInjections = injections.filter(i => i.in_text !== false && !i.inactive)
|
|
79
|
+
const foundInjections = validInjections.filter(i => fullText.includes(i.sentence))
|
|
80
|
+
|
|
81
|
+
/** @type {LinkInjectionRanges} */
|
|
82
|
+
const ranges = {}
|
|
83
|
+
|
|
84
|
+
for (const injection of foundInjections) {
|
|
85
|
+
const elementIndex = elements.findIndex(element => element.innerText.includes(injection.sentence))
|
|
86
|
+
const element = elements[elementIndex]
|
|
87
|
+
|
|
88
|
+
if (!element) continue
|
|
89
|
+
|
|
90
|
+
const nodeContainingText = findTextNodeContaining(injection.title, element, ['A'])
|
|
91
|
+
// Ignore if the found injection has no node or if it is inside a link.
|
|
92
|
+
if (!nodeContainingText || isNodeInLink(nodeContainingText)) continue
|
|
93
|
+
|
|
94
|
+
const linkElement = document.createElement('a')
|
|
95
|
+
linkElement.innerText = injection.title
|
|
96
|
+
linkElement.href = injection.playpilot_url
|
|
97
|
+
linkElement.dataset.playpilotInjectionKey = injection.key
|
|
98
|
+
linkElement.dataset.playpilotOriginalTitle = injection.title_details?.original_title
|
|
99
|
+
linkElement.target = '_blank'
|
|
100
|
+
linkElement.rel = 'noopener nofollow noreferrer'
|
|
101
|
+
|
|
102
|
+
// Replace starting from the end of the previously injected link, making sure injections with the same or overlapping
|
|
103
|
+
// phrases can still be injected.
|
|
104
|
+
const startingIndex = getLargestValueInArray(Object.values(ranges).filter(r => r.elementIndex === elementIndex).map(r => r.to))
|
|
105
|
+
element.innerHTML = replaceStartingFrom(element.innerHTML, injection.title, linkElement.outerHTML, startingIndex)
|
|
106
|
+
|
|
107
|
+
const from = element.innerHTML.indexOf(linkElement.outerHTML)
|
|
108
|
+
|
|
109
|
+
ranges[injection.key] = {
|
|
110
|
+
elementIndex,
|
|
111
|
+
from,
|
|
112
|
+
to: from + linkElement.outerHTML.length,
|
|
113
|
+
}
|
|
114
|
+
}
|
|
115
|
+
|
|
116
|
+
addLinkInjectionEventListeners(validInjections, onclick)
|
|
117
|
+
|
|
118
|
+
const afterArticleInjections = injections.filter(i => i.after_article && !i.inactive)
|
|
119
|
+
if (afterArticleInjections.length) insertAfterArticlePlaylinks(elements, afterArticleInjections, onclick)
|
|
120
|
+
|
|
121
|
+
const sortedInjections = sortLinkInjectionsByRange(injections, ranges)
|
|
122
|
+
|
|
123
|
+
return sortedInjections.map(injection => {
|
|
124
|
+
const failed = !injection.inactive && !injection.after_article && !document.querySelector(`[data-playpilot-injection-key="${injection.key}"]`)
|
|
125
|
+
|
|
126
|
+
return {
|
|
127
|
+
...injection,
|
|
128
|
+
inactive: !!injection.inactive,
|
|
129
|
+
failed,
|
|
130
|
+
}
|
|
131
|
+
})
|
|
132
|
+
}
|
|
133
|
+
|
|
134
|
+
/**
|
|
135
|
+
* Add event listeners to all injected links. These events are for both the popover and the modal.
|
|
136
|
+
* @param {LinkInjection[]} injections
|
|
137
|
+
* @param {function} onclick
|
|
138
|
+
* @returns {void}
|
|
139
|
+
*/
|
|
140
|
+
function addLinkInjectionEventListeners(injections, onclick) {
|
|
141
|
+
const createdLinkElements = document.querySelectorAll('[data-playpilot-injection-key]')
|
|
142
|
+
|
|
143
|
+
createdLinkElements.forEach((linkElement) => {
|
|
144
|
+
// @ts-ignore
|
|
145
|
+
const key = linkElement.dataset.playpilotInjectionKey
|
|
146
|
+
const injection = injections.find(injection => key === injection.key)
|
|
147
|
+
|
|
148
|
+
if (!injection) return
|
|
149
|
+
|
|
150
|
+
// @ts-ignore
|
|
151
|
+
linkElement.addEventListener('click', (event) => openLinkModal(event, injection, onclick))
|
|
152
|
+
// @ts-ignore
|
|
153
|
+
linkElement.addEventListener('mouseenter', (event) => openLinkPopover(event, injection))
|
|
154
|
+
linkElement.addEventListener('mouseleave', () => destroyLinkPopover(injection))
|
|
155
|
+
})
|
|
156
|
+
}
|
|
157
|
+
|
|
158
|
+
/**
|
|
159
|
+
* Prevent default click and run onclick from parent. Ignore clicks that used modifier keys or that were not left click.
|
|
160
|
+
* The event is not fired when the click happens from inside a popover.
|
|
161
|
+
* @param {MouseEvent} event
|
|
162
|
+
* @param {LinkInjection} injection
|
|
163
|
+
* @param {function} onclick
|
|
164
|
+
* @returns {void}
|
|
165
|
+
*/
|
|
166
|
+
function openLinkModal(event, injection, onclick) {
|
|
167
|
+
if (event.ctrlKey || event.metaKey || event.button !== 0) return
|
|
168
|
+
|
|
169
|
+
event.preventDefault()
|
|
170
|
+
|
|
171
|
+
// Don't open modal if click happened inside of popover.
|
|
172
|
+
const target = /** @type {Element} */ (event.target)
|
|
173
|
+
if (target.closest('.popover')) return
|
|
174
|
+
|
|
175
|
+
onclick(injection)
|
|
176
|
+
destroyLinkPopover(injection)
|
|
177
|
+
}
|
|
178
|
+
|
|
179
|
+
/**
|
|
180
|
+
* When a link is hovered, it is shown as a popover. The component is mounted when a mouse enters the link,
|
|
181
|
+
* and removed when clicked or on mouseleave.
|
|
182
|
+
* @param {MouseEvent} event
|
|
183
|
+
* @param {LinkInjection} injection
|
|
184
|
+
*/
|
|
185
|
+
function openLinkPopover(event, injection) {
|
|
186
|
+
// Skip touch devices
|
|
187
|
+
if (window.matchMedia('(pointer: coarse)').matches) return
|
|
188
|
+
|
|
189
|
+
const target = /** @type {Element} */ (event.currentTarget)
|
|
190
|
+
const popover = mount(TitlePopover, { target, props: { title: injection.title_details } })
|
|
191
|
+
|
|
192
|
+
activePopovers[injection.key] = { injection, component: popover }
|
|
193
|
+
}
|
|
194
|
+
|
|
195
|
+
/**
|
|
196
|
+
* Unmount the popover, removing it from the dom
|
|
197
|
+
* @param {LinkInjection} injection
|
|
198
|
+
* @param {boolean} outro
|
|
199
|
+
*/
|
|
200
|
+
function destroyLinkPopover(injection, outro = true) {
|
|
201
|
+
const popover = activePopovers[injection.key]
|
|
202
|
+
|
|
203
|
+
if (popover) unmount(popover.component, { outro })
|
|
204
|
+
}
|
|
205
|
+
|
|
206
|
+
/**
|
|
207
|
+
* Insert AfterArticlePlaylinks after the last valid element.
|
|
208
|
+
* @param {HTMLElement[]} elements
|
|
209
|
+
* @param {LinkInjection[]} injections
|
|
210
|
+
* @param {(linkInjection: LinkInjection) => void} onclickmodal
|
|
211
|
+
*/
|
|
212
|
+
export function insertAfterArticlePlaylinks(elements, injections, onclickmodal) {
|
|
213
|
+
if (!injections.length) return
|
|
214
|
+
|
|
215
|
+
const target = document.createElement('div')
|
|
216
|
+
target.dataset.playpilotAfterArticlePlaylinks = 'true'
|
|
217
|
+
elements[elements.length - 1].insertAdjacentElement('afterend', target)
|
|
218
|
+
|
|
219
|
+
afterArticlePlaylinkInsertedComponent = mount(AfterArticlePlaylinks, { target, props: { linkInjections: injections, onclickmodal } })
|
|
220
|
+
}
|
|
221
|
+
|
|
222
|
+
function clearAfterArticlePlaylinks() {
|
|
223
|
+
if (!afterArticlePlaylinkInsertedComponent) return
|
|
224
|
+
|
|
225
|
+
unmount(afterArticlePlaylinkInsertedComponent)
|
|
226
|
+
document.querySelector('[data-playpilot-after-article-playlinks')?.remove()
|
|
227
|
+
}
|
|
228
|
+
|
|
229
|
+
/**
|
|
230
|
+
* Clear link injections from the page
|
|
231
|
+
*/
|
|
232
|
+
export function clearLinkInjections() {
|
|
233
|
+
Object.values(activePopovers).forEach(popover => destroyLinkPopover(popover.injection))
|
|
234
|
+
|
|
235
|
+
const elements = document.querySelectorAll('[data-playpilot-injection-key]')
|
|
236
|
+
elements.forEach((element /** @type {HTMLAnchorElement} */) => element.outerHTML = element.textContent || '')
|
|
237
|
+
|
|
238
|
+
Object.values(activePopovers).forEach(({ injection }) => destroyLinkPopover(injection, false))
|
|
239
|
+
|
|
240
|
+
clearAfterArticlePlaylinks()
|
|
241
|
+
}
|
|
242
|
+
|
|
243
|
+
/**
|
|
244
|
+
* Clear specific link injection from the page
|
|
245
|
+
* @param {string} key Given of the injection to be removed from the page
|
|
246
|
+
*/
|
|
247
|
+
export function clearLinkInjection(key) {
|
|
248
|
+
/** @type {HTMLAnchorElement | null} */
|
|
249
|
+
const element = document.querySelector(`[data-playpilot-injection-key="${key}"]`)
|
|
250
|
+
if (element) element.outerHTML = element.textContent || ''
|
|
251
|
+
}
|
|
252
|
+
|
|
253
|
+
/**
|
|
254
|
+
* Sort injections by where they were inserted. First by their element index, second by where in the element the
|
|
255
|
+
* injection was injected. Injections without range (after article injections or failed injection) go last.
|
|
256
|
+
* @param {LinkInjection[]} injections
|
|
257
|
+
* @param {LinkInjectionRanges} ranges
|
|
258
|
+
* @returns {LinkInjection[]}
|
|
259
|
+
*/
|
|
260
|
+
export function sortLinkInjectionsByRange(injections, ranges) {
|
|
261
|
+
return injections.sort((a, b) => {
|
|
262
|
+
const rangeA = ranges[a.key]
|
|
263
|
+
const rangeB = ranges[b.key]
|
|
264
|
+
|
|
265
|
+
if (!rangeA && rangeB) return 1
|
|
266
|
+
if (rangeA && !rangeB) return -1
|
|
267
|
+
if (!rangeA && !rangeB) return 0
|
|
268
|
+
|
|
269
|
+
if (rangeA?.elementIndex !== rangeB?.elementIndex) {
|
|
270
|
+
return (rangeA?.elementIndex || 0) - (rangeB?.elementIndex || 0)
|
|
271
|
+
}
|
|
272
|
+
|
|
273
|
+
return (rangeA?.from || 0) - (rangeB?.from || 0)
|
|
274
|
+
})
|
|
275
|
+
}
|
|
@@ -0,0 +1,24 @@
|
|
|
1
|
+
import { getApiToken } from './api'
|
|
2
|
+
import { apiBaseUrl } from './constants'
|
|
3
|
+
|
|
4
|
+
/**
|
|
5
|
+
* Search for movies & shows. Requires valid API token.
|
|
6
|
+
* @param {*} query
|
|
7
|
+
* @returns {Promise<TitleData[]>}
|
|
8
|
+
*/
|
|
9
|
+
export async function searchTitles(query) {
|
|
10
|
+
const headers = new Headers({ 'Content-Type': 'application/json' })
|
|
11
|
+
const apiToken = getApiToken()
|
|
12
|
+
|
|
13
|
+
if (!apiToken) throw new Error('No token was provided')
|
|
14
|
+
|
|
15
|
+
const response = await fetch(apiBaseUrl + `/search/titles/?api-token=${apiToken}&query=${query}`, {
|
|
16
|
+
headers,
|
|
17
|
+
})
|
|
18
|
+
|
|
19
|
+
if (!response.ok) throw response
|
|
20
|
+
|
|
21
|
+
const parsed = await response.json()
|
|
22
|
+
|
|
23
|
+
return parsed
|
|
24
|
+
}
|
package/src/lib/text.js
ADDED
|
@@ -0,0 +1,61 @@
|
|
|
1
|
+
import { decodeHtmlEntities, encodeHtmlEntities } from './html'
|
|
2
|
+
|
|
3
|
+
/**
|
|
4
|
+
* Find the node that a particular string is in. For example:
|
|
5
|
+
* <p>Some sentence <strong>with words</strong> in it</p>
|
|
6
|
+
* findTextNodeContaining('words') would return the <strong> node.
|
|
7
|
+
*
|
|
8
|
+
* @param {string} text
|
|
9
|
+
* @param {HTMLElement} element The parent element from which to search for the given text
|
|
10
|
+
* @param {string[]} ignoredParentNodes Filtered out parent nodes
|
|
11
|
+
* @returns {Node | null}
|
|
12
|
+
*/
|
|
13
|
+
export function findTextNodeContaining(text, element, ignoredParentNodes = []) {
|
|
14
|
+
const walker = document.createTreeWalker(element)
|
|
15
|
+
|
|
16
|
+
let node
|
|
17
|
+
while ((node = walker.nextNode())) {
|
|
18
|
+
if (node.nodeValue?.includes(text) && !ignoredParentNodes.includes(node.parentNode?.nodeName || '')) break
|
|
19
|
+
}
|
|
20
|
+
|
|
21
|
+
return node
|
|
22
|
+
}
|
|
23
|
+
|
|
24
|
+
/**
|
|
25
|
+
* Returns whether or not the node itself is a link or is inside of a link tag.
|
|
26
|
+
* @param {Node} node
|
|
27
|
+
* @returns {boolean}
|
|
28
|
+
*/
|
|
29
|
+
export function isNodeInLink(node) {
|
|
30
|
+
const parentNode = /** @type {HTMLElement} */ (node.parentNode)
|
|
31
|
+
|
|
32
|
+
if (!parentNode) return false
|
|
33
|
+
|
|
34
|
+
return parentNode.nodeName === 'A' || !!parentNode.closest('a')
|
|
35
|
+
}
|
|
36
|
+
|
|
37
|
+
/**
|
|
38
|
+
* Replace a string in a bit of text, but only if it's after the given startIndex.
|
|
39
|
+
* This replace method matches html entities against their decoded and encoded variant.
|
|
40
|
+
* For instance & and & are treated as one and the same character.
|
|
41
|
+
* @param {string} text
|
|
42
|
+
* @param {string} search
|
|
43
|
+
* @param {string} replacement
|
|
44
|
+
* @param {number} startIndex
|
|
45
|
+
* @return {string}
|
|
46
|
+
*/
|
|
47
|
+
export function replaceStartingFrom(text, search, replacement, startIndex) {
|
|
48
|
+
const encodedSearch = encodeHtmlEntities(search)
|
|
49
|
+
const decodedSearch = decodeHtmlEntities(search)
|
|
50
|
+
|
|
51
|
+
const before = text.slice(0, startIndex)
|
|
52
|
+
const after = text.slice(startIndex)
|
|
53
|
+
|
|
54
|
+
const pattern = /[-/\\^$*+?.()|[\]{}]/g // Match html symbols such as &
|
|
55
|
+
const searchRegex = new RegExp(`${decodedSearch.replace(pattern, '\\$&')}|${encodedSearch.replace(pattern, '\\$&')}`)
|
|
56
|
+
|
|
57
|
+
const updatedAfter = after.replace(searchRegex, replacement)
|
|
58
|
+
|
|
59
|
+
return before + updatedAfter
|
|
60
|
+
}
|
|
61
|
+
|
|
@@ -0,0 +1,32 @@
|
|
|
1
|
+
const baseUrl = 'https://insights.playpilot.net'
|
|
2
|
+
|
|
3
|
+
/**
|
|
4
|
+
* Track events via PlayPilot insights tracking. Inserts data about the title when a title is passed.
|
|
5
|
+
* Other data can be passed via the `payload` param.
|
|
6
|
+
* @param {string} event Name of the event
|
|
7
|
+
* @param {TitleData | null} [title] Title related to the event
|
|
8
|
+
* @param {Record<string, string>} [payload] Any data that will be included with the event
|
|
9
|
+
*/
|
|
10
|
+
export async function track(event, title = null, payload = {}) {
|
|
11
|
+
const headers = new Headers({ 'Content-Type': 'application/json' })
|
|
12
|
+
|
|
13
|
+
if (title) {
|
|
14
|
+
payload = {
|
|
15
|
+
original_title: title.original_title,
|
|
16
|
+
title_sid: title.sid,
|
|
17
|
+
...payload,
|
|
18
|
+
}
|
|
19
|
+
}
|
|
20
|
+
|
|
21
|
+
payload.url = window.location.href
|
|
22
|
+
|
|
23
|
+
fetch(baseUrl, {
|
|
24
|
+
headers,
|
|
25
|
+
method: 'POST',
|
|
26
|
+
body: JSON.stringify(({
|
|
27
|
+
event,
|
|
28
|
+
payload,
|
|
29
|
+
source: 'ali',
|
|
30
|
+
})),
|
|
31
|
+
})
|
|
32
|
+
}
|
|
@@ -0,0 +1,16 @@
|
|
|
1
|
+
:root {
|
|
2
|
+
--playpilot-primary: #fa548a;
|
|
3
|
+
--playpilot-dark: #101426;
|
|
4
|
+
--playpilot-light: #1b2743;
|
|
5
|
+
--playpilot-lighter: #233257;
|
|
6
|
+
--playpilot-content: #354367;
|
|
7
|
+
--playpilot-content-light: #4b5b82;
|
|
8
|
+
--playpilot-green: #53bca0;
|
|
9
|
+
--playpilot-font-family: "Poppins", sans-serif;
|
|
10
|
+
--playpilot-text-color: #fff;
|
|
11
|
+
--playpilot-text-color-alt: #c8d4de;
|
|
12
|
+
--playpilot-shadow: 0.2rem 0.15rem 0.15rem rgba(0, 0, 0, 0.2);
|
|
13
|
+
--playpilot-shadow-large: 0.15rem 0.15rem 0.2rem rgba(0, 0, 0, 0.05), 0.2rem 0.35rem 0.35rem rgba(0, 0, 0, 0.1), 0.1rem 0.1rem 0.75rem rgba(0, 0, 0, 0.25);
|
|
14
|
+
--playpilot-error: #ea5a5a;
|
|
15
|
+
--playpilot-error-dark: #442533;
|
|
16
|
+
}
|
package/src/main.js
ADDED
|
@@ -0,0 +1,45 @@
|
|
|
1
|
+
// @ts-nocheck
|
|
2
|
+
|
|
3
|
+
import { mount } from 'svelte'
|
|
4
|
+
import App from './routes/+page.svelte'
|
|
5
|
+
import { clearLinkInjections } from '$lib/linkInjection'
|
|
6
|
+
|
|
7
|
+
window.PlayPilotLinkInjections = {
|
|
8
|
+
token: '',
|
|
9
|
+
editorial_token: '',
|
|
10
|
+
selector: '',
|
|
11
|
+
app: null,
|
|
12
|
+
|
|
13
|
+
initialize(config = { token: '', selector: '', editorial_token: '' }) {
|
|
14
|
+
if (!config.token) {
|
|
15
|
+
console.error('An API token is required.')
|
|
16
|
+
return
|
|
17
|
+
}
|
|
18
|
+
|
|
19
|
+
this.token = config.token
|
|
20
|
+
this.editorial_token = config.editorial_token
|
|
21
|
+
this.selector = config.selector
|
|
22
|
+
|
|
23
|
+
if (this.app) this.destroy()
|
|
24
|
+
|
|
25
|
+
const target = document.createElement('div')
|
|
26
|
+
target.id = 'playpilot-link-injection'
|
|
27
|
+
|
|
28
|
+
document.body.insertAdjacentElement('beforeend', target)
|
|
29
|
+
|
|
30
|
+
this.app = mount(App, { target })
|
|
31
|
+
},
|
|
32
|
+
|
|
33
|
+
destroy() {
|
|
34
|
+
if (!this.app) return
|
|
35
|
+
|
|
36
|
+
this.app = null
|
|
37
|
+
|
|
38
|
+
const target = document.getElementById('playpilot-link-injection')
|
|
39
|
+
if (target) target.remove()
|
|
40
|
+
|
|
41
|
+
clearLinkInjections()
|
|
42
|
+
},
|
|
43
|
+
}
|
|
44
|
+
|
|
45
|
+
export default window.PlayPilotLinkInjections
|
|
@@ -0,0 +1,54 @@
|
|
|
1
|
+
<script>
|
|
2
|
+
import { browser } from '$app/environment'
|
|
3
|
+
|
|
4
|
+
/**
|
|
5
|
+
* This layout file is for development purposes only and will not be compiled with the final script.
|
|
6
|
+
*/
|
|
7
|
+
|
|
8
|
+
const { children } = $props()
|
|
9
|
+
|
|
10
|
+
// This is used to remove the scoped classes in Svelte in order to prevent articles
|
|
11
|
+
// from having to be re-generated between different HMR builds.
|
|
12
|
+
// This is purely for development.
|
|
13
|
+
const noClass = (/** @type {HTMLElement} */ node) => {
|
|
14
|
+
node.classList.forEach(e => {if (e.startsWith('s-')) node.classList.remove(e)})
|
|
15
|
+
}
|
|
16
|
+
|
|
17
|
+
// This is normally given through window.PlayPilotLinkInjections.initialize({ token: 'some-token' })
|
|
18
|
+
// @ts-ignore
|
|
19
|
+
if (browser) window.PlayPilotLinkInjections = { token: 'ZoAL14yqzevMyQiwckbvyetOkeIUeEDN', selector: 'article' }
|
|
20
|
+
</script>
|
|
21
|
+
|
|
22
|
+
{#key Math.random()}
|
|
23
|
+
<article use:noClass>
|
|
24
|
+
<p use:noClass>Following the success of John M. Chu's 2018 romantic-comedy Crazy Rich Asians, Quan was inspired to return to acting. He first scored a supporting role in the Netflix movie Finding 'Ohana, before securing a starring role in the absurdist comedy-drama Everything Everywhere all At Once. A critical and commercial success, the film earned $143 million against a budget of $14-25 million, and saw Quan win the Academy Award for Best Supporting Actor. Following his win, Quan struggled to choose projects he was satisfied with, passing on an action-comedy three times, before finally taking his first leading role in it, following advice from Spielberg.</p>
|
|
25
|
+
<p use:noClass>In an interview with Epire & Magazine, Quan reveals he quested starring in Love Hurts, which sees him in the leading role of a former assassin turned successful realtor, whose past returns when his brother attempts to hunt him down. The movie is in a similar vein to successful films such as The Long Kiss Goodnight and Nobody, and Quan discussed how he was reluctant to take the part due to his conditioned beliefs about how an action hero should look. But he reveals that he changed his mind following a meeting with Spielberg, who convinced him to do it.</p>
|
|
26
|
+
</article>
|
|
27
|
+
|
|
28
|
+
{#if browser}
|
|
29
|
+
{@render children()}
|
|
30
|
+
{/if}
|
|
31
|
+
{/key}
|
|
32
|
+
|
|
33
|
+
|
|
34
|
+
<style>
|
|
35
|
+
:global {
|
|
36
|
+
article {
|
|
37
|
+
min-height: 100vh;
|
|
38
|
+
padding: 2rem;
|
|
39
|
+
background: #222;
|
|
40
|
+
color: white;
|
|
41
|
+
font-family: sans-serif;
|
|
42
|
+
line-height: 1.4em;
|
|
43
|
+
}
|
|
44
|
+
|
|
45
|
+
article > :first-child {
|
|
46
|
+
margin-top: 0;
|
|
47
|
+
}
|
|
48
|
+
|
|
49
|
+
article a {
|
|
50
|
+
color: hotpink;
|
|
51
|
+
}
|
|
52
|
+
}
|
|
53
|
+
</style>
|
|
54
|
+
|
|
@@ -0,0 +1,96 @@
|
|
|
1
|
+
<script>
|
|
2
|
+
import { onMount } from 'svelte'
|
|
3
|
+
import { pollLinkInjections } from '$lib/api'
|
|
4
|
+
import { clearLinkInjections, getLinkInjectionElements, getLinkInjectionsParentElement, injectLinksInDocument } from '$lib/linkInjection'
|
|
5
|
+
import { track } from '$lib/tracking'
|
|
6
|
+
import { TrackingEvent } from '$lib/enums/TrackingEvent'
|
|
7
|
+
import { authorize, isEditorialModeEnabled } from '$lib/auth'
|
|
8
|
+
import TitleModal from './components/TitleModal.svelte'
|
|
9
|
+
import Editor from './components/Editorial/Editor.svelte'
|
|
10
|
+
|
|
11
|
+
const parentElement = getLinkInjectionsParentElement()
|
|
12
|
+
/** @type {HTMLElement[]} */
|
|
13
|
+
const elements = getLinkInjectionElements(parentElement)
|
|
14
|
+
const htmlString = elements.map(p => p.outerHTML).join('')
|
|
15
|
+
const isEditorialMode = isEditorialModeEnabled()
|
|
16
|
+
|
|
17
|
+
/** @type {LinkInjection[]} */
|
|
18
|
+
let linkInjections = $state([])
|
|
19
|
+
/** @type {LinkInjection | null} */
|
|
20
|
+
let activeInjection = $state(null)
|
|
21
|
+
let authorized = $state(false)
|
|
22
|
+
let loading = $state(true)
|
|
23
|
+
|
|
24
|
+
// Rerender link injections when linkInjections change. This is only relevant for editiorial mode.
|
|
25
|
+
$effect(() => {
|
|
26
|
+
if (isEditorialMode) rerender()
|
|
27
|
+
})
|
|
28
|
+
|
|
29
|
+
onMount(() => {
|
|
30
|
+
track(TrackingEvent.ArticlePageView)
|
|
31
|
+
|
|
32
|
+
initialize()
|
|
33
|
+
|
|
34
|
+
return () => clearLinkInjections()
|
|
35
|
+
})
|
|
36
|
+
|
|
37
|
+
async function initialize() {
|
|
38
|
+
if (isEditorialMode) authorized = await authorize()
|
|
39
|
+
|
|
40
|
+
const url = location.protocol + '//' + location.host + location.pathname
|
|
41
|
+
const response = await pollLinkInjections(url, htmlString) || []
|
|
42
|
+
linkInjections = response.filter(i => i.title_details) // Filter out injections without titles
|
|
43
|
+
|
|
44
|
+
inject()
|
|
45
|
+
|
|
46
|
+
loading = false
|
|
47
|
+
}
|
|
48
|
+
|
|
49
|
+
function rerender() {
|
|
50
|
+
clearLinkInjections()
|
|
51
|
+
inject()
|
|
52
|
+
}
|
|
53
|
+
|
|
54
|
+
function inject() {
|
|
55
|
+
if (!linkInjections.length) return
|
|
56
|
+
|
|
57
|
+
const filteredInjections = injectLinksInDocument(elements, linkInjections, setTarget)
|
|
58
|
+
if (JSON.stringify(linkInjections) !== JSON.stringify(filteredInjections)) linkInjections = filteredInjections
|
|
59
|
+
}
|
|
60
|
+
|
|
61
|
+
/** @param {LinkInjection} injection */
|
|
62
|
+
function setTarget(injection) {
|
|
63
|
+
activeInjection = injection
|
|
64
|
+
}
|
|
65
|
+
</script>
|
|
66
|
+
|
|
67
|
+
<div class="playpilot-link-injections">
|
|
68
|
+
{#if isEditorialMode && authorized}
|
|
69
|
+
<Editor bind:linkInjections {htmlString} {loading} />
|
|
70
|
+
{/if}
|
|
71
|
+
|
|
72
|
+
{#if activeInjection && activeInjection.title_details}
|
|
73
|
+
<TitleModal title={activeInjection.title_details} onclose={() => activeInjection = null} />
|
|
74
|
+
{/if}
|
|
75
|
+
</div>
|
|
76
|
+
|
|
77
|
+
<style lang="scss">
|
|
78
|
+
@import url('https://fonts.googleapis.com/css?family=Poppins:400,600,700');
|
|
79
|
+
@import url('$lib/variables.css');
|
|
80
|
+
@import url('$lib/global.css');
|
|
81
|
+
|
|
82
|
+
.playpilot-link-injections :global(*) {
|
|
83
|
+
box-sizing: border-box;
|
|
84
|
+
}
|
|
85
|
+
|
|
86
|
+
.playpilot-link-injections :global(button),
|
|
87
|
+
.playpilot-link-injections :global(input) {
|
|
88
|
+
transition: outline-offset 100ms;
|
|
89
|
+
}
|
|
90
|
+
|
|
91
|
+
.playpilot-link-injections :global(button):focus-visible,
|
|
92
|
+
.playpilot-link-injections :global(input):focus-visible {
|
|
93
|
+
outline: 2px solid white;
|
|
94
|
+
outline-offset: 2px;
|
|
95
|
+
}
|
|
96
|
+
</style>
|