@playpilot/tpi 3.1.0 → 3.2.0-beta.1

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 (32) hide show
  1. package/dist/link-injections.js +8 -7
  2. package/package.json +1 -1
  3. package/src/lib/api.ts +1 -3
  4. package/src/lib/auth.ts +13 -1
  5. package/src/lib/constants.ts +2 -0
  6. package/src/lib/enums/TrackingEvent.ts +1 -0
  7. package/src/lib/linkInjection.ts +42 -32
  8. package/src/lib/scss/global.scss +6 -6
  9. package/src/lib/scss/variables.scss +2 -0
  10. package/src/lib/stores/organization.ts +4 -0
  11. package/src/lib/tracking.ts +14 -1
  12. package/src/lib/types/injection.d.ts +3 -0
  13. package/src/routes/+layout.svelte +6 -2
  14. package/src/routes/+page.svelte +17 -6
  15. package/src/routes/components/Editorial/AIIndicator.svelte +12 -4
  16. package/src/routes/components/Editorial/Alert.svelte +12 -2
  17. package/src/routes/components/Editorial/Editor.svelte +47 -20
  18. package/src/routes/components/Editorial/EditorItem.svelte +32 -7
  19. package/src/routes/components/Editorial/PlaylinkTypeSelect.svelte +14 -0
  20. package/src/routes/components/Editorial/ResizeHandle.svelte +1 -1
  21. package/src/routes/components/Editorial/Search/TitleSearchItem.svelte +6 -4
  22. package/src/routes/components/Icons/IconWarning.svelte +5 -0
  23. package/src/routes/components/TitlePopover.svelte +1 -1
  24. package/src/tests/lib/auth.test.js +31 -1
  25. package/src/tests/lib/linkInjection.test.js +79 -48
  26. package/src/tests/lib/tracking.test.js +61 -1
  27. package/src/tests/routes/+page.test.js +21 -4
  28. package/src/tests/routes/components/Editorial/AiIndicator.test.js +12 -5
  29. package/src/tests/routes/components/Editorial/Alert.test.js +10 -3
  30. package/src/tests/routes/components/Editorial/Editor.test.js +15 -0
  31. package/src/tests/routes/components/Editorial/EditorItem.test.js +32 -7
  32. package/src/tests/routes/components/Editorial/PlaylinkTypeSelect.test.js +13 -1
package/package.json CHANGED
@@ -1,6 +1,6 @@
1
1
  {
2
2
  "name": "@playpilot/tpi",
3
- "version": "3.1.0",
3
+ "version": "3.2.0-beta.1",
4
4
  "type": "module",
5
5
  "scripts": {
6
6
  "dev": "vite dev",
package/src/lib/api.ts CHANGED
@@ -14,11 +14,10 @@ let pollTimeout: ReturnType<typeof setTimeout> | null = null
14
14
  * @param url URL of the given article
15
15
  * @param html HTML to be crawled
16
16
  * @param options
17
- * @param [options.automation] Enable automation, disable when inserting into editorial
18
17
  * @param [options.hash] unique key to identify the HTML
19
18
  * @param [options.params] Any rest params to include in the request body
20
19
  */
21
- export async function fetchLinkInjections(url: string, html: string, { hash = stringToHash(html), params = {} }: { automation?: boolean; hash?: string; params?: object } = {}): Promise<LinkInjectionResponse> {
20
+ export async function fetchLinkInjections(url: string, html: string, { hash = stringToHash(html), params = {} }: { hash?: string; params?: object } = {}): Promise<LinkInjectionResponse> {
22
21
  const headers = new Headers({ 'Content-Type': 'application/json' })
23
22
  const apiToken = getApiToken()
24
23
  const isEditorialMode = isEditorialModeEnabled() ? await authorize() : false
@@ -113,7 +112,6 @@ export async function saveLinkInjections(linkInjections: LinkInjection[], html:
113
112
  const response = await fetchLinkInjections(getFullUrlPath(), html, {
114
113
  params: {
115
114
  private_token: getAuthToken(),
116
- automation_enabled: false,
117
115
  link_injections: newLinkInjections,
118
116
  text_selector: selector,
119
117
  },
package/src/lib/auth.ts CHANGED
@@ -1,5 +1,7 @@
1
1
  import { getApiToken } from './api'
2
2
  import { apiBaseUrl } from './constants'
3
+ import { TrackingEvent } from './enums/TrackingEvent'
4
+ import { track } from './tracking'
3
5
 
4
6
  const cookieName = 'EncryptedToken'
5
7
  const urlParam = 'articleReplacementEditToken'
@@ -32,8 +34,11 @@ export async function authorize(href: string = window.location.href): Promise<bo
32
34
  setAuthCookie(authToken)
33
35
 
34
36
  return true
35
- } catch(error) {
37
+ } catch(error: any) {
36
38
  console.error(error)
39
+ track(TrackingEvent.AuthFailed)
40
+ } finally {
41
+ removeAuthParamFromUrl()
37
42
  }
38
43
 
39
44
  return false
@@ -84,3 +89,10 @@ export function isEditorialModeEnabled(): boolean {
84
89
  const windowToken = window?.PlayPilotLinkInjections?.editorial_token
85
90
  return new URLSearchParams(window.location.search).get('playpilot-editorial-mode') === 'true' || !!windowToken
86
91
  }
92
+
93
+ export function removeAuthParamFromUrl(): void {
94
+ const url = new URL(window.location.href)
95
+ url.searchParams.delete(urlParam)
96
+
97
+ window.history.replaceState({}, '', url)
98
+ }
@@ -1,2 +1,4 @@
1
1
  export const playPilotBaseUrl = 'https://www.playpilot.com'
2
2
  export const apiBaseUrl = 'https://partner-api.playpilot.tech/1.0.0'
3
+
4
+ export const imagePlaceholderDataUrl = 'data:image/png;base64,iVBORw0KGgoAAAANSUhEUgAAAFQAAAB+CAMAAACNgsajAAAAb1BMVEU1Q2fI1N5YZoNVYoFndI9qd5JBT3FFU3S8yNS3xNC4xNFBTnDG0t2lscJJV3e9ytaptcWToLOOm6/BzdiZprh3hJ1aaIWJlatte5VPXHx7iJ9zgZlfbYk3RWmNmq5jcY1XZIO2wtCjsMB+i6I9S25IprTeAAABqUlEQVRo3u3W2ZKCMBCF4T5ExRmRVXBfZnn/Z5wKiU5pz1AJ7ZXV/x03HxVCB0jTNE3TNE3TNO0l2rbPNxcFdk8334AINTUD5eSaWbPoQs0qw0CVN98BzNNgE4NVv+ZHsJliuNVt7UgotATAafp/5mZiG4waAB0N5k0kUeg0wMykKDfLvRTl5pxyCFFupjQVo9ykiRTlphzl5nNQNu8C9Hv2lylDT0W2NMyUoeXdLC68KUNLuM7O9HskQ0uLLAEUR2aOQjfORE5LzHP2PMehxpl2k6otM8eh2aQGkBlieyRBYbs3y5Rk6IPZn8mT65XJR6LcROGErwaoxqLm4XvkiTVsy1FoYe5n06zcjazp1Wk0umHz3k9BT6+bXjXR6PnRtKpr755PfRjx4WPz7tXW/26gGYnOvOmrM7xtiK4qYtDOrpGZtnR7JFcSi+Z1XZt7k5d4NCZmcrWxqMzkbRgqN+nAULHpx1RiylFvftJwlxjUz2bWdpPB7NnSxZih5RFrD20Vai4izGOgeenPukMSUE6hte5denI7NMyU1xrSNE3TNE3TNE17hX4ADHsS0j0OCOoAAAAASUVORK5CYII='
@@ -17,4 +17,5 @@ export const TrackingEvent = Object.freeze({
17
17
  InjectionFailed: 'ali_injection_failed',
18
18
  TotalInjectionsCount: 'ali_injection_count',
19
19
  FetchingConfigFailed: 'ali_fetch_config_failed',
20
+ AuthFailed: 'ali_auth_failed',
20
21
  })
@@ -97,14 +97,15 @@ export function injectLinksInDocument(elements: HTMLElement[], onclick: (LinkInj
97
97
 
98
98
  // Find injection in text content of all elements together, ignore potential HTML elements.
99
99
  // This is to filter out injections that can't be injected anyway.
100
- const fullText = elements.map(element => element.innerText).join(' ')
100
+ const fullText = cleanPhrase(elements.map(element => element.innerText).join(' '))
101
101
 
102
102
  const validInjections = filterInvalidInTextInjections(mergedInjections)
103
103
  const foundInjections = validInjections.filter(i => {
104
- return cleanPhrase(fullText).includes(cleanPhrase(i.sentence))
104
+ return fullText.includes(cleanPhrase(i.sentence))
105
105
  })
106
106
 
107
107
  const ranges: LinkInjectionRanges = {}
108
+ const failedMessages: Record<string, string> = {}
108
109
 
109
110
  for (const injection of foundInjections) {
110
111
  const elementIndex = elements.findIndex(element => cleanPhrase(element.innerText).includes(cleanPhrase(injection.sentence)))
@@ -113,8 +114,18 @@ export function injectLinksInDocument(elements: HTMLElement[], onclick: (LinkInj
113
114
  if (!element) continue
114
115
 
115
116
  const nodeContainingText = findTextNodeContaining(injection.title, element, ['A'])
117
+
116
118
  // Ignore if the found injection has no node or if it is inside a link.
117
- if (!nodeContainingText || isNodeInLink(nodeContainingText)) continue
119
+ if (!nodeContainingText?.nodeValue || isNodeInLink(nodeContainingText)) {
120
+ // We check once more where the text was found, this time without ignoring links
121
+ // so we can determine if the failure was due to it being in a link
122
+ const linkNodeContainingText = findTextNodeContaining(injection.title, element)
123
+ if (linkNodeContainingText && isNodeInLink(linkNodeContainingText)) {
124
+ failedMessages[injection.key] = 'Given text is already inside of a link.'
125
+ }
126
+
127
+ continue
128
+ }
118
129
 
119
130
  // Create a wrapper in which the link will be placed. This wrapper exists as a parent for the popover
120
131
  // so that it is not directly inside of the link.
@@ -131,9 +142,15 @@ export function injectLinksInDocument(elements: HTMLElement[], onclick: (LinkInj
131
142
  linkWrapperElement.insertAdjacentElement('beforeend', linkElement)
132
143
 
133
144
  // Replace starting from the end of the previously injected link, making sure injections with the same or overlapping
134
- // phrases can still be injected.
145
+ // phrases can still be injected. Additionally we check the index of where the node value starts. This can differentiate from
146
+ // the actual start if it was partially matched with manual injections. If that martial match contains a phrase that also
147
+ // occurs in the full sentence before it, it would incorrectly match against that. If that happens to be a link already,
148
+ // it would break that link with newly inserted HTML.
135
149
  const startingIndex = getLargestValueInArray(Object.values(ranges).filter(r => r.elementIndex === elementIndex).map(r => r.to))
136
- element.innerHTML = replaceStartingFrom(element.innerHTML, injection.title, linkWrapperElement.outerHTML, startingIndex)
150
+ const valueIndex = element.innerHTML.indexOf(nodeContainingText.nodeValue)
151
+ const highestIndex = Math.max(startingIndex, valueIndex, 0)
152
+
153
+ element.innerHTML = replaceStartingFrom(element.innerHTML, injection.title, linkWrapperElement.outerHTML, highestIndex)
137
154
 
138
155
  const from = element.innerHTML.indexOf(linkWrapperElement.outerHTML)
139
156
 
@@ -150,18 +167,23 @@ export function injectLinksInDocument(elements: HTMLElement[], onclick: (LinkInj
150
167
  const afterArticleInjections = filterInvalidAfterArticleInjections(mergedInjections)
151
168
  if (afterArticleInjections.length) insertAfterArticlePlaylinks(elements, afterArticleInjections, onclick)
152
169
 
153
- const sortedInjections = sortLinkInjectionsByRange(mergedInjections, ranges)
154
-
155
- return sortedInjections.filter(i => i.title_details).map((injection, index) => {
156
- const failed = !injection.inactive && !injection.after_article && !document.querySelector(`[${keyDataAttribute}="${injection.key}"]`)
170
+ return mergedInjections.filter(i => i.title_details).map((injection, index) => {
157
171
  // Favour manual injections over AI injections
158
172
  const duplicate = injection.duplicate ?? (!injection.manual && isAvailableAsManualInjection(injection, index, mergedInjections))
159
173
 
174
+ const matchingElement = document.querySelector(`[${keyDataAttribute}="${injection.key}"]`)
175
+ const failed = isValidPlaylinkType(injection) && !injection.inactive && !injection.after_article && !matchingElement
176
+ const failedMessage =
177
+ !failed ? '' :
178
+ failedMessages[injection.key] ||
179
+ (!fullText.includes(cleanPhrase(injection.sentence)) ? 'Given sentence was not found in the article.' : 'The link failed to inject for unknown reasons.')
180
+
160
181
  return {
161
182
  ...injection,
162
183
  inactive: injection.inactive ?? false,
163
184
  duplicate,
164
185
  failed,
186
+ failed_message: failedMessage
165
187
  }
166
188
  })
167
189
  }
@@ -325,32 +347,11 @@ export function clearLinkInjection(key: string): void {
325
347
  if (element) element.outerHTML = element.textContent || ''
326
348
  }
327
349
 
328
- /**
329
- * Sort injections by where they were inserted. First by their element index, second by where in the element the
330
- * injection was injected. Injections without range (after article injections or failed injection) go last.
331
- */
332
- export function sortLinkInjectionsByRange(injections: LinkInjection[], ranges: LinkInjectionRanges): LinkInjection[] {
333
- return injections.sort((a, b) => {
334
- const rangeA = ranges[a.key]
335
- const rangeB = ranges[b.key]
336
-
337
- if (!rangeA && rangeB) return 1
338
- if (rangeA && !rangeB) return -1
339
- if (!rangeA && !rangeB) return 0
340
-
341
- if (rangeA?.elementIndex !== rangeB?.elementIndex) {
342
- return (rangeA?.elementIndex || 0) - (rangeB?.elementIndex || 0)
343
- }
344
-
345
- return (rangeA?.from || 0) - (rangeB?.from || 0)
346
- })
347
- }
348
-
349
350
  /**
350
351
  * Merge different injection types
351
352
  */
352
353
  export function mergeInjectionTypes({ aiInjections, manualInjections }: LinkInjectionTypes): LinkInjection[] {
353
- return [...aiInjections, ...manualInjections.map(i => ({ ...i, manual: true }))]
354
+ return [...manualInjections.map(i => ({ ...i, manual: true })), ...aiInjections]
354
355
  }
355
356
 
356
357
  /**
@@ -367,7 +368,16 @@ export function separateLinkInjectionTypes(injections: LinkInjection[]): LinkInj
367
368
  * Returns whether or not an injection would be valid for any sort of injection, text or after_article
368
369
  */
369
370
  export function isValidInjection(injection: LinkInjection): boolean {
370
- return !injection.inactive && !injection.removed && !injection.duplicate && !!injection.title_details
371
+ return !injection.inactive && !injection.removed && !injection.duplicate && !!injection.title_details && isValidPlaylinkType(injection)
372
+ }
373
+
374
+ /**
375
+ * An injection can have be of various playlink types, when all are false equivalent, the link is not valid.
376
+ * It should be treated similar to an inactive playlink in this case.
377
+ */
378
+ export function isValidPlaylinkType(injection: LinkInjection): boolean {
379
+ if (injection.in_text || injection.in_text === undefined) return true
380
+ return !!injection.after_article
371
381
  }
372
382
 
373
383
  /**
@@ -2,13 +2,13 @@
2
2
 
3
3
  [data-playpilot-injection-key] {
4
4
  position: relative;
5
+ }
5
6
 
6
- &.injection-highlight {
7
- outline: margin(0.25) solid var(--playpilot-primary) !important;
8
- outline-offset: margin(0.5) !important;
9
- border-radius: margin(0.05);
10
- scroll-margin: margin(5);
11
- }
7
+ .playpilot-injection-highlight {
8
+ outline: margin(0.25) solid var(--playpilot-primary) !important;
9
+ outline-offset: margin(0.5) !important;
10
+ border-radius: margin(0.05);
11
+ scroll-margin: margin(5);
12
12
  }
13
13
 
14
14
  .playpilot-styled-scrollbar {
@@ -19,4 +19,6 @@
19
19
  #{margin(0.1) margin(0.1) margin(0.75)} rgba(0, 0, 0, 0.25);
20
20
  --playpilot-error: #ea5a5a;
21
21
  --playpilot-error-dark: #442533;
22
+ --playpilot-warning: #f7c74e;
23
+ --playpilot-warning-dark: #413c23;
22
24
  }
@@ -0,0 +1,4 @@
1
+ import { writable, type Writable } from "svelte/store"
2
+
3
+ export const currentOrganizationSid: Writable<string | null> = writable(null)
4
+ export const currentDomainSid: Writable<string | null> = writable(null)
@@ -1,3 +1,5 @@
1
+ import { get } from "svelte/store"
2
+ import { currentDomainSid, currentOrganizationSid } from "./stores/organization"
1
3
  import type { TitleData } from "./types/title"
2
4
 
3
5
  const baseUrl = 'https://insights.playpilot.net'
@@ -9,7 +11,7 @@ const baseUrl = 'https://insights.playpilot.net'
9
11
  * @param [title] Title related to the event
10
12
  * @param [payload] Any data that will be included with the event
11
13
  */
12
- export async function track(event: string, title: TitleData | null = null, payload: Record<string, string | number> = {}): Promise<void> {
14
+ export async function track(event: string, title: TitleData | null = null, payload: Record<string, string | number | null> = {}): Promise<void> {
13
15
  const headers = new Headers({ 'Content-Type': 'application/json' })
14
16
 
15
17
  if (title) {
@@ -21,6 +23,8 @@ export async function track(event: string, title: TitleData | null = null, paylo
21
23
  }
22
24
 
23
25
  payload.url = window.location.href
26
+ payload.organization_sid = get(currentOrganizationSid)
27
+ payload.domain_sid = get(currentDomainSid)
24
28
 
25
29
  fetch(baseUrl, {
26
30
  headers,
@@ -32,3 +36,12 @@ export async function track(event: string, title: TitleData | null = null, paylo
32
36
  })),
33
37
  })
34
38
  }
39
+
40
+ /**
41
+ * Set the sid of the organization and domain to a store..
42
+ * These are saved for tracking purposes and currently serve no other function.
43
+ */
44
+ export function setTrackingSids({ organizationSid = null, domainSid = null }: { organizationSid?: string | null, domainSid?: string | null }): void {
45
+ currentOrganizationSid.set(organizationSid || null)
46
+ currentDomainSid.set(domainSid || null)
47
+ }
@@ -9,6 +9,7 @@ export type LinkInjection = {
9
9
  title_details?: TitleData
10
10
  inactive?: boolean
11
11
  failed?: boolean
12
+ failed_message?: string
12
13
  in_text?: boolean
13
14
  after_article?: boolean
14
15
  after_article_style?: 'modal_button' | 'playlinks' | null
@@ -27,6 +28,8 @@ export type LinkInjectionResponse = {
27
28
  ai_running: boolean
28
29
  ai_injections: LinkInjection[] | null
29
30
  link_injections: LinkInjection[] | null
31
+ organization_sid?: string
32
+ domain_sid?: string
30
33
  }
31
34
 
32
35
  export type LinkInjectionTypes = {
@@ -27,12 +27,16 @@
27
27
  <h1 use:noClass>Some heading</h1>
28
28
  <time datetime="14:00">1 hour ago</time>
29
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>
30
- <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>
30
+ <p use:noClass>In an interview with Epire &amp; Magazine, Quan reveals he quested starring in Love Hurts, which sees him Love Hurts 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>
31
31
  <p use:noClass><strong use:noClass>Jason Momoa</strong> (”Aquaman”), <strong use:noClass>Jack Black</strong> (”Nacho Libre”) och <strong use:noClass>Jennifer Coolidge</strong> (”The White Lotus”) medverkar i den <strong use:noClass>Jared Hess</strong>-regisserade (”Napolen Dynamite”) filmen. Filmen följer fyra utbölingar som via en magisk portal sugs in i en värld där allt är kubformat. För att komma hem igen måste de övervinna den färgstarka världen.</p>
32
32
 
33
+ <p use:noClass>
34
+ Following their post-credits scene in <a use:noClass href="/">John Wick</a>, in a new John Wick spinoff.
35
+ </p>
36
+
33
37
  <ul use:noClass>
34
38
  <li use:noClass><strong use:noClass>Winner:</strong> The Zone of Interest</li>
35
- <li use:noClass>Oppenheimer</li>
39
+ <li use:noClass>Oppenheimer and Oppenheimer and Oppenheimer, and Oppenheimer</li>
36
40
  <li use:noClass>Past Lives</li>
37
41
  <li use:noClass>Anatomy of a Fall</li>
38
42
  <li use:noClass>Killers of the Flower Moon</li>
@@ -2,7 +2,7 @@
2
2
  import { onMount } from 'svelte'
3
3
  import { fetchConfig, pollLinkInjections } from '$lib/api'
4
4
  import { clearLinkInjections, getLinkInjectionElements, getLinkInjectionsParentElement, injectLinksInDocument, separateLinkInjectionTypes } from '$lib/linkInjection'
5
- import { track } from '$lib/tracking'
5
+ import { setTrackingSids, track } from '$lib/tracking'
6
6
  import { getFullUrlPath } from '$lib/url'
7
7
  import { isCrawler } from '$lib/crawler'
8
8
  import { TrackingEvent } from '$lib/enums/TrackingEvent'
@@ -36,9 +36,10 @@
36
36
  onMount(() => {
37
37
  if (isCrawler()) return
38
38
 
39
- track(TrackingEvent.ArticlePageView)
40
-
41
- initialize()
39
+ (async () => {
40
+ await initialize()
41
+ track(TrackingEvent.ArticlePageView)
42
+ })()
42
43
 
43
44
  return () => clearLinkInjections()
44
45
  })
@@ -68,7 +69,10 @@
68
69
  // [TODO] TEMP: Only try once for editorial as well
69
70
  response = await pollLinkInjections(url, htmlString, { maxTries: 1 })
70
71
 
71
- inject({ aiInjections, manualInjections })
72
+ if (response) {
73
+ setTrackingSids({ organizationSid: response.organization_sid, domainSid: response.domain_sid })
74
+ inject({ aiInjections, manualInjections })
75
+ }
72
76
 
73
77
  loading = false
74
78
 
@@ -119,7 +123,14 @@
119
123
  {/if}
120
124
 
121
125
  {#if isEditorialMode && authorized}
122
- <Editor bind:linkInjections bind:this={editor} {htmlString} {loading} aiRunning={response?.ai_running && response?.automation_enabled} />
126
+ <Editor
127
+ bind:linkInjections
128
+ bind:this={editor}
129
+ {htmlString}
130
+ {loading}
131
+ aiRunning={response?.ai_running && response?.automation_enabled}
132
+ automationEnabled={response?.automation_enabled}
133
+ injectionsEnabled={response?.injections_enabled} />
123
134
  {/if}
124
135
 
125
136
  {#if activeInjection && activeInjection.title_details}
@@ -6,16 +6,17 @@
6
6
  // eslint-disable-next-line no-unused-vars
7
7
  onadd: (injections: LinkInjection[]) => void
8
8
  /** Used to guesstimate the load times. */
9
- htmlString?: string
9
+ htmlString?: string,
10
+ automationEnabled?: boolean,
10
11
  }
11
12
 
12
- const { onadd, htmlString = '' }: Props = $props()
13
+ const { onadd, htmlString = '', automationEnabled = false }: Props = $props()
13
14
 
14
15
  // Guesstimate AI load times based on the given text length.
15
16
  // The value will always be between 1 and 10 minutes.
16
17
  const fakeLoadTimes = $derived(Math.min(Math.max(htmlString.length * 30, 60000), 600000))
17
18
 
18
- let running = $state(true)
19
+ let running = $state(automationEnabled)
19
20
  let injectionsToBeInserted: LinkInjection[] = $state([])
20
21
  let dismissed = $state(false)
21
22
 
@@ -38,7 +39,9 @@
38
39
  </div>
39
40
 
40
41
  <div>
41
- {#if running}
42
+ {#if !automationEnabled}
43
+ <strong>AI processing is disabled.</strong> Enable AI from the <a href="https://partner.playpilot.net">Partner Portal</a>
44
+ {:else if running}
42
45
  AI links are currently processing. This can take several minutes.<br>
43
46
  You can add manual links while this is ongoing.
44
47
  {:else if injectionsToBeInserted?.length}
@@ -70,6 +73,10 @@
70
73
  {/if}
71
74
 
72
75
  <style lang="scss">
76
+ a {
77
+ color: currentColor;
78
+ }
79
+
73
80
  .ai-indicator {
74
81
  position: relative;
75
82
  margin: 0 margin(0.5);
@@ -139,6 +146,7 @@
139
146
  background: transparent;
140
147
  color: var(--playpilot-green);
141
148
  font-family: var(--playpilot-font-family);
149
+ line-height: 1.5;
142
150
  cursor: pointer;
143
151
 
144
152
  &:hover {
@@ -2,13 +2,14 @@
2
2
  import type { Snippet } from 'svelte'
3
3
 
4
4
  interface Props {
5
+ type?: 'error' | 'warning'
5
6
  children: Snippet
6
7
  }
7
8
 
8
- const { children }: Props = $props()
9
+ const { type = 'error', children }: Props = $props()
9
10
  </script>
10
11
 
11
- <div class="alert">
12
+ <div class="alert {type}">
12
13
  {@render children()}
13
14
  </div>
14
15
 
@@ -19,5 +20,14 @@
19
20
  border: 1px solid var(--playpilot-error);
20
21
  background: var(--playpilot-error-dark);
21
22
  font-size: margin(0.75);
23
+
24
+ &.warning {
25
+ border-color: var(--playpilot-warning);
26
+ background: var(--playpilot-warning-dark);
27
+ }
28
+
29
+ :global(a) {
30
+ color: currentColor;
31
+ }
22
32
  }
23
33
  </style>
@@ -19,10 +19,19 @@
19
19
  linkInjections: LinkInjection[],
20
20
  htmlString?: string,
21
21
  loading?: boolean,
22
- aiRunning?: boolean
22
+ aiRunning?: boolean,
23
+ injectionsEnabled?: boolean,
24
+ automationEnabled?: boolean,
23
25
  }
24
26
 
25
- let { linkInjections = $bindable(), htmlString = '', loading = false, aiRunning = false }: Props = $props()
27
+ let {
28
+ linkInjections = $bindable(),
29
+ htmlString = '',
30
+ loading = false,
31
+ aiRunning = false,
32
+ injectionsEnabled = false,
33
+ automationEnabled = false,
34
+ }: Props = $props()
26
35
 
27
36
  const editorPositionKey = 'editor-position'
28
37
  const editorHeightKey = 'editor-height'
@@ -38,7 +47,7 @@
38
47
  let aIIndicator = $state()
39
48
 
40
49
  const linkInjectionsString = $derived(JSON.stringify(linkInjections))
41
- const hasChanged = $derived(initialStateString !== linkInjectionsString)
50
+ const hasChanged = $derived(initialStateString && initialStateString !== linkInjectionsString)
42
51
  // Filter out injections without title_details, injections that are removed, duplicate, or are AI injections that failed to inject
43
52
  const filteredInjections = $derived(linkInjections.filter((i) => i.title_details && !i.removed && !i.duplicate && (i.manual || (!i.manual && !i.failed))))
44
53
  const sortedInjections = $derived(sortInjections(filteredInjections))
@@ -47,7 +56,7 @@
47
56
  if (loading) return
48
57
 
49
58
  untrack(() => {
50
- initialStateString = linkInjectionsString
59
+ requestAnimationFrame(() => initialStateString = linkInjectionsString)
51
60
  trackInjectionsCount()
52
61
  })
53
62
  })
@@ -137,19 +146,19 @@
137
146
  </script>
138
147
 
139
148
  <section class="editor playpilot-styled-scrollbar" class:panel-open={manualInjectionActive} class:loading bind:this={editorElement} {onscroll}>
140
- <header class="header">
141
- {#if editorElement}
142
- {#if !loading}
143
- <div class="handle">
144
- <ResizeHandle element={editorElement} {height} onchange={(height) => saveLocalStorage(editorHeightKey, JSON.stringify(height))} />
145
- </div>
146
- {/if}
149
+ {#if editorElement && !loading}
150
+ <div class="handles">
151
+ <div class="handle">
152
+ <ResizeHandle element={editorElement} {height} onchange={(height) => saveLocalStorage(editorHeightKey, JSON.stringify(height))} />
153
+ </div>
147
154
 
148
155
  <div class="handle">
149
156
  <DragHandle element={editorElement} {position} limit={{ x: 16, y: 16 }} onchange={(position) => saveLocalStorage(editorPositionKey, JSON.stringify(position))} />
150
157
  </div>
151
- {/if}
158
+ </div>
159
+ {/if}
152
160
 
161
+ <header class="header">
153
162
  <h1>Playlinks</h1>
154
163
 
155
164
  {#if loading}
@@ -165,11 +174,20 @@
165
174
  {/if}
166
175
  </header>
167
176
 
168
- {#if !loading && aiRunning}
169
- <AIIndicator {htmlString} bind:this={aIIndicator} onadd={(newInjections) => newInjections.forEach(i => linkInjections.push(i))} />
170
- {/if}
171
-
172
177
  {#if !loading}
178
+ {#if !injectionsEnabled}
179
+ <div class="alert">
180
+ <Alert type="warning">
181
+ <strong>Playlinks are currently not published.</strong> Visitors to this page will not see any of the injected links.
182
+ Publish playlinks from the <a href="https://partner.playpilot.net">Partner Portal</a>
183
+ </Alert>
184
+ </div>
185
+ {/if}
186
+
187
+ {#if aiRunning || !automationEnabled}
188
+ <AIIndicator {htmlString} bind:this={aIIndicator} {automationEnabled} onadd={(newInjections) => newInjections.forEach(i => linkInjections.push(i))} />
189
+ {/if}
190
+
173
191
  {#if hasError}
174
192
  <div class="error" transition:slide|global={{ duration: 150 }}>
175
193
  <Alert>Something went wrong, check your links below.</Alert>
@@ -225,7 +243,7 @@
225
243
  right: margin(1);
226
244
  width: 100%;
227
245
  max-width: margin(22);
228
- height: min(50vh, margin(50));
246
+ height: min(70vh, margin(40));
229
247
  min-height: 10rem;
230
248
  margin: 0;
231
249
  padding: margin(1);
@@ -254,6 +272,13 @@
254
272
  font-size: margin(0.85);
255
273
  }
256
274
 
275
+ .handles {
276
+ z-index: 20;
277
+ position: sticky;
278
+ top: margin(-1);
279
+ margin: margin(-1) margin(-1) 0;
280
+ }
281
+
257
282
  .handle {
258
283
  opacity: 0;
259
284
  transition: opacity 150ms;
@@ -264,10 +289,8 @@
264
289
  }
265
290
 
266
291
  .header {
292
+ @extend .handles;
267
293
  z-index: 5;
268
- position: sticky;
269
- top: margin(-1);
270
- margin: margin(-1) margin(-1) 0;
271
294
  padding: margin(1) margin(1) margin(1) margin(1.5);
272
295
  border: 0;
273
296
  background: var(--playpilot-dark);
@@ -340,6 +363,10 @@
340
363
  margin-top: margin(0.5);
341
364
  }
342
365
 
366
+ .alert {
367
+ margin: 0 margin(0.5) margin(0.5);
368
+ }
369
+
343
370
  .panel {
344
371
  z-index: 10;
345
372
  position: absolute;