@playpilot/tpi 5.34.1 → 6.0.0-beta.explore.15

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 (69) hide show
  1. package/dist/link-injections.js +25 -10
  2. package/package.json +1 -1
  3. package/src/lib/api/titles.ts +13 -1
  4. package/src/lib/data/countries.json +216 -0
  5. package/src/lib/data/translations.ts +5 -0
  6. package/src/lib/enums/SplitTest.ts +1 -6
  7. package/src/lib/explore.ts +59 -0
  8. package/src/lib/fakeData.ts +1 -0
  9. package/src/lib/images/titles-list.webp +0 -0
  10. package/src/lib/modal.ts +7 -6
  11. package/src/lib/scss/global.scss +0 -2
  12. package/src/lib/trailer.ts +22 -0
  13. package/src/lib/types/api.d.ts +6 -0
  14. package/src/lib/types/config.d.ts +12 -0
  15. package/src/lib/types/filter.d.ts +2 -0
  16. package/src/lib/types/title.d.ts +4 -1
  17. package/src/routes/+page.svelte +6 -1
  18. package/src/routes/components/Ads/TopScroll.svelte +4 -18
  19. package/src/routes/components/Button.svelte +101 -0
  20. package/src/routes/components/Debugger.svelte +25 -0
  21. package/src/routes/components/Explore/Explore.svelte +240 -0
  22. package/src/routes/components/Explore/ExploreCallToAction.svelte +58 -0
  23. package/src/routes/components/Explore/ExploreModal.svelte +15 -0
  24. package/src/routes/components/Explore/Filter/Dropdown.svelte +72 -0
  25. package/src/routes/components/Explore/Filter/Filter.svelte +99 -0
  26. package/src/routes/components/Explore/Filter/FilterItem.svelte +57 -0
  27. package/src/routes/components/Explore/Filter/FilterSorting.svelte +70 -0
  28. package/src/routes/components/Explore/Filter/Search.svelte +57 -0
  29. package/src/routes/components/Explore/Filter/TogglesWithSearch.svelte +142 -0
  30. package/src/routes/components/GridTitle.svelte +122 -0
  31. package/src/routes/components/GridTitleSkeleton.svelte +36 -0
  32. package/src/routes/components/Icons/IconArrow.svelte +10 -2
  33. package/src/routes/components/Icons/IconClose.svelte +9 -1
  34. package/src/routes/components/Icons/IconFilter.svelte +5 -0
  35. package/src/routes/components/Icons/IconPlay.svelte +3 -0
  36. package/src/routes/components/Icons/IconSearch.svelte +3 -0
  37. package/src/routes/components/ListTitle.svelte +10 -68
  38. package/src/routes/components/ListTitleSkeleton.svelte +42 -0
  39. package/src/routes/components/Modal.svelte +5 -23
  40. package/src/routes/components/Participant.svelte +0 -4
  41. package/src/routes/components/Playlinks/PlaylinkIcon.svelte +1 -1
  42. package/src/routes/components/Playlinks/PlaylinksCompact.svelte +62 -0
  43. package/src/routes/components/Share.svelte +5 -23
  44. package/src/routes/components/Title.svelte +22 -22
  45. package/src/routes/components/TitleModal.svelte +4 -1
  46. package/src/routes/components/Trailer.svelte +18 -0
  47. package/src/routes/components/YouTubeEmbedOverlay.svelte +96 -0
  48. package/src/routes/elements/+page.svelte +39 -2
  49. package/src/routes/explore/+page.svelte +60 -0
  50. package/src/tests/lib/api/ads.test.js +0 -1
  51. package/src/tests/lib/api/titles.test.js +55 -0
  52. package/src/tests/lib/explore.test.js +139 -0
  53. package/src/tests/lib/trailer.test.js +56 -0
  54. package/src/tests/routes/components/Button.test.js +28 -0
  55. package/src/tests/routes/components/Explore/Explore.test.js +94 -0
  56. package/src/tests/routes/components/Explore/Filter/Dropdown.test.js +16 -0
  57. package/src/tests/routes/components/Explore/Filter/Filter.test.js +28 -0
  58. package/src/tests/routes/components/Explore/Filter/FilterItem.test.js +50 -0
  59. package/src/tests/routes/components/Explore/Filter/FilterSorting.test.js +34 -0
  60. package/src/tests/routes/components/Explore/Filter/Search.test.js +26 -0
  61. package/src/tests/routes/components/Explore/Filter/TogglesWithSearch.test.js +53 -0
  62. package/src/tests/routes/components/GridTitle.test.js +42 -0
  63. package/src/tests/routes/components/ListTitle.test.js +1 -1
  64. package/src/tests/routes/components/Playlinks/PlaylinksCompact.test.js +42 -0
  65. package/src/tests/routes/components/Share.test.js +12 -12
  66. package/src/tests/routes/components/Title.test.js +13 -0
  67. package/src/tests/routes/components/Trailer.test.js +20 -0
  68. package/src/tests/routes/components/YouTubeEmbedOverlay.test.js +31 -0
  69. package/src/tests/setup.js +2 -0
@@ -0,0 +1,101 @@
1
+ <script lang="ts">
2
+ import type { Snippet } from 'svelte'
3
+
4
+ interface Props {
5
+ variant?: 'filled' | 'border' | 'link'
6
+ size?: 'base' | 'large'
7
+ active?: boolean
8
+ // eslint-disable-next-line no-unused-vars
9
+ onclick?: (event: MouseEvent) => void
10
+ children?: Snippet
11
+ }
12
+
13
+ const { variant = 'filled', size = 'base', active = false, onclick, children }: Props = $props()
14
+ </script>
15
+
16
+ <button class="button {variant} {size}" class:active {onclick}>
17
+ {@render children?.()}
18
+ </button>
19
+
20
+ <style lang="scss">
21
+ .button {
22
+ appearance: none;
23
+ display: flex;
24
+ height: 100%;
25
+ align-items: center;
26
+ justify-content: center;
27
+ gap: margin(0.25);
28
+ border: 0;
29
+ padding: 0.25em 0.5em;
30
+ background: transparent;
31
+ border-radius: theme(button-border-radius, border-radius);
32
+ color: theme(button-text-color, text-color-alt);
33
+ font-size: inherit;
34
+ font-family: inherit;
35
+ font-weight: theme(button-font-weight, normal);
36
+ cursor: pointer;
37
+
38
+ :global(svg) {
39
+ width: 1.5em;
40
+ height: 1.5em;
41
+ opacity: 0.75;
42
+
43
+ &:last-child {
44
+ width: 0.85em;
45
+ height: 0.85em;
46
+ }
47
+ }
48
+ }
49
+
50
+ .filled {
51
+ background: theme(button-filled-background, content);
52
+
53
+ &:hover,
54
+ &:active {
55
+ background: theme(button-filled-hover-background, content-light);
56
+ color: theme(button-filled-hover-text-color, text-color);
57
+ }
58
+
59
+ &.active {
60
+ box-shadow: inset 0 0 0 1px currentColor;
61
+ }
62
+ }
63
+
64
+ .border {
65
+ box-shadow: inset 0 0 0 1px theme(button-border-color, content-light);
66
+
67
+ &:hover,
68
+ &:active,
69
+ &.active {
70
+ background: theme(button-border-color, lighter);
71
+ color: theme(button-hover-text-color, text-color);
72
+ }
73
+
74
+ &.active {
75
+ filter: brightness(theme(hover-filter-brightness));
76
+ }
77
+ }
78
+
79
+ .link {
80
+ display: inline-flex;
81
+ height: auto;
82
+ padding: 0;
83
+ font-size: inherit;
84
+ line-height: inherit;
85
+
86
+ &:hover,
87
+ &:active {
88
+ color: theme(button-hover-text-color, text-color);
89
+ }
90
+
91
+ :global(svg) {
92
+ width: 0.85em;
93
+ height: 0.85em;
94
+ }
95
+ }
96
+
97
+ .large {
98
+ padding: 0.5em 1em;
99
+ }
100
+ </style>
101
+
@@ -108,6 +108,27 @@
108
108
  currentScriptTag.insertAdjacentElement('afterend', newScriptTag)
109
109
  currentScriptTag.remove()
110
110
  }
111
+
112
+ function replacePageWithExplore() {
113
+ const element = document.querySelector("main, article")
114
+
115
+ element!.innerHTML = `
116
+ <div data-playpilot-explore style="background: white;">
117
+ <div style="padding: 64px 32px; min-height: 100vh; color: black; max-width: 1264px; margin: 0 auto">
118
+ <div class="divider" style="width: 100%; max-width: 600px; height: 0.25rem;background-image: linear-gradient(to right,#51B3E0,#51B3E0 2.5rem,#E5ADAE 2.5rem,#E5ADAE 5rem,#E5E54F 5rem,#E5E54F 7.5rem,black 7.5rem,black);"></div>
119
+
120
+ <div class="heading" style="margin: 4px 0; font-size: clamp(24px, 5vw, 32px); line-height: 1.5; font-weight: bold; text-transform: uppercase;">
121
+ Streaming Guide
122
+ </div>
123
+
124
+ <div role="status" aria-live="polite">
125
+ <svg fill="currentColor" viewBox="0 0 24 24" width="72"><path fill="currentColor" d="M10.14,1.16a11,11,0,0,0-9,8.92A1.59,1.59,0,0,0,2.46,12,1.52,1.52,0,0,0,4.11,10.7a8,8,0,0,1,6.66-6.61A1.42,1.42,0,0,0,12,2.69h0A1.57,1.57,0,0,0,10.14,1.16Z"><animateTransform attributeName="transform" type="rotate" dur="500ms" values="0 12 12;360 12 12" repeatCount="indefinite"/></path></svg>
126
+ <div style="margin: 12px 0; font-weight: bold; text-transform: uppercase;">Loading…</div>
127
+ <noscript>Sorry, this page requires JavaScript to be enabled.</noscript>
128
+ </div>
129
+ </div>
130
+ </div>`
131
+ }
111
132
  </script>
112
133
 
113
134
  <svelte:window {onkeydown} />
@@ -164,6 +185,10 @@
164
185
  {:else}
165
186
  <button onclick={replaceWithBetaScript}>Use beta script</button>
166
187
  {/if}
188
+
189
+ <hr />
190
+
191
+ <button onclick={replacePageWithExplore}>Replace page with explore</button>
167
192
  </div>
168
193
  {/if}
169
194
 
@@ -0,0 +1,240 @@
1
+ <script lang="ts">
2
+ import { heading } from '$lib/actions/heading'
3
+ import { searchTitles } from '$lib/api/search'
4
+ import { fetchTitles } from '$lib/api/titles'
5
+ import { exploreParentSelector } from '$lib/explore'
6
+ import { openModal } from '$lib/modal'
7
+ import type { APIPaginatedResult } from '$lib/types/api'
8
+ import type { ExploreFilter } from '$lib/types/filter'
9
+ import type { TitleData } from '$lib/types/title'
10
+ import Button from '../Button.svelte'
11
+ import GridTitle from '../GridTitle.svelte'
12
+ import GridTitleSkeleton from '../GridTitleSkeleton.svelte'
13
+ import ListTitle from '../ListTitle.svelte'
14
+ import ListTitleSkeleton from '../ListTitleSkeleton.svelte'
15
+ import Filter from './Filter/Filter.svelte'
16
+ import Search from './Filter/Search.svelte'
17
+
18
+ const filter: ExploreFilter = $state({})
19
+
20
+ let element: HTMLElement | null = null
21
+ let titles: TitleData[] = $state([])
22
+ let page = 1
23
+ let searchQuery = ''
24
+ let debounce: ReturnType<typeof setTimeout> | null = null
25
+ let promise = $state(getTitlesForFilter())
26
+ let height: string | null = $state(null)
27
+ let width = $state(0)
28
+
29
+ const grid = $derived(width > 500)
30
+ const gridColumns = $derived.by(() => {
31
+ if (width > 1400) return 8
32
+ if (width > 900) return 6
33
+ else if (width > 600) return 4
34
+ return 3
35
+ })
36
+ const TitleComponent = $derived(grid ? GridTitle : ListTitle)
37
+ const SkeletonComponent = $derived(grid ? GridTitleSkeleton : ListTitleSkeleton)
38
+
39
+ $effect(() => {
40
+ // Set the height of this element to match that of it's container. That way we can
41
+ // allow our element to be scrollable, rather than the parent needing to be scrollable.
42
+ height = element?.closest<HTMLElement>(exploreParentSelector)?.style.height || null
43
+ })
44
+
45
+ async function getTitlesForFilter(): Promise<APIPaginatedResult<TitleData>> {
46
+ let response
47
+
48
+ // If a search query is given we use searchTitles with just the query.
49
+ // If not, we use fetchTitles with the given params for the filter.
50
+ // This is because the backend does not support filters for search results yet.
51
+ // In the future we will likely merge these two, either by adding filters in the backend
52
+ // or by filtering results in the frontend (this seems like the less good option).
53
+ if (searchQuery) {
54
+ const results: TitleData[] = await searchTitles(searchQuery)
55
+ response = { results, next: null, previous: null }
56
+
57
+ titles = results
58
+ } else {
59
+ const params = { page_size: 24, page, ...filter }
60
+
61
+ response = await fetchTitles(params)
62
+
63
+ if (!response?.results) throw new Error('Something went wrong when fetching titles in Explore')
64
+
65
+ titles = [...titles, ...response.results]
66
+ }
67
+
68
+ return response
69
+ }
70
+
71
+ async function search(query: string): Promise<void> {
72
+ searchQuery = query
73
+
74
+ if (debounce) clearTimeout(debounce)
75
+
76
+ debounce = setTimeout(() => {
77
+ resetTitles()
78
+ promise = getTitlesForFilter()
79
+ }, 100)
80
+ }
81
+
82
+ // Hooking this up later
83
+ // eslint-disable-next-line no-unused-vars
84
+ function setFilter(key: keyof ExploreFilter, value: any): void {
85
+ // If the value was previous present in the filter and a falsey value is given we remove it from
86
+ // the filter entirely so it will no longer be used as a param
87
+ if (!value && (key in filter)) {
88
+ delete filter[key]
89
+ } else if (value) {
90
+ filter[key] = value
91
+ }
92
+
93
+ resetTitles()
94
+ promise = getTitlesForFilter()
95
+ }
96
+
97
+ function resetTitles(): void {
98
+ page = 1
99
+ titles = []
100
+ }
101
+
102
+ function fetchMoreTitles(): void {
103
+ page++
104
+ promise = getTitlesForFilter()
105
+ }
106
+ </script>
107
+
108
+ <div class="explore playpilot-styled-scrollbar" class:grid bind:this={element} bind:clientWidth={width} style:height>
109
+ <div class="header" role="banner">
110
+ <div>
111
+ <div class="divider"></div>
112
+
113
+ <div class="heading" use:heading>
114
+ Streaming Guide
115
+ </div>
116
+
117
+ <p class="description">
118
+ Find where to watch movies online - the ultimate guide that helps you find the best movies and shows across streaming services.
119
+ </p>
120
+ </div>
121
+
122
+ {#key grid}
123
+ <Search oninput={search} />
124
+ <Filter {filter} limit={!grid} />
125
+ {/key}
126
+ </div>
127
+
128
+ <div class="titles" role="main" style:--grid-columns={gridColumns}>
129
+ {#each titles as title}
130
+ {#key title.sid}
131
+ <TitleComponent {title} onclick={(event: MouseEvent) => openModal({ event, data: title })} />
132
+ {/key}
133
+ {/each}
134
+
135
+ {#await promise}
136
+ {#each { length: 24 } as _}
137
+ <SkeletonComponent />
138
+ {/each}
139
+ {:catch}
140
+ <!-- This is handled below -->
141
+ {/await}
142
+ </div>
143
+
144
+ {#await promise then { next }}
145
+ {#if next}
146
+ <div class="show-more">
147
+ <Button size="large" onclick={fetchMoreTitles}>Show more</Button>
148
+ </div>
149
+ {/if}
150
+ {:catch}
151
+ Something went wrong
152
+ {/await}
153
+ </div>
154
+
155
+ <style lang="scss">
156
+ .explore {
157
+ background: theme(explore-background, light);
158
+ border-radius: theme(border-radius-large);
159
+ max-width: theme(explore-max-width, 1200px);
160
+ margin: 0 auto;
161
+ padding: theme(explore-padding, margin(1) margin(1) margin(2));
162
+ overflow: auto;
163
+ font-family: theme(font-family);
164
+ font-family: theme(detail-font-family, font-family);
165
+ font-weight: theme(detail-font-weight, normal);
166
+ font-size: theme(detail-font-size, font-size-base);
167
+
168
+ :global(*) {
169
+ box-sizing: border-box;
170
+ }
171
+ }
172
+
173
+ .header {
174
+ display: flex;
175
+ flex-direction: column;
176
+ gap: margin(0.5);
177
+ margin: theme(explore-header-margin, 0 0 margin(2));
178
+ width: 100%;
179
+
180
+ .grid & {
181
+ gap: margin(1);
182
+ }
183
+ }
184
+
185
+ .divider {
186
+ width: 100%;
187
+ height: theme(explore-divider-height, 0);
188
+ max-width: theme(explore-header-max-width, 600px);
189
+ background: theme(explore-divider-background, text-color);
190
+ }
191
+
192
+ .heading {
193
+ margin: theme(explore-heading-margin, margin(0.25) 0);
194
+ color: theme(text-color);
195
+ font-size: theme(explore-heading-size, clamp(margin(1.5), 5vw, margin(2)));
196
+ font-weight: theme(explore-heading-font-weight, font-bold);
197
+ text-transform: theme(explore-heading-text-transform, normal);
198
+ line-height: 1.5;
199
+ }
200
+
201
+ .description {
202
+ max-width: theme(explore-header-max-width, 600px);
203
+ margin: theme(explore-description-margin, 0 0 margin(1));
204
+ color: theme(text-color-alt);
205
+ font-size: theme(font-size-small);
206
+ line-height: 1.35;
207
+
208
+ .grid & {
209
+ font-size: theme(font-size-base);
210
+ }
211
+ }
212
+
213
+ .titles {
214
+ --playpilot-list-item-padding: 0;
215
+ --playpilot-list-item-background: transparent;
216
+ --playpilot-list-item-hover-shadow: 0 0 0 #{margin(0.25)} #{theme(lighter)};
217
+ --playpilot-detail-image-background: #{theme(content)};
218
+ display: flex;
219
+ flex-direction: column;
220
+ gap: margin(0.5);
221
+
222
+ .grid & {
223
+ display: grid;
224
+ grid-template-columns: repeat(var(--grid-columns), minmax(120px, 1fr));
225
+ gap: margin(1);
226
+ }
227
+ }
228
+
229
+ .show-more {
230
+ flex: 0 0 100%;
231
+ display: grid;
232
+ margin-top: margin(1);
233
+
234
+ .grid & {
235
+ margin: margin(2) auto 0;
236
+ max-width: 600px;
237
+ width: 100%;
238
+ }
239
+ }
240
+ </style>
@@ -0,0 +1,58 @@
1
+ <script lang="ts">
2
+ import IconArrow from '../Icons/IconArrow.svelte'
3
+ import ImageTitlesList from '$lib/images/titles-list.webp'
4
+ import { openModal } from '$lib/modal'
5
+ </script>
6
+
7
+ <button class="call-to-action" onclick={() => openModal({ type: 'explore' })}>
8
+ <img src={ImageTitlesList} alt="" width="70" height="57" />
9
+
10
+ <div>
11
+ <strong>Looking for something else?</strong>
12
+ <div>Use our streamingguide</div>
13
+ </div>
14
+
15
+ <div class="arrow">
16
+ <IconArrow />
17
+ </div>
18
+ </button>
19
+
20
+ <style lang="scss">
21
+ strong {
22
+ color: theme(text-color);
23
+ font-size: theme(font-size-base);
24
+ font-weight: normal;
25
+ }
26
+
27
+ .call-to-action {
28
+ appearance: none;
29
+ display: flex;
30
+ align-items: center;
31
+ gap: margin(1);
32
+ width: 100%;
33
+ padding: margin(1);
34
+ border: 0;
35
+ border-radius: theme(border-radius-large) theme(border-radius-large) 0 0;
36
+ background: theme(light);
37
+ font-family: theme(font-family);
38
+ color: theme(text-color-alt);
39
+ font-size: theme(font-size-small);
40
+ line-height: 1.5;
41
+ text-align: left;
42
+ cursor: pointer;
43
+
44
+ &:hover {
45
+ filter: brightness(theme(hover-filter-brightness));
46
+ }
47
+ }
48
+
49
+ .arrow {
50
+ margin-left: auto;
51
+
52
+ :global(svg) {
53
+ display: block;
54
+ height: margin(1.25);
55
+ width: auto;
56
+ }
57
+ }
58
+ </style>
@@ -0,0 +1,15 @@
1
+
2
+ <script lang="ts">
3
+ import Modal from '../Modal.svelte'
4
+ import Explore from './Explore.svelte'
5
+
6
+ interface Props {
7
+ initialScrollPosition?: number
8
+ }
9
+
10
+ const { initialScrollPosition = 0 }: Props = $props()
11
+ </script>
12
+
13
+ <Modal {initialScrollPosition} closeButtonStyle="flat">
14
+ <Explore />
15
+ </Modal>
@@ -0,0 +1,72 @@
1
+ <script lang="ts">
2
+ import { scale } from 'svelte/transition'
3
+ import type { Snippet } from 'svelte'
4
+
5
+ interface Props {
6
+ // eslint-disable-next-line no-unused-vars
7
+ button: Snippet<[{ toggle: (event: MouseEvent) => void }]>,
8
+ content: Snippet
9
+ }
10
+
11
+ const { button, content }: Props = $props()
12
+
13
+ let active = $state(false)
14
+ let element: HTMLElement | null = $state(null)
15
+ let direction: 'left' | 'right' = $state('left')
16
+
17
+ function closeOnOutsideClick(event: MouseEvent): void {
18
+ const target = event.target as HTMLElement
19
+
20
+ if (!target) return
21
+ if (target.closest('.dropdown') === element) return
22
+
23
+ active = false
24
+ }
25
+
26
+ function toggle(event: MouseEvent): void {
27
+ const target = event.target as HTMLElement
28
+ const leftOffset = target.getBoundingClientRect().left
29
+
30
+ direction = leftOffset < window.innerWidth / 2 ? 'left' : 'right'
31
+ active = !active
32
+ }
33
+ </script>
34
+
35
+ <svelte:window onclick={closeOnOutsideClick} />
36
+
37
+ <div class="dropdown {direction}" bind:this={element}>
38
+ {@render button({ toggle })}
39
+
40
+ {#if active}
41
+ <div class="content playpilot-styled-scrollbar" transition:scale={{ start: 0.85, duration: 100 }}>
42
+ {@render content()}
43
+ </div>
44
+ {/if}
45
+ </div>
46
+
47
+ <style lang="scss">
48
+ .dropdown {
49
+ position: relative;
50
+ }
51
+
52
+ .content {
53
+ z-index: 10;
54
+ position: absolute;
55
+ bottom: margin(-0.5);
56
+ right: 0;
57
+ max-height: 60vh;
58
+ border-radius: theme(border-radius);
59
+ box-shadow: theme(shadow-large);
60
+ transform: translateY(100%);
61
+ transform-origin: top right;
62
+ background: theme(content);
63
+ overflow-y: auto;
64
+ overflow-x: hidden;
65
+
66
+ .left & {
67
+ right: auto;
68
+ left: 0;
69
+ transform-origin: top left;
70
+ }
71
+ }
72
+ </style>
@@ -0,0 +1,99 @@
1
+ <script lang="ts">
2
+ import type { ExploreFilter } from '$lib/types/filter'
3
+ import genres from '$lib/data/genres.json'
4
+ import countries from '$lib/data/countries.json'
5
+ import FilterItem from './FilterItem.svelte'
6
+ import FilterSorting from './FilterSorting.svelte'
7
+ import Button from '../../Button.svelte'
8
+ import IconArrow from '../../Icons/IconArrow.svelte'
9
+ import IconFilter from '../../Icons/IconFilter.svelte'
10
+ import { scale } from 'svelte/transition'
11
+
12
+ interface Props {
13
+ filter: ExploreFilter
14
+ limit?: boolean
15
+ }
16
+
17
+ const { filter, limit = true }: Props = $props()
18
+
19
+ const shownItemsLimit = 3
20
+ const items = [{
21
+ label: 'Services',
22
+ param: 'provider',
23
+ data: [{ label: 'Netflix', value: 'netflix' }, { label: 'Disney+', value: 'disney-plus' }],
24
+ }, {
25
+ label: 'Genres',
26
+ param: 'genres',
27
+ data: genres.map(genre => ({ label: genre.name, value: genre.slug })),
28
+ }, {
29
+ label: 'IMDb',
30
+ param: 'imdb',
31
+ }, {
32
+ label: 'Year',
33
+ param: 'year',
34
+ }, {
35
+ label: 'Length',
36
+ param: 'length',
37
+ }, {
38
+ label: 'Countries',
39
+ param: 'country',
40
+ data: countries,
41
+ }]
42
+
43
+ let limited = $state(limit)
44
+ </script>
45
+
46
+ <div class="filter" role="navigation" class:limit>
47
+ <div class="items">
48
+ {#each limited ? items.slice(0, shownItemsLimit) : items as item}
49
+ <div transition:scale={{ start: 0.75, duration: 100 }}>
50
+ <FilterItem {filter} {...item} />
51
+ </div>
52
+ {/each}
53
+ </div>
54
+
55
+ <div class="actions">
56
+ {#if limit}
57
+ <Button variant="link" onclick={() => limited = !limited}>
58
+ <IconFilter />
59
+ All filters
60
+ <IconArrow direction={limited ? 'down' : 'up'} />
61
+ </Button>
62
+ {/if}
63
+
64
+ <div class="sorting">
65
+ <FilterSorting {filter} />
66
+ </div>
67
+ </div>
68
+ </div>
69
+
70
+ <style lang="scss">
71
+ .filter {
72
+ position: relative;
73
+ }
74
+
75
+ .items {
76
+ display: flex;
77
+ flex-wrap: wrap;
78
+ gap: margin(0.5);
79
+ padding-right: margin(7);
80
+
81
+ .limit & {
82
+ padding-right: 0;
83
+ }
84
+ }
85
+
86
+ .actions {
87
+ position: absolute;
88
+ top: margin(0.25);
89
+ right: 0;
90
+
91
+ .limit & {
92
+ position: initial;
93
+ top: auto;
94
+ display: flex;
95
+ justify-content: space-between;
96
+ margin-top: margin(0.5);
97
+ }
98
+ }
99
+ </style>
@@ -0,0 +1,57 @@
1
+ <script lang="ts">
2
+ import Button from '../../Button.svelte'
3
+ import IconArrow from '../../Icons/IconArrow.svelte'
4
+ import TogglesWithSearch from './TogglesWithSearch.svelte'
5
+ import type { ExploreFilter } from '$lib/types/filter'
6
+ import Dropdown from './Dropdown.svelte'
7
+
8
+ interface Props {
9
+ filter: ExploreFilter,
10
+ label: string,
11
+ param: string,
12
+ data?: { label: string, value: string }[] | null
13
+ }
14
+
15
+ const { filter, label, param, data = null }: Props = $props()
16
+
17
+ const active = $derived(!!filter[param])
18
+
19
+ function setFilter(value: string): void {
20
+ if (value) filter[param] = value
21
+ else delete filter[param]
22
+ }
23
+ </script>
24
+
25
+ <div class="filter-item" class:active data-testid="filter-item">
26
+ <Dropdown>
27
+ {#snippet button({ toggle })}
28
+ <Button variant="border" size="large" {active} onclick={toggle}>
29
+ {label}
30
+ <IconArrow direction="down" />
31
+ </Button>
32
+ {/snippet}
33
+
34
+ {#snippet content()}
35
+ <div class="content" data-testid="content">
36
+ {#if data}
37
+ {@const selected = filter[param]?.split(',') || []}
38
+ <TogglesWithSearch options={data} {selected} onchange={(selected) => setFilter(selected.join(','))} />
39
+ {:else}
40
+ Some range filter
41
+ {/if}
42
+ </div>
43
+ {/snippet}
44
+ </Dropdown>
45
+ </div>
46
+
47
+ <style lang="scss">
48
+ .filter-item {
49
+ &.active :global(.dropdown > button) {
50
+ box-shadow: inset 0 0 0 1px theme(green);
51
+ }
52
+ }
53
+
54
+ .content {
55
+ width: margin(15);
56
+ }
57
+ </style>