@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.
Files changed (101) hide show
  1. package/.github/workflows/tests.yml +22 -0
  2. package/.prettierignore +4 -0
  3. package/.prettierrc +16 -0
  4. package/README.md +38 -0
  5. package/dist/link-injections.js +7 -0
  6. package/eslint.config.js +33 -0
  7. package/index.html +11 -0
  8. package/jsconfig.json +19 -0
  9. package/package.json +35 -0
  10. package/src/app.d.ts +13 -0
  11. package/src/app.html +12 -0
  12. package/src/demo.spec.js +7 -0
  13. package/src/lib/api.js +160 -0
  14. package/src/lib/array.js +15 -0
  15. package/src/lib/auth.js +84 -0
  16. package/src/lib/constants.js +2 -0
  17. package/src/lib/enums/TrackingEvent.js +15 -0
  18. package/src/lib/fakeData.js +140 -0
  19. package/src/lib/genres.json +420 -0
  20. package/src/lib/global.css +37 -0
  21. package/src/lib/hash.js +15 -0
  22. package/src/lib/html.js +21 -0
  23. package/src/lib/index.js +1 -0
  24. package/src/lib/linkInjection.js +275 -0
  25. package/src/lib/search.js +24 -0
  26. package/src/lib/text.js +61 -0
  27. package/src/lib/tracking.js +32 -0
  28. package/src/lib/variables.css +16 -0
  29. package/src/main.js +45 -0
  30. package/src/routes/+layout.svelte +54 -0
  31. package/src/routes/+page.svelte +96 -0
  32. package/src/routes/components/AfterArticlePlaylinks.svelte +90 -0
  33. package/src/routes/components/ContextMenu.svelte +67 -0
  34. package/src/routes/components/Description.svelte +47 -0
  35. package/src/routes/components/Editorial/Alert.svelte +18 -0
  36. package/src/routes/components/Editorial/DragHandle.svelte +134 -0
  37. package/src/routes/components/Editorial/Editor.svelte +277 -0
  38. package/src/routes/components/Editorial/EditorItem.svelte +260 -0
  39. package/src/routes/components/Editorial/ManualInjection.svelte +192 -0
  40. package/src/routes/components/Editorial/PlaylinkTypeSelect.svelte +132 -0
  41. package/src/routes/components/Editorial/Search/TitleSearch.svelte +176 -0
  42. package/src/routes/components/Editorial/Switch.svelte +76 -0
  43. package/src/routes/components/Editorial/TextInput.svelte +29 -0
  44. package/src/routes/components/Genres.svelte +41 -0
  45. package/src/routes/components/Icons/IconAlign.svelte +12 -0
  46. package/src/routes/components/Icons/IconBack.svelte +3 -0
  47. package/src/routes/components/Icons/IconBookmark.svelte +3 -0
  48. package/src/routes/components/Icons/IconChevron.svelte +18 -0
  49. package/src/routes/components/Icons/IconClose.svelte +3 -0
  50. package/src/routes/components/Icons/IconContinue.svelte +3 -0
  51. package/src/routes/components/Icons/IconDots.svelte +5 -0
  52. package/src/routes/components/Icons/IconEnlarge.svelte +12 -0
  53. package/src/routes/components/Icons/IconIMDb.svelte +3 -0
  54. package/src/routes/components/Icons/IconNewTab.svelte +3 -0
  55. package/src/routes/components/Modal.svelte +106 -0
  56. package/src/routes/components/Participants.svelte +44 -0
  57. package/src/routes/components/Playlinks.svelte +155 -0
  58. package/src/routes/components/Popover.svelte +95 -0
  59. package/src/routes/components/RoundButton.svelte +38 -0
  60. package/src/routes/components/SkeletonText.svelte +33 -0
  61. package/src/routes/components/Title.svelte +180 -0
  62. package/src/routes/components/TitleModal.svelte +24 -0
  63. package/src/routes/components/TitlePopover.svelte +17 -0
  64. package/src/tests/helpers.js +18 -0
  65. package/src/tests/lib/api.test.js +162 -0
  66. package/src/tests/lib/array.test.js +14 -0
  67. package/src/tests/lib/auth.test.js +115 -0
  68. package/src/tests/lib/hash.test.js +28 -0
  69. package/src/tests/lib/html.test.js +16 -0
  70. package/src/tests/lib/linkInjection.test.js +754 -0
  71. package/src/tests/lib/search.test.js +42 -0
  72. package/src/tests/lib/text.test.js +94 -0
  73. package/src/tests/lib/tracking.test.js +71 -0
  74. package/src/tests/routes/+page.test.js +109 -0
  75. package/src/tests/routes/components/AfterArticlePlaylinks.test.js +115 -0
  76. package/src/tests/routes/components/ContextMenu.test.js +37 -0
  77. package/src/tests/routes/components/Description.test.js +58 -0
  78. package/src/tests/routes/components/Editorial/Alert.test.js +17 -0
  79. package/src/tests/routes/components/Editorial/DragHandle.test.js +55 -0
  80. package/src/tests/routes/components/Editorial/Editor.test.js +64 -0
  81. package/src/tests/routes/components/Editorial/EditorItem.test.js +142 -0
  82. package/src/tests/routes/components/Editorial/ManualInjection.test.js +114 -0
  83. package/src/tests/routes/components/Editorial/PlaylinkTypeSelect.test.js +63 -0
  84. package/src/tests/routes/components/Editorial/Search/TitleSearch.test.js +58 -0
  85. package/src/tests/routes/components/Editorial/Switch.test.js +60 -0
  86. package/src/tests/routes/components/Editorial/TextInput.test.js +30 -0
  87. package/src/tests/routes/components/Genres.test.js +37 -0
  88. package/src/tests/routes/components/Modal.test.js +84 -0
  89. package/src/tests/routes/components/Participants.test.js +33 -0
  90. package/src/tests/routes/components/Playlinks.test.js +101 -0
  91. package/src/tests/routes/components/Popover.test.js +66 -0
  92. package/src/tests/routes/components/RoundButton.test.js +35 -0
  93. package/src/tests/routes/components/SkeletonText.test.js +12 -0
  94. package/src/tests/routes/components/Title.test.js +82 -0
  95. package/src/tests/routes/components/TitleModal.test.js +33 -0
  96. package/src/tests/routes/components/TitlePopover.test.js +23 -0
  97. package/src/tests/setup.js +53 -0
  98. package/src/typedefs.js +72 -0
  99. package/static/favicon.png +0 -0
  100. package/svelte.config.js +13 -0
  101. 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
+ }
@@ -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
+ }
@@ -0,0 +1,21 @@
1
+ /**
2
+ * Returns a string with decoded html characters. For instance & -> &amp;
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 &amp; -> &
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
+ }
@@ -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
+ }
@@ -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 &amp; 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 &amp;
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 &amp; 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>