@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,90 @@
1
+ <script>
2
+ import { TrackingEvent } from '$lib/enums/TrackingEvent'
3
+ import { track } from '$lib/tracking'
4
+
5
+ /** @type {{ linkInjections: LinkInjection[], onclickmodal?: (linkInjection: LinkInjection) => void }} */
6
+ // eslint-disable-next-line no-unused-vars
7
+ const { linkInjections, onclickmodal = (linkInjection) => null } = $props()
8
+
9
+ /**
10
+ * @param {TitleData} title The full title data of the clicked playlink
11
+ * @param {string} playlink Name of the clicked playlink
12
+ */
13
+ function onclick(title, playlink) {
14
+ track(TrackingEvent.AfterArticlePlaylinkClick, title, { playlink })
15
+ }
16
+
17
+ /**
18
+ * Open a modal for the given injection and track the click
19
+ * @param {TitleData} title The full title data of the title the modal button was clicked for
20
+ * @param {LinkInjection} linkInjection
21
+ */
22
+ function openModal(title, linkInjection) {
23
+ track(TrackingEvent.AfterArticleModalButtonClick, title)
24
+ onclickmodal(linkInjection)
25
+ }
26
+ </script>
27
+
28
+ {#if linkInjections.length}
29
+ <p>
30
+ {#each linkInjections as linkInjection}
31
+ {@const { key, title, title_details, after_article_style } = linkInjection}
32
+ {@const playlinks = title_details?.providers || []}
33
+
34
+ <span data-playpilot-injection-key={key}>
35
+ {#if playlinks.length}
36
+ {#if after_article_style === 'modal_button'}
37
+ "{title}" is available to stream.
38
+
39
+ <span>
40
+ <button onclick={() => openModal(/** @type {TitleData} */ (title_details), linkInjection)}>
41
+ View streaming options
42
+ </button>
43
+ </span>
44
+ {:else}
45
+ "{title}" is available to stream on
46
+ {#each playlinks as { name, url }, i}
47
+ {#if i}
48
+ {i === playlinks.length - 1 ? ', and' : ','}
49
+ {/if}
50
+
51
+ <a onclick={() => onclick(/** @type {TitleData} */ (title_details), name)} href={url} target="_blank">
52
+ {name}
53
+ </a>
54
+ {/each}.
55
+ {/if}
56
+ <br>
57
+ {:else}
58
+ "{title}" is not currently available to stream. <br>
59
+ {/if}
60
+ </span>
61
+ {/each}
62
+ </p>
63
+ {/if}
64
+
65
+ <style>
66
+ span {
67
+ display: block;
68
+ }
69
+
70
+ button {
71
+ appearance: none;
72
+ padding: var(--playpilot-after-article-button-padding, 0.25rem 0.75rem);
73
+ margin: var(--playpilot-after-article-button-margin, 0.25rem 0 0);
74
+ background: var(--playpilot-after-article-button-background, transparent);
75
+ border: var(--playpilot-after-article-button-border, 2px solid currentColor);
76
+ border-radius: var(--playpilot-after-article-button-border-radius, 1rem);
77
+ color: var(--playpilot-after-article-button-text-color, inherit);
78
+ font-weight: var(--playpilot-after-article-button-font-weight, bold);
79
+ font-size: var(--playpilot-after-article-button-font-size, 1rem);
80
+ cursor: pointer;
81
+ transition: all 50ms;
82
+ }
83
+
84
+ button:hover {
85
+ background: var(--playpilot-after-article-button-background-hover, transparent);
86
+ border: var(--playpilot-after-article-button-border-hover, 2px solid currentColor);
87
+ color: var(--playpilot-after-article-button-text-color-hover, inherit);
88
+ transform: var(--playpilot-after-article-button-transform-hover, scale(1.02));
89
+ }
90
+ </style>
@@ -0,0 +1,67 @@
1
+ <script>
2
+ import { fly } from 'svelte/transition'
3
+ import IconDots from './Icons/IconDots.svelte'
4
+
5
+ /** @type {{ ariaLabel: string, children: import('svelte').Snippet }} */
6
+ const { ariaLabel, children } = $props()
7
+
8
+ let active = $state(false)
9
+ /** @type {HTMLElement | null} */
10
+ let buttonElement = $state(null)
11
+
12
+ /**
13
+ * Close the context menu when clicking anything but the toggle button.
14
+ * @param {MouseEvent} event
15
+ */
16
+ function closeOnOutsideClick(event) {
17
+ const target = /** @type {HTMLElement} */ (event.target)
18
+
19
+ if (target === buttonElement || buttonElement?.contains(target)) return
20
+
21
+ active = false
22
+ }
23
+ </script>
24
+
25
+ <svelte:window onclick={closeOnOutsideClick} />
26
+
27
+ <div class="context-menu">
28
+ <button aria-label={ariaLabel} aria-expanded={active} onclick={() => active = !active} bind:this={buttonElement}>
29
+ <IconDots />
30
+ </button>
31
+
32
+ {#if active}
33
+ <div class="content" in:fly={{ duration: 150, y: 5 }}>
34
+ {@render children()}
35
+ </div>
36
+ {/if}
37
+ </div>
38
+
39
+ <style>
40
+ button {
41
+ appearance: none;
42
+ border: 0;
43
+ border-radius: 0.25rem;
44
+ background: transparent;
45
+ color: var(--playpilot-text-color-alt);
46
+ }
47
+
48
+ button:hover {
49
+ cursor: pointer;
50
+ color: white;
51
+ }
52
+
53
+ .context-menu {
54
+ position: relative;
55
+ }
56
+
57
+ .content {
58
+ z-index: 1;
59
+ position: absolute;
60
+ top: 100%;
61
+ right: 0;
62
+ max-height: 5rem;
63
+ max-width: 10rem;
64
+ border-radius: 0.5rem;
65
+ background: var(--playpilot-lighter);
66
+ }
67
+ </style>
@@ -0,0 +1,47 @@
1
+ <script>
2
+ /** @type {{ text: string, blurb?: string, limit?: number }} */
3
+ const { text, blurb = '', limit = 200 } = $props()
4
+
5
+ let expanded = $state(false)
6
+ let limitedText = $derived(text.slice(0, limit))
7
+ </script>
8
+
9
+ <div>
10
+ <span class="paragraph">
11
+ {expanded ? text : limitedText}{#if !expanded && text.length > limit}...{/if}
12
+
13
+ {#if !expanded && (text.length > limit || blurb)}
14
+ <button class="show-more" onclick={() => expanded = true}>Show more</button>
15
+ {/if}
16
+ </span>
17
+
18
+ {#if expanded}
19
+ <p>{blurb}</p>
20
+ {/if}
21
+ </div>
22
+
23
+ <style>
24
+ p,
25
+ .paragraph {
26
+ display: block;
27
+ margin: 1rem 0 0;
28
+ }
29
+
30
+ .show-more {
31
+ display: inline;
32
+ appearance: none;
33
+ padding: 0;
34
+ background: transparent;
35
+ border: 0;
36
+ color: var(--playpilot-read-more-text-color, var(--playpilot-text-color-alt));
37
+ font-weight: var(--playpilot-read-more-font-weight, inherit);
38
+ font-family: inherit;
39
+ font-size: inherit;
40
+ }
41
+
42
+ .show-more:hover {
43
+ text-decoration: underline;
44
+ text-decoration-thickness: 2px;
45
+ cursor: pointer;
46
+ }
47
+ </style>
@@ -0,0 +1,18 @@
1
+ <script>
2
+ /** @type {{ children: import('svelte').Snippet }} */
3
+ const { children } = $props()
4
+ </script>
5
+
6
+ <div class="alert">
7
+ {@render children()}
8
+ </div>
9
+
10
+ <style>
11
+ .alert {
12
+ padding: 0.5rem;
13
+ border-radius: 0.5rem;
14
+ border: 1px solid var(--playpilot-error);
15
+ background: var(--playpilot-error-dark);
16
+ font-size: 0.75rem;
17
+ }
18
+ </style>
@@ -0,0 +1,134 @@
1
+ <script>
2
+ import { onMount } from 'svelte'
3
+
4
+ /** @type {{ element: HTMLElement, position: Position, limit?: Position, onchange?: (position: Position) => void }} */
5
+ let { element, position = { x: 0, y: 0 }, limit = { x: 0, y: 0 }, onchange = () => null } = $props()
6
+
7
+ let isDragging = false
8
+
9
+ /** @type {Position} */
10
+ let dragStartPosition = { x: 0, y: 0 }
11
+ /** @type {Position} */
12
+ let elementStartPosition = position
13
+
14
+ onMount(() => {
15
+ setPosition(position.x, position.y)
16
+ })
17
+
18
+ /**
19
+ * @param {number} x
20
+ * @param {number} y
21
+ */
22
+ function setPosition(x, y) {
23
+ const windowWidth = window.innerWidth
24
+ const windowHeight = window.innerHeight
25
+ const elementWidth = element.clientWidth
26
+ const elementHeight = element.clientHeight
27
+
28
+ x = Math.min(Math.max(x, limit.x), windowWidth - elementWidth - limit.x)
29
+ y = Math.min(Math.max(y, limit.y), windowHeight - elementHeight - limit.y)
30
+
31
+ element.style.right = x + 'px'
32
+ element.style.bottom = Math.max(y, limit.y) + 'px'
33
+
34
+ position = { x, y }
35
+
36
+ onchange(position)
37
+ }
38
+
39
+ /**
40
+ * @param {MouseEvent | TouchEvent} event
41
+ * @returns {Position}
42
+ */
43
+ function getPositionFromEvent(event) {
44
+ return {
45
+ // @ts-ignore
46
+ x: event.pageX || event.touches?.[0].pageX || 0,
47
+ // @ts-ignore
48
+ y: event.pageY || event.touches?.[0].pageY || 0,
49
+ }
50
+ }
51
+
52
+ /**
53
+ * @param {MouseEvent | TouchEvent} event
54
+ */
55
+ function start(event) {
56
+ isDragging = true
57
+
58
+ dragStartPosition = getPositionFromEvent(event)
59
+ elementStartPosition = position
60
+ }
61
+
62
+ /**
63
+ * @param {MouseEvent | TouchEvent} event
64
+ */
65
+ function move(event) {
66
+ if (!isDragging) return
67
+
68
+ const dragCurrentPosition = getPositionFromEvent(event)
69
+ const differenceX = dragCurrentPosition.x - dragStartPosition.x
70
+ const differenceY = dragCurrentPosition.y - dragStartPosition.y
71
+
72
+ const newX = elementStartPosition.x - differenceX
73
+ const newY = elementStartPosition.y - differenceY
74
+
75
+ setPosition(newX, newY)
76
+ }
77
+
78
+ function end() {
79
+ if (!isDragging) return
80
+
81
+ isDragging = false
82
+ }
83
+ </script>
84
+
85
+ <svelte:window
86
+ onmousemove={move}
87
+ ontouchmove={move}
88
+ onmouseup={end}
89
+ ontouchend={end}
90
+ onresize={() => setPosition(position.x, position.y)} />
91
+
92
+ <button class="drag-handle" onmousedown={start} ontouchstart={start} aria-label="Move editor"></button>
93
+
94
+ <style>
95
+ .drag-handle {
96
+ appearance: none;
97
+ position: absolute;
98
+ top: 0;
99
+ left: 30%;
100
+ width: 40%;
101
+ height: 1rem;
102
+ background: transparent;
103
+ border: 0;
104
+ cursor: grab;
105
+ }
106
+
107
+ .drag-handle:active {
108
+ cursor: grabbing;
109
+ }
110
+
111
+ .drag-handle::before {
112
+ display: block;
113
+ content: "";
114
+ position: absolute;
115
+ top: 40%;
116
+ right: 0;
117
+ bottom: 40%;
118
+ left: 0;
119
+ border-radius: 1rem;
120
+ background: var(--playpilot-text-color);
121
+ opacity: 0.15;
122
+ transition: opacity 100ms, transform 50ms;
123
+ }
124
+
125
+ .drag-handle:hover::before {
126
+ opacity: 0.65;
127
+ transform: scale(1.05);
128
+ }
129
+
130
+ .drag-handle:active::before {
131
+ opacity: 0.9;
132
+ transform: scale(0.95);
133
+ }
134
+ </style>
@@ -0,0 +1,277 @@
1
+ <script>
2
+ import { fly, slide } from 'svelte/transition'
3
+ import EditorItem from './EditorItem.svelte'
4
+ import DragHandle from './DragHandle.svelte'
5
+ import Alert from './Alert.svelte'
6
+ import ManualInjection from './ManualInjection.svelte'
7
+ import RoundButton from '../RoundButton.svelte'
8
+ import { saveLinkInjections } from '$lib/api'
9
+
10
+ /** @type {{ linkInjections: LinkInjection[], htmlString?: string, loading?: boolean }} */
11
+ let { linkInjections = $bindable(), htmlString = '', loading = false } = $props()
12
+
13
+ const editorPositionKey = 'editor-position'
14
+
15
+ /** @type {HTMLElement | null} */
16
+ let editorElement = $state(null)
17
+ /** @type {Position} */
18
+ let position = $state(JSON.parse(localStorage.getItem(editorPositionKey) || '{ "x": 0, "y": 0 }'))
19
+ let manualInjectionActive = $state(false)
20
+ let saving = $state(false)
21
+ let hasError = $state(false)
22
+ let scrollDistance = $state(0)
23
+
24
+ /** Save the given injections in their current state and rewrite object to returned value */
25
+ async function save() {
26
+ try {
27
+ saving = true
28
+ hasError = false
29
+
30
+ linkInjections = await saveLinkInjections(linkInjections, htmlString)
31
+ } catch {
32
+ hasError = true
33
+ } finally {
34
+ saving = false
35
+ }
36
+ }
37
+
38
+ /**
39
+ * @param {Position} position
40
+ */
41
+ function savePosition(position) {
42
+ localStorage.setItem(editorPositionKey, JSON.stringify(position))
43
+ }
44
+
45
+ /**
46
+ * @param {string} key
47
+ */
48
+ function removeInjection(key) {
49
+ linkInjections = linkInjections.filter(i => i.key !== key)
50
+ }
51
+
52
+ /**
53
+ * @param {HTMLElement} itemElement
54
+ */
55
+ function scrollToItem(itemElement) {
56
+ if (!editorElement) return
57
+
58
+ const itemTop = itemElement.offsetTop
59
+ const itemBottom = itemTop + itemElement.offsetHeight
60
+ const editorElementTop = editorElement.scrollTop
61
+ const editorElementBottom = editorElementTop + editorElement.clientHeight
62
+
63
+ const isFullyVisible = itemTop >= editorElementTop && itemBottom <= editorElementBottom
64
+
65
+ if (!isFullyVisible) {
66
+ editorElement.scrollTo({
67
+ top: itemTop - editorElement.clientHeight / 2,
68
+ behavior: 'smooth',
69
+ })
70
+ }
71
+ }
72
+
73
+ /**
74
+ * @param {Event} event
75
+ */
76
+ function onscroll(event) {
77
+ const target = /** @type {HTMLElement} */ (event.target)
78
+ scrollDistance = target.scrollTop
79
+ }
80
+ </script>
81
+
82
+ <section class="editor playpilot-styled-scrollbar" class:panel-open={manualInjectionActive} class:loading bind:this={editorElement} {onscroll}>
83
+ {#if editorElement}
84
+ <div class="drag-handle">
85
+ <DragHandle element={editorElement} {position} limit={{ x: 16, y: 16 }} onchange={savePosition} />
86
+ </div>
87
+ {/if}
88
+
89
+ <header class="header">
90
+ <h1>Playlinks</h1>
91
+
92
+ {#if loading}
93
+ <div class="loading">Loading...</div>
94
+ {:else}
95
+ <div class="bubble" aria-label="{linkInjections.length} found playlinks">
96
+ {linkInjections.length}
97
+ </div>
98
+
99
+ <RoundButton onclick={() => manualInjectionActive = true} size="2rem" aria-label="Add manual injection">
100
+ +
101
+ </RoundButton>
102
+ {/if}
103
+ </header>
104
+
105
+ {#if !loading}
106
+ {#if hasError}
107
+ <div class="error" transition:slide|global={{ duration: 150 }}>
108
+ <Alert>Something went wrong, check your links below.</Alert>
109
+ </div>
110
+ {/if}
111
+
112
+ <div class="items">
113
+ {#each linkInjections as linkInjection, i (linkInjection.key)}
114
+ <div transition:slide={{ duration: 100 }}>
115
+ <EditorItem bind:linkInjection={linkInjections[i]} onremove={() => removeInjection(linkInjection.key)} onhighlight={scrollToItem} />
116
+ </div>
117
+ {:else}
118
+ <div class="empty">No link injections are present</div>
119
+ {/each}
120
+ </div>
121
+
122
+ {#if linkInjections.length}
123
+ <button class="save" disabled={saving} onclick={save}>
124
+ {saving ? 'Saving...' : 'Save links'}
125
+ </button>
126
+ {/if}
127
+ {/if}
128
+
129
+ {#if manualInjectionActive}
130
+ <div
131
+ class="panel"
132
+ style:top="{scrollDistance}px"
133
+ transition:fly={{ x: Math.min(window.innerWidth, 320), duration: 200, opacity: 1 }}>
134
+ <ManualInjection
135
+ {htmlString}
136
+ onclose={() => manualInjectionActive = false}
137
+ onsave={(linkInjection) => linkInjections.push(linkInjection)} />
138
+ </div>
139
+ {/if}
140
+ </section>
141
+
142
+ <style>
143
+ h1 {
144
+ margin: 0 0.75rem 0 0;
145
+ font-size: 1.25rem;
146
+ font-weight: normal;
147
+ }
148
+
149
+ .editor {
150
+ z-index: 1000;
151
+ display: flex;
152
+ flex-direction: column;
153
+ position: fixed;
154
+ bottom: 1rem;
155
+ right: 1rem;
156
+ width: 100%;
157
+ max-width: 20rem;
158
+ max-height: min(50vh, 50rem);
159
+ min-height: 25rem;
160
+ padding: 1rem;
161
+ border-radius: 1.5rem;
162
+ background: var(--playpilot-dark);
163
+ box-shadow: var(--playpilot-shadow-large);
164
+ color: var(--playpilot-text-color);
165
+ font-family: var(--playpilot-font-family);
166
+ transition: border-radius 100ms;
167
+ overflow-y: auto;
168
+ overflow-x: hidden;
169
+ }
170
+
171
+ .panel-open {
172
+ overflow: hidden;
173
+ }
174
+
175
+ .loading {
176
+ min-height: 0;
177
+ border-radius: 2rem;
178
+ margin-left: auto;
179
+ padding-right: 0.5rem;
180
+ color: var(--playpilot-text-color-alt);
181
+ font-size: 0.85rem;
182
+ }
183
+
184
+ .drag-handle {
185
+ opacity: 0;
186
+ transition: opacity 150ms;
187
+ }
188
+
189
+ .editor:hover .drag-handle {
190
+ opacity: 1;
191
+ }
192
+
193
+ .header {
194
+ z-index: 5;
195
+ position: sticky;
196
+ top: -1rem;
197
+ margin: -1rem -1rem 0;
198
+ padding: 1rem 1rem 1rem 1.5rem;
199
+ background: var(--playpilot-dark);
200
+ display: flex;
201
+ align-items: center;
202
+ }
203
+
204
+ .loading .header {
205
+ margin: -1rem;
206
+ }
207
+
208
+ .bubble {
209
+ appearance: none;
210
+ width: 1.5rem;
211
+ height: 1.5rem;
212
+ border: 0;
213
+ padding: 0;
214
+ margin-right: auto;
215
+ border-radius: 50%;
216
+ background: var(--playpilot-green);
217
+ line-height: 1.5rem;
218
+ text-align: center;
219
+ font-size: 0.85rem;
220
+ }
221
+
222
+ .items {
223
+ padding-top: 0.5rem;
224
+ margin-bottom: auto;
225
+ }
226
+
227
+ .empty {
228
+ padding: 0.5rem;
229
+ font-size: 0.85rem;
230
+ font-style: italic;
231
+ color: var(--playpilot-text-color-alt);
232
+ }
233
+
234
+ .save {
235
+ z-index: 1;
236
+ appearance: none;
237
+ position: sticky;
238
+ bottom: 0;
239
+ left: 0;
240
+ width: 100%;
241
+ margin-top: 1rem;
242
+ padding: 0.5rem;
243
+ border: 0;
244
+ border-radius: 2rem;
245
+ background: var(--playpilot-content);
246
+ box-shadow: 0 0 1rem 1rem var(--playpilot-dark);
247
+ transition: opacity 100ms;
248
+ font-family: inherit;
249
+ color: var(--playpilot-text-color-alt);
250
+ cursor: pointer;
251
+ }
252
+
253
+ .save:hover {
254
+ background: var(--playpilot-content-light);
255
+ color: var(--playpilot-text-color);
256
+ }
257
+
258
+ .save[disabled] {
259
+ opacity: 0.75;
260
+ }
261
+
262
+ .error {
263
+ margin-top: 0.5rem;
264
+ }
265
+
266
+ .panel {
267
+ z-index: 10;
268
+ position: absolute;
269
+ top: 0;
270
+ left: 0;
271
+ width: 100%;
272
+ height: 100%;
273
+ padding: 1.5rem;
274
+ background: var(--playpilot-dark);
275
+ overflow-y: auto;
276
+ }
277
+ </style>