@playpilot/tpi 1.4.3 → 2.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/dist/link-injections.js +7 -7
- package/package.json +1 -1
- package/src/lib/api.js +13 -30
- package/src/lib/fakeData.js +2 -1
- package/src/lib/linkInjection.js +102 -11
- package/src/lib/text.js +0 -1
- package/src/routes/+layout.svelte +3 -1
- package/src/routes/+page.svelte +35 -16
- package/src/routes/components/Editorial/AIIndicator.svelte +133 -0
- package/src/routes/components/Editorial/Editor.svelte +39 -10
- package/src/routes/components/Editorial/EditorItem.svelte +2 -2
- package/src/routes/components/Editorial/ManualInjection.svelte +1 -0
- package/src/routes/components/Editorial/PlaylinkTypeSelect.svelte +2 -2
- package/src/routes/components/Icons/IconAi.svelte +1 -0
- package/src/tests/helpers.js +17 -0
- package/src/tests/lib/api.test.js +13 -26
- package/src/tests/lib/linkInjection.test.js +259 -250
- package/src/tests/routes/+page.test.js +16 -1
- package/src/tests/routes/components/Editorial/AiIndicator.test.js +58 -0
- package/src/tests/routes/components/Editorial/Editor.test.js +18 -20
- package/src/tests/routes/components/Editorial/EditorItem.test.js +12 -17
- package/src/tests/routes/components/Editorial/ManualInjection.test.js +12 -10
- package/src/typedefs.js +27 -12
package/package.json
CHANGED
package/src/lib/api.js
CHANGED
|
@@ -1,5 +1,6 @@
|
|
|
1
1
|
import { authorize, getAuthToken, isEditorialModeEnabled } from './auth'
|
|
2
2
|
import { apiBaseUrl } from './constants'
|
|
3
|
+
import { linkInjections } from './fakeData'
|
|
3
4
|
import { stringToHash } from './hash'
|
|
4
5
|
import { getPageMetaData } from './meta'
|
|
5
6
|
import { getFullUrlPath } from './url'
|
|
@@ -49,9 +50,9 @@ export async function fetchLinkInjections(url, html, { hash = stringToHash(html)
|
|
|
49
50
|
* The results return `injections_ready=false` while the injections are not yet ready.
|
|
50
51
|
* @param {string} url URL of the given article
|
|
51
52
|
* @param {string} html HTML to be crawled
|
|
52
|
-
* @returns {Promise<
|
|
53
|
+
* @returns {Promise<LinkInjectionResponse>}
|
|
53
54
|
*/
|
|
54
|
-
export async function pollLinkInjections(url, html, pollInterval = 3000, maxTries = 600) {
|
|
55
|
+
export async function pollLinkInjections(url, html, { requireCompletedResult = false, pollInterval = 3000, maxTries = 600 } = {}) {
|
|
55
56
|
let hash = stringToHash(html)
|
|
56
57
|
let currentTry = 0
|
|
57
58
|
|
|
@@ -69,35 +70,13 @@ export async function pollLinkInjections(url, html, pollInterval = 3000, maxTrie
|
|
|
69
70
|
try {
|
|
70
71
|
const response = await fetchLinkInjections(url, html, { hash })
|
|
71
72
|
|
|
72
|
-
|
|
73
|
-
// Specifically checking against false to exclude responses without the property.
|
|
74
|
-
// Should still return injections as expect in editorial mode.
|
|
75
|
-
if (response.injections_enabled === false && !isEditorialModeEnabled()) {
|
|
76
|
-
resolve([])
|
|
77
|
-
return
|
|
78
|
-
}
|
|
79
|
-
|
|
80
|
-
// Temporary solution #2. If you're reading this weeks, months, or maybe even years from now, we've failed.
|
|
81
|
-
// Injections might be re-generated when a hash changes but injections do exist for the same url.
|
|
82
|
-
// In this case previous injections are returned while still generating new injections in the background.
|
|
83
|
-
// We ignore the upcoming re-generation and use the current injections instead.
|
|
84
|
-
if (response.injections_ready === false && response.link_injections?.length) {
|
|
85
|
-
response.link_injections = insertRandomKeys(response.link_injections)
|
|
86
|
-
|
|
87
|
-
resolve(response.link_injections || [])
|
|
88
|
-
return
|
|
89
|
-
}
|
|
90
|
-
|
|
91
|
-
if (response.injections_ready) {
|
|
92
|
-
if (response.link_injections) {
|
|
93
|
-
response.link_injections = insertRandomKeys(response.link_injections)
|
|
94
|
-
}
|
|
73
|
+
if (requireCompletedResult && (response.automation_enabled && response.ai_running)) throw new Error
|
|
95
74
|
|
|
96
|
-
|
|
97
|
-
|
|
98
|
-
}
|
|
75
|
+
response.link_injections = insertRandomKeys(response.link_injections || [])
|
|
76
|
+
response.ai_injections = insertRandomKeys(response.ai_injections || [])
|
|
99
77
|
|
|
100
|
-
|
|
78
|
+
resolve(response)
|
|
79
|
+
return
|
|
101
80
|
} catch {
|
|
102
81
|
currentTry++
|
|
103
82
|
|
|
@@ -123,7 +102,10 @@ export async function saveLinkInjections(linkInjections, html) {
|
|
|
123
102
|
// @ts-ignore
|
|
124
103
|
const selector = window.PlayPilotLinkInjections?.selector
|
|
125
104
|
|
|
126
|
-
|
|
105
|
+
// Only save manual injections, AI injections should be left intact.
|
|
106
|
+
const filteredLinkInjections = linkInjections.filter(i => i.manual)
|
|
107
|
+
|
|
108
|
+
const newLinkInjections = filteredLinkInjections.map((/** @type {any} */linkInjection) => ({
|
|
127
109
|
sid: linkInjection.sid,
|
|
128
110
|
title: linkInjection.title,
|
|
129
111
|
sentence: linkInjection.sentence,
|
|
@@ -132,6 +114,7 @@ export async function saveLinkInjections(linkInjections, html) {
|
|
|
132
114
|
after_article_style: linkInjection.after_article_style || null,
|
|
133
115
|
in_text: linkInjection.in_text ?? true,
|
|
134
116
|
inactive: !!linkInjection.inactive,
|
|
117
|
+
removed: !!linkInjection.removed,
|
|
135
118
|
}))
|
|
136
119
|
|
|
137
120
|
const response = await fetchLinkInjections(getFullUrlPath(), html, {
|
package/src/lib/fakeData.js
CHANGED
|
@@ -121,7 +121,7 @@ export const linkInjections = [{
|
|
|
121
121
|
sentence: 'The movie is in a similar vein to successful films such as The Long Kiss Goodnight and Nobody',
|
|
122
122
|
playpilot_url: 'https://playpilot.com/movie/example-2/',
|
|
123
123
|
key: 'some-key-2',
|
|
124
|
-
after_article:
|
|
124
|
+
after_article: false,
|
|
125
125
|
title_details: title,
|
|
126
126
|
}, {
|
|
127
127
|
sid: '3',
|
|
@@ -131,6 +131,7 @@ export const linkInjections = [{
|
|
|
131
131
|
key: 'some-key-3',
|
|
132
132
|
after_article: true,
|
|
133
133
|
title_details: title,
|
|
134
|
+
manual: false,
|
|
134
135
|
}, {
|
|
135
136
|
sid: '4',
|
|
136
137
|
title: 'The Wheel of Time',
|
package/src/lib/linkInjection.js
CHANGED
|
@@ -3,6 +3,7 @@ import TitlePopover from '../routes/components/TitlePopover.svelte'
|
|
|
3
3
|
import AfterArticlePlaylinks from '../routes/components/AfterArticlePlaylinks.svelte'
|
|
4
4
|
import { findTextNodeContaining, isNodeInLink, replaceStartingFrom } from './text'
|
|
5
5
|
import { getLargestValueInArray } from './array'
|
|
6
|
+
import { decodeHtmlEntities } from './html'
|
|
6
7
|
|
|
7
8
|
const keyDataAttribute = 'data-playpilot-injection-key'
|
|
8
9
|
const keySelector = `[${keyDataAttribute}]`
|
|
@@ -74,25 +75,27 @@ export function getLinkInjectionsParentElement() {
|
|
|
74
75
|
/**
|
|
75
76
|
* Replace all found injections within all given elements on the page
|
|
76
77
|
* @param {HTMLElement[]} elements
|
|
77
|
-
* @param {LinkInjection[]} injections
|
|
78
78
|
* @param {(LinkInjection: LinkInjection) => void} onclick
|
|
79
|
+
* @param {LinkInjectionTypes} injections
|
|
79
80
|
* @returns {LinkInjection[]} Returns an array of injections with injections that failed to be inserted marked as `failed`.
|
|
80
81
|
*/
|
|
81
|
-
export function injectLinksInDocument(elements, injections,
|
|
82
|
+
export function injectLinksInDocument(elements, onclick, injections = { aiInjections: [], manualInjections: [] }) {
|
|
83
|
+
const mergedInjections = mergeInjectionTypes(injections)
|
|
84
|
+
|
|
82
85
|
// Find injection in text content of all elements together, ignore potential HTML elements.
|
|
83
86
|
// This is to filter out injections that can't be injected anyway.
|
|
84
87
|
const fullText = elements.map(element => element.innerText).join(' ')
|
|
85
88
|
|
|
86
|
-
|
|
87
|
-
|
|
88
|
-
|
|
89
|
-
|
|
89
|
+
const validInjections = filterInvalidInTextInjections(mergedInjections)
|
|
90
|
+
const foundInjections = validInjections.filter(i => {
|
|
91
|
+
return decodeHtmlEntities(fullText).includes(decodeHtmlEntities(i.sentence))
|
|
92
|
+
})
|
|
90
93
|
|
|
91
94
|
/** @type {LinkInjectionRanges} */
|
|
92
95
|
const ranges = {}
|
|
93
96
|
|
|
94
97
|
for (const injection of foundInjections) {
|
|
95
|
-
const elementIndex = elements.findIndex(element => element.innerText.includes(injection.sentence))
|
|
98
|
+
const elementIndex = elements.findIndex(element => element.innerText.includes(decodeHtmlEntities(injection.sentence)))
|
|
96
99
|
const element = elements[elementIndex]
|
|
97
100
|
|
|
98
101
|
if (!element) continue
|
|
@@ -132,17 +135,20 @@ export function injectLinksInDocument(elements, injections, onclick) {
|
|
|
132
135
|
addLinkInjectionEventListeners(validInjections, onclick)
|
|
133
136
|
addCSSVariablesToLinks()
|
|
134
137
|
|
|
135
|
-
const afterArticleInjections =
|
|
138
|
+
const afterArticleInjections = filterInvalidAfterArticleInjections(mergedInjections)
|
|
136
139
|
if (afterArticleInjections.length) insertAfterArticlePlaylinks(elements, afterArticleInjections, onclick)
|
|
137
140
|
|
|
138
|
-
const sortedInjections = sortLinkInjectionsByRange(
|
|
141
|
+
const sortedInjections = sortLinkInjectionsByRange(mergedInjections, ranges)
|
|
139
142
|
|
|
140
|
-
return sortedInjections.map(injection => {
|
|
143
|
+
return sortedInjections.filter(i => i.title_details).map((injection, index) => {
|
|
141
144
|
const failed = !injection.inactive && !injection.after_article && !document.querySelector(`[${keyDataAttribute}="${injection.key}"]`)
|
|
145
|
+
// Favour manual injections over AI injections
|
|
146
|
+
const duplicate = injection.duplicate ?? (!injection.manual && isAvailableAsManualInjection(injection, index, mergedInjections))
|
|
142
147
|
|
|
143
148
|
return {
|
|
144
149
|
...injection,
|
|
145
|
-
inactive:
|
|
150
|
+
inactive: injection.inactive ?? false,
|
|
151
|
+
duplicate,
|
|
146
152
|
failed,
|
|
147
153
|
}
|
|
148
154
|
})
|
|
@@ -278,6 +284,7 @@ function clearAfterArticlePlaylinks() {
|
|
|
278
284
|
|
|
279
285
|
unmount(afterArticlePlaylinkInsertedComponent)
|
|
280
286
|
document.querySelector('[data-playpilot-after-article-playlinks]')?.remove()
|
|
287
|
+
afterArticlePlaylinkInsertedComponent = null
|
|
281
288
|
}
|
|
282
289
|
|
|
283
290
|
/**
|
|
@@ -327,3 +334,87 @@ export function sortLinkInjectionsByRange(injections, ranges) {
|
|
|
327
334
|
return (rangeA?.from || 0) - (rangeB?.from || 0)
|
|
328
335
|
})
|
|
329
336
|
}
|
|
337
|
+
|
|
338
|
+
/**
|
|
339
|
+
* Merge different injection types
|
|
340
|
+
* @param {LinkInjectionTypes} injections
|
|
341
|
+
* @returns {LinkInjection[]}
|
|
342
|
+
*/
|
|
343
|
+
export function mergeInjectionTypes({ aiInjections, manualInjections }) {
|
|
344
|
+
return [...aiInjections, ...manualInjections.map(i => ({ ...i, manual: true }))]
|
|
345
|
+
}
|
|
346
|
+
|
|
347
|
+
/**
|
|
348
|
+
* Separate an array of flat injections into ai and manual arrays.
|
|
349
|
+
* @param {LinkInjection[]} injections
|
|
350
|
+
* @returns {LinkInjectionTypes}
|
|
351
|
+
*/
|
|
352
|
+
export function separateLinkInjectionTypes(injections) {
|
|
353
|
+
return {
|
|
354
|
+
aiInjections: injections.filter(i => !i.manual),
|
|
355
|
+
manualInjections: injections.filter(i => i.manual),
|
|
356
|
+
}
|
|
357
|
+
}
|
|
358
|
+
|
|
359
|
+
/**
|
|
360
|
+
* Returns whether or not an injection would be valid for any sort of injection, text or after_article
|
|
361
|
+
* @param {LinkInjection} injection
|
|
362
|
+
* @returns {boolean}
|
|
363
|
+
*/
|
|
364
|
+
export function isValidInjection(injection) {
|
|
365
|
+
return !injection.inactive && !injection.removed && !injection.duplicate && !!injection.title_details
|
|
366
|
+
}
|
|
367
|
+
|
|
368
|
+
/**
|
|
369
|
+
* Filter links for in-text injections, removing after article, inactive, removed, duplicate, and items without title_details
|
|
370
|
+
* @param {LinkInjection[]} injections
|
|
371
|
+
* @returns {LinkInjection[]}
|
|
372
|
+
*/
|
|
373
|
+
export function filterInvalidInTextInjections(injections) {
|
|
374
|
+
return filterRemovedInjections(injections).filter(i => i.in_text !== false && isValidInjection(i))
|
|
375
|
+
}
|
|
376
|
+
|
|
377
|
+
/**
|
|
378
|
+
* Filter links for after article injections, removing in-text only, inactive, removed, duplicate, and items without title_details
|
|
379
|
+
* @param {LinkInjection[]} injections
|
|
380
|
+
* @returns {LinkInjection[]}
|
|
381
|
+
*/
|
|
382
|
+
export function filterInvalidAfterArticleInjections(injections) {
|
|
383
|
+
return filterRemovedInjections(injections).filter(i => i.after_article === true && isValidInjection(i))
|
|
384
|
+
}
|
|
385
|
+
|
|
386
|
+
/**
|
|
387
|
+
* Filter injections that were marked as removed or have an equivalent removed manual injections, soley based on the same sentence and title.
|
|
388
|
+
* @param {LinkInjection[]} injections
|
|
389
|
+
* @returns {LinkInjection[]}
|
|
390
|
+
*/
|
|
391
|
+
export function filterRemovedInjections(injections) {
|
|
392
|
+
return injections.filter(injection => {
|
|
393
|
+
if (injection.removed) return false
|
|
394
|
+
if (injection.manual && !injection.removed) return true
|
|
395
|
+
return !injections.some(i => i.manual && i.removed && isEquivalentInjection(i, injection))
|
|
396
|
+
})
|
|
397
|
+
}
|
|
398
|
+
|
|
399
|
+
/**
|
|
400
|
+
* Return whether or not an injection is also available as manual injection
|
|
401
|
+
* @param {LinkInjection} injection
|
|
402
|
+
* @param {number} injectionIndex
|
|
403
|
+
* @param {LinkInjection[]} injections
|
|
404
|
+
* @returns {boolean}
|
|
405
|
+
*/
|
|
406
|
+
export function isAvailableAsManualInjection(injection, injectionIndex, injections) {
|
|
407
|
+
return injections.some((i, index) => {
|
|
408
|
+
return injectionIndex !== index && i.manual && isEquivalentInjection(i, injection)
|
|
409
|
+
})
|
|
410
|
+
}
|
|
411
|
+
|
|
412
|
+
/**
|
|
413
|
+
* Returns whether or not 2 injections match in title and sentence
|
|
414
|
+
* @param {LinkInjection} injection1
|
|
415
|
+
* @param {LinkInjection} injection2
|
|
416
|
+
* @returns {boolean}
|
|
417
|
+
*/
|
|
418
|
+
export function isEquivalentInjection(injection1, injection2) {
|
|
419
|
+
return injection1.title === injection2.title && injection1.sentence === injection2.sentence
|
|
420
|
+
}
|
package/src/lib/text.js
CHANGED
|
@@ -19,11 +19,13 @@
|
|
|
19
19
|
if (browser) window.PlayPilotLinkInjections = { token: 'ZoAL14yqzevMyQiwckbvyetOkeIUeEDN', selector: 'article' }
|
|
20
20
|
</script>
|
|
21
21
|
|
|
22
|
+
<meta property="article:modified_time" content="2025-05-15T20:00:00+00:00" />
|
|
23
|
+
|
|
22
24
|
<div>
|
|
23
25
|
{#key Math.random()}
|
|
24
26
|
<article use:noClass>
|
|
25
27
|
<h1 use:noClass>Some heading</h1>
|
|
26
|
-
<time datetime="
|
|
28
|
+
<time datetime="14:00">1 hour ago</time>
|
|
27
29
|
<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>
|
|
28
30
|
<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>
|
|
29
31
|
</article>
|
package/src/routes/+page.svelte
CHANGED
|
@@ -1,7 +1,7 @@
|
|
|
1
1
|
<script>
|
|
2
2
|
import { onMount } from 'svelte'
|
|
3
3
|
import { pollLinkInjections } from '$lib/api'
|
|
4
|
-
import { clearLinkInjections, getLinkInjectionElements, getLinkInjectionsParentElement, injectLinksInDocument } from '$lib/linkInjection'
|
|
4
|
+
import { clearLinkInjections, getLinkInjectionElements, getLinkInjectionsParentElement, injectLinksInDocument, separateLinkInjectionTypes } from '$lib/linkInjection'
|
|
5
5
|
import { track } from '$lib/tracking'
|
|
6
6
|
import { getFullUrlPath } from '$lib/url'
|
|
7
7
|
import { TrackingEvent } from '$lib/enums/TrackingEvent'
|
|
@@ -15,12 +15,18 @@
|
|
|
15
15
|
const htmlString = elements.map(p => p.outerHTML).join('')
|
|
16
16
|
const isEditorialMode = isEditorialModeEnabled()
|
|
17
17
|
|
|
18
|
-
/** @type {
|
|
19
|
-
let
|
|
18
|
+
/** @type {LinkInjectionResponse | null} */
|
|
19
|
+
let response = $state(null)
|
|
20
20
|
/** @type {LinkInjection | null} */
|
|
21
21
|
let activeInjection = $state(null)
|
|
22
22
|
let authorized = $state(false)
|
|
23
23
|
let loading = $state(true)
|
|
24
|
+
/** @type {LinkInjection[]} */
|
|
25
|
+
let linkInjections = $state([])
|
|
26
|
+
let editor = $state()
|
|
27
|
+
|
|
28
|
+
// @ts-ignore It's ok if the response is empty
|
|
29
|
+
const { ai_injections: aiInjections = [], link_injections: manualInjections = [] } = $derived(response || {})
|
|
24
30
|
|
|
25
31
|
// Rerender link injections when linkInjections change. This is only relevant for editiorial mode.
|
|
26
32
|
$effect(() => {
|
|
@@ -38,24 +44,37 @@
|
|
|
38
44
|
async function initialize() {
|
|
39
45
|
if (isEditorialMode) authorized = await authorize()
|
|
40
46
|
|
|
41
|
-
|
|
42
|
-
linkInjections = response.filter(i => i.title_details) // Filter out injections without titles
|
|
47
|
+
response = await pollLinkInjections(getFullUrlPath(), htmlString)
|
|
43
48
|
|
|
44
|
-
inject()
|
|
49
|
+
inject({ aiInjections, manualInjections })
|
|
45
50
|
|
|
46
51
|
loading = false
|
|
52
|
+
|
|
53
|
+
// A response was previous returned, but injections were still being generated in the backend.
|
|
54
|
+
// With this second request we wait until AI links are ready. We only do this in editorial
|
|
55
|
+
// so as not to suddenly insert new links while a user is reading the article.
|
|
56
|
+
if (!response?.ai_running) return
|
|
57
|
+
if (!isEditorialMode) return
|
|
58
|
+
|
|
59
|
+
const continuedResponse = await pollLinkInjections(getFullUrlPath(), htmlString, { requireCompletedResult: true })
|
|
60
|
+
editor.requestNewAIInjections(continuedResponse?.ai_injections || [])
|
|
47
61
|
}
|
|
48
62
|
|
|
49
63
|
function rerender() {
|
|
50
64
|
clearLinkInjections()
|
|
51
|
-
inject()
|
|
65
|
+
inject(separateLinkInjectionTypes(linkInjections))
|
|
52
66
|
}
|
|
53
67
|
|
|
54
|
-
|
|
55
|
-
|
|
68
|
+
/**
|
|
69
|
+
* @param {LinkInjectionTypes} injections
|
|
70
|
+
*/
|
|
71
|
+
function inject(injections = { aiInjections, manualInjections }) {
|
|
72
|
+
if (!aiInjections.length && !manualInjections.length) return
|
|
56
73
|
|
|
57
|
-
|
|
58
|
-
if
|
|
74
|
+
// Get filtered injections as they are shown on the page.
|
|
75
|
+
// Only update state if it they are different from current injections.
|
|
76
|
+
const filteredInjections = injectLinksInDocument(elements, setTarget, injections)
|
|
77
|
+
if (JSON.stringify(filteredInjections) !== JSON.stringify(linkInjections)) linkInjections = filteredInjections
|
|
59
78
|
}
|
|
60
79
|
|
|
61
80
|
/** @param {LinkInjection} injection */
|
|
@@ -66,7 +85,7 @@
|
|
|
66
85
|
|
|
67
86
|
<div class="playpilot-link-injections">
|
|
68
87
|
{#if isEditorialMode && authorized}
|
|
69
|
-
<Editor bind:linkInjections {htmlString} {loading} />
|
|
88
|
+
<Editor bind:linkInjections bind:this={editor} {htmlString} {loading} aiRunning={response?.ai_running && response?.automation_enabled} />
|
|
70
89
|
{/if}
|
|
71
90
|
|
|
72
91
|
{#if activeInjection && activeInjection.title_details}
|
|
@@ -79,13 +98,13 @@
|
|
|
79
98
|
@import url('$lib/scss/variables.scss');
|
|
80
99
|
@import url('$lib/scss/global.scss');
|
|
81
100
|
|
|
82
|
-
.playpilot-link-injections
|
|
83
|
-
* {
|
|
101
|
+
.playpilot-link-injections {
|
|
102
|
+
:global(*) {
|
|
84
103
|
box-sizing: border-box;
|
|
85
104
|
}
|
|
86
105
|
|
|
87
|
-
.playpilot-link-injections button,
|
|
88
|
-
.playpilot-link-injections input {
|
|
106
|
+
:global(.playpilot-link-injections button),
|
|
107
|
+
:global(.playpilot-link-injections input) {
|
|
89
108
|
transition: outline-offset 100ms;
|
|
90
109
|
|
|
91
110
|
&:focus-visible,
|
|
@@ -0,0 +1,133 @@
|
|
|
1
|
+
<script>
|
|
2
|
+
import IconAi from '../Icons/IconAi.svelte'
|
|
3
|
+
|
|
4
|
+
/** @type {{ onadd: (injections: LinkInjection[]) => void }} */
|
|
5
|
+
let { onadd } = $props()
|
|
6
|
+
|
|
7
|
+
let running = $state(true)
|
|
8
|
+
/** @type {LinkInjection[]} */
|
|
9
|
+
let injectionsToBeInserted = $state([])
|
|
10
|
+
let dismissed = $state(false)
|
|
11
|
+
|
|
12
|
+
/**
|
|
13
|
+
* This is called from the Editor component when AI links are ready.
|
|
14
|
+
* From here we determine what to show the user. Either we show them an option
|
|
15
|
+
* to inject new links, or we tell them no new injections were found.
|
|
16
|
+
* @param {LinkInjection[]} injections
|
|
17
|
+
*/
|
|
18
|
+
export function notifyUserOfNewState(injections) {
|
|
19
|
+
running = false
|
|
20
|
+
injectionsToBeInserted = injections
|
|
21
|
+
}
|
|
22
|
+
</script>
|
|
23
|
+
|
|
24
|
+
{#if !dismissed}
|
|
25
|
+
<div class="ai-indicator" class:running>
|
|
26
|
+
<div class="content">
|
|
27
|
+
<div class="icon">
|
|
28
|
+
<IconAi />
|
|
29
|
+
</div>
|
|
30
|
+
|
|
31
|
+
<div>
|
|
32
|
+
{#if running}
|
|
33
|
+
AI links are currently processing. You can add manual links while this is ongoing.
|
|
34
|
+
{:else if injectionsToBeInserted?.length}
|
|
35
|
+
AI links are ready.
|
|
36
|
+
<strong>{injectionsToBeInserted.length} New {injectionsToBeInserted.length > 1 ? 'links were' : 'link was'} found.</strong>
|
|
37
|
+
New links will be used next time you refresh the page, or you can insert them now.
|
|
38
|
+
|
|
39
|
+
<button class="button" onclick={() => { onadd(injectionsToBeInserted); dismissed = true }}>
|
|
40
|
+
Add AI links
|
|
41
|
+
</button>
|
|
42
|
+
{:else}
|
|
43
|
+
AI links finished running, but no new links were found.
|
|
44
|
+
|
|
45
|
+
<button class="button" onclick={() => dismissed = true}>Dismiss</button>
|
|
46
|
+
{/if}
|
|
47
|
+
</div>
|
|
48
|
+
</div>
|
|
49
|
+
|
|
50
|
+
<div class="border">
|
|
51
|
+
{#if running}
|
|
52
|
+
<div class="animator"></div>
|
|
53
|
+
{/if}
|
|
54
|
+
</div>
|
|
55
|
+
</div>
|
|
56
|
+
{/if}
|
|
57
|
+
|
|
58
|
+
<style lang="scss">
|
|
59
|
+
.ai-indicator {
|
|
60
|
+
position: relative;
|
|
61
|
+
margin: 0 margin(0.5);
|
|
62
|
+
}
|
|
63
|
+
|
|
64
|
+
.content {
|
|
65
|
+
position: relative;
|
|
66
|
+
display: flex;
|
|
67
|
+
gap: margin(0.5);
|
|
68
|
+
background: var(--playpilot-light);
|
|
69
|
+
padding: margin(0.5);
|
|
70
|
+
margin: 2px;
|
|
71
|
+
border-radius: 0.5rem;
|
|
72
|
+
font-size: 12px;
|
|
73
|
+
line-height: 1.5;
|
|
74
|
+
z-index: 1;
|
|
75
|
+
color: var(--playpilot-text-color-alt);
|
|
76
|
+
}
|
|
77
|
+
|
|
78
|
+
.icon {
|
|
79
|
+
color: var(--playpilot-green);
|
|
80
|
+
}
|
|
81
|
+
|
|
82
|
+
.border {
|
|
83
|
+
position: absolute;
|
|
84
|
+
top: 0;
|
|
85
|
+
right: 0;
|
|
86
|
+
bottom: 0;
|
|
87
|
+
left: 0;
|
|
88
|
+
border-radius: 0.5rem;
|
|
89
|
+
background: var(--playpilot-green);
|
|
90
|
+
overflow: hidden;
|
|
91
|
+
transition: background-color 500ms;
|
|
92
|
+
|
|
93
|
+
.running & {
|
|
94
|
+
background: var(--playpilot-light);
|
|
95
|
+
}
|
|
96
|
+
}
|
|
97
|
+
|
|
98
|
+
@keyframes rotate {
|
|
99
|
+
to {
|
|
100
|
+
rotate: 360deg;
|
|
101
|
+
}
|
|
102
|
+
}
|
|
103
|
+
|
|
104
|
+
.animator {
|
|
105
|
+
position: absolute;
|
|
106
|
+
left: 50%;
|
|
107
|
+
top: 50%;
|
|
108
|
+
transform: translateY(-50%);
|
|
109
|
+
width: 100%;
|
|
110
|
+
height: 20rem;
|
|
111
|
+
background: var(--playpilot-green);
|
|
112
|
+
transform-origin: left 50%;
|
|
113
|
+
animation: rotate 2000ms infinite linear;
|
|
114
|
+
filter: blur(5rem);
|
|
115
|
+
}
|
|
116
|
+
|
|
117
|
+
.button {
|
|
118
|
+
display: block;
|
|
119
|
+
appearance: none;
|
|
120
|
+
padding: 0 margin(0.25);
|
|
121
|
+
margin: margin(0.25) 0 0;
|
|
122
|
+
border: 1px solid currentColor;
|
|
123
|
+
border-radius: 0.25rem;
|
|
124
|
+
background: transparent;
|
|
125
|
+
color: var(--playpilot-green);
|
|
126
|
+
font-family: var(--playpilot-font-family);
|
|
127
|
+
cursor: pointer;
|
|
128
|
+
|
|
129
|
+
&:hover {
|
|
130
|
+
color: white;
|
|
131
|
+
}
|
|
132
|
+
}
|
|
133
|
+
</style>
|
|
@@ -1,5 +1,5 @@
|
|
|
1
1
|
<script>
|
|
2
|
-
import { fly, slide } from 'svelte/transition'
|
|
2
|
+
import { fade, fly, slide } from 'svelte/transition'
|
|
3
3
|
import { flip } from 'svelte/animate'
|
|
4
4
|
import EditorItem from './EditorItem.svelte'
|
|
5
5
|
import DragHandle from './DragHandle.svelte'
|
|
@@ -8,9 +8,11 @@
|
|
|
8
8
|
import RoundButton from '../RoundButton.svelte'
|
|
9
9
|
import { saveLinkInjections } from '$lib/api'
|
|
10
10
|
import { untrack } from 'svelte'
|
|
11
|
+
import AIIndicator from './AIIndicator.svelte'
|
|
12
|
+
import { isEquivalentInjection, isValidInjection } from '$lib/linkInjection';
|
|
11
13
|
|
|
12
|
-
/** @type {{ linkInjections: LinkInjection[], htmlString?: string, loading?: boolean }} */
|
|
13
|
-
let { linkInjections = $bindable(), htmlString = '', loading = false } = $props()
|
|
14
|
+
/** @type {{ linkInjections: LinkInjection[], htmlString?: string, loading?: boolean, aiRunning?: boolean }} */
|
|
15
|
+
let { linkInjections = $bindable(), htmlString = '', loading = false, aiRunning = false } = $props()
|
|
14
16
|
|
|
15
17
|
const editorPositionKey = 'editor-position'
|
|
16
18
|
|
|
@@ -23,9 +25,11 @@
|
|
|
23
25
|
let hasError = $state(false)
|
|
24
26
|
let scrollDistance = $state(0)
|
|
25
27
|
let initialStateString = $state('')
|
|
28
|
+
let aIIndicator = $state()
|
|
26
29
|
|
|
27
30
|
const linkInjectionsString = $derived(JSON.stringify(linkInjections))
|
|
28
31
|
const hasChanged = $derived(initialStateString !== linkInjectionsString)
|
|
32
|
+
const filteredInjections = $derived(linkInjections.filter(i => i.title_details && !i.removed && !i.duplicate))
|
|
29
33
|
|
|
30
34
|
$effect(() => {
|
|
31
35
|
if (!loading) untrack(() => initialStateString = linkInjectionsString)
|
|
@@ -57,7 +61,12 @@
|
|
|
57
61
|
* @param {string} key
|
|
58
62
|
*/
|
|
59
63
|
function removeInjection(key) {
|
|
60
|
-
|
|
64
|
+
const index = linkInjections.findIndex(i => i.key === key)
|
|
65
|
+
|
|
66
|
+
linkInjections[index].manual = true
|
|
67
|
+
linkInjections[index].removed = true
|
|
68
|
+
|
|
69
|
+
linkInjections = [...linkInjections]
|
|
61
70
|
}
|
|
62
71
|
|
|
63
72
|
/**
|
|
@@ -88,6 +97,20 @@
|
|
|
88
97
|
const target = /** @type {HTMLElement} */ (event.target)
|
|
89
98
|
scrollDistance = target.scrollTop
|
|
90
99
|
}
|
|
100
|
+
|
|
101
|
+
/**
|
|
102
|
+
* This is called from outside when new AI links are ready.
|
|
103
|
+
* We only pass on links that do not already exist.
|
|
104
|
+
* @param {LinkInjection[]} injections
|
|
105
|
+
*/
|
|
106
|
+
export function requestNewAIInjections(injections) {
|
|
107
|
+
const newInjections = injections.filter(injection => {
|
|
108
|
+
if (!isValidInjection(injection)) return
|
|
109
|
+
return !linkInjections.some((i) => isEquivalentInjection(injection, i))
|
|
110
|
+
})
|
|
111
|
+
|
|
112
|
+
aIIndicator.notifyUserOfNewState(newInjections)
|
|
113
|
+
}
|
|
91
114
|
</script>
|
|
92
115
|
|
|
93
116
|
<section class="editor playpilot-styled-scrollbar" class:panel-open={manualInjectionActive} class:loading bind:this={editorElement} {onscroll}>
|
|
@@ -113,6 +136,10 @@
|
|
|
113
136
|
{/if}
|
|
114
137
|
</header>
|
|
115
138
|
|
|
139
|
+
{#if !loading && aiRunning}
|
|
140
|
+
<AIIndicator bind:this={aIIndicator} onadd={(newInjections) => newInjections.forEach(i => linkInjections.push(i))} />
|
|
141
|
+
{/if}
|
|
142
|
+
|
|
116
143
|
{#if !loading}
|
|
117
144
|
{#if hasError}
|
|
118
145
|
<div class="error" transition:slide|global={{ duration: 150 }}>
|
|
@@ -121,9 +148,12 @@
|
|
|
121
148
|
{/if}
|
|
122
149
|
|
|
123
150
|
<div class="items">
|
|
124
|
-
{#each
|
|
151
|
+
{#each filteredInjections as linkInjection (linkInjection.key)}
|
|
152
|
+
<!-- We want to bind to the original object, not the derived object, so we get the index of the injection in the original object by it's key -->
|
|
153
|
+
{@const index = linkInjections.findIndex(i => i.key === linkInjection.key)}
|
|
154
|
+
|
|
125
155
|
<div animate:flip={{ duration: 300 }}>
|
|
126
|
-
<EditorItem bind:linkInjection={linkInjections[
|
|
156
|
+
<EditorItem bind:linkInjection={linkInjections[index]} onremove={() => removeInjection(linkInjection.key)} onhighlight={scrollToItem} />
|
|
127
157
|
</div>
|
|
128
158
|
{:else}
|
|
129
159
|
<div class="empty">No links available. Add links manually by clicking the + button and select text to add a link to.</div>
|
|
@@ -131,7 +161,7 @@
|
|
|
131
161
|
</div>
|
|
132
162
|
|
|
133
163
|
{#if hasChanged && linkInjections.length}
|
|
134
|
-
<button class="save" disabled={saving} onclick={save}
|
|
164
|
+
<button class="save" disabled={saving} onclick={save} in:fade={{ duration: 100 }}>
|
|
135
165
|
{saving ? 'Saving...' : 'Save links'}
|
|
136
166
|
</button>
|
|
137
167
|
{/if}
|
|
@@ -168,8 +198,7 @@
|
|
|
168
198
|
right: margin(1);
|
|
169
199
|
width: 100%;
|
|
170
200
|
max-width: margin(20);
|
|
171
|
-
|
|
172
|
-
min-height: margin(25);
|
|
201
|
+
height: min(50vh, margin(50));
|
|
173
202
|
padding: margin(1);
|
|
174
203
|
border-radius: margin(1.5);
|
|
175
204
|
background: var(--playpilot-dark);
|
|
@@ -187,7 +216,7 @@
|
|
|
187
216
|
}
|
|
188
217
|
|
|
189
218
|
.loading {
|
|
190
|
-
|
|
219
|
+
height: auto;
|
|
191
220
|
border-radius: margin(2);
|
|
192
221
|
margin-left: auto;
|
|
193
222
|
padding-right: margin(0.5);
|
|
@@ -121,7 +121,7 @@
|
|
|
121
121
|
<IconChevron {expanded} />
|
|
122
122
|
</button>
|
|
123
123
|
|
|
124
|
-
<Switch label="Visible" active={!linkInjection.inactive} onclick={(active) => linkInjection.inactive = !active}>
|
|
124
|
+
<Switch label="Visible" active={!linkInjection.inactive} onclick={(active) => { linkInjection.inactive = !active; linkInjection.manual = true }}>
|
|
125
125
|
Visible
|
|
126
126
|
</Switch>
|
|
127
127
|
</div>
|
|
@@ -130,7 +130,7 @@
|
|
|
130
130
|
{#if expanded}
|
|
131
131
|
<div class="expanded" transition:slide={{ duration: 100 }}>
|
|
132
132
|
<div class="label">Link URL</div>
|
|
133
|
-
<TextInput bind:value={linkInjection.playpilot_url} label="Playlink URL" />
|
|
133
|
+
<TextInput bind:value={linkInjection.playpilot_url} oninput={() => linkInjection.manual = true} label="Playlink URL" />
|
|
134
134
|
|
|
135
135
|
<div class="label offset">Layout options</div>
|
|
136
136
|
<div class="type-select">
|