itube-specs 0.0.919 → 0.0.921

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 (35) hide show
  1. package/assets/scss/themes/dark/_theme.scss +31 -5
  2. package/assets/scss/themes/light/_theme.scss +31 -5
  3. package/assets/scss/vars/_palette.scss +3 -0
  4. package/components/card/webcam/index.vue +141 -15
  5. package/components/card/webcam/languages.vue +7 -3
  6. package/components/filter/model.vue +56 -1
  7. package/components/grid/webcams.vue +78 -0
  8. package/components/player/core.vue +37 -1
  9. package/components/ui/switcher.vue +114 -0
  10. package/components/view/webcams.vue +370 -0
  11. package/components/webcam/header.vue +1 -43
  12. package/components/webcam/icon.vue +49 -0
  13. package/components/webcam/main.vue +8 -62
  14. package/components/webcam/stripcash-card.vue +2 -2
  15. package/composables/fetch/use-fetch-models-count.ts +20 -0
  16. package/composables/fetch/use-fetch-webcam-models.ts +45 -0
  17. package/lib/index.ts +1 -0
  18. package/lib/scheme/sort-webcams.ts +22 -0
  19. package/package.json +1 -1
  20. package/pages/webcams/[[tag]].vue +75 -0
  21. package/server/api/models/count.get.ts +18 -0
  22. package/server/api/webcams/list.post.ts +41 -0
  23. package/services/api/models.service.ts +20 -0
  24. package/services/api/webcams.service.ts +34 -0
  25. package/types/index.d.ts +3 -0
  26. package/types/switcher-item.d.ts +13 -0
  27. package/types/webcam-facet.d.ts +7 -0
  28. package/types/webcam-models-response.d.ts +12 -0
  29. package/utils/webcams/__tests__/count-filters.test.ts +12 -0
  30. package/utils/webcams/__tests__/normalize-facets.test.ts +52 -0
  31. package/utils/webcams/__tests__/to-filter-payload.test.ts +17 -0
  32. package/utils/webcams/count-filters.ts +14 -0
  33. package/utils/webcams/facets.ts +31 -0
  34. package/utils/webcams/normalize-facets.ts +57 -0
  35. package/utils/webcams/to-filter-payload.ts +16 -0
@@ -0,0 +1,114 @@
1
+ <!-- Переключатель разделов: пилюля с опциями, активная подсвечена.
2
+
3
+ Базовый компонент: получает массив опций и текущее значение, а решение «что
4
+ показывать» остаётся снаружи. У опции, кроме подписи, могут быть иконка и
5
+ второстепенная подпись — так в переключателе видно не только название раздела,
6
+ но и сколько в нём сейчас всего (например «Webcam · 30 online»).
7
+
8
+ Опция с адресом (`to`) — это переход, а не выбор: такие опции рисуются ссылкой,
9
+ чтобы переключение разделов работало как навигация (и индексировалось). -->
10
+ <template>
11
+ <div class="ui-switcher">
12
+ <component
13
+ v-for="item in items"
14
+ :is="item.to ? NuxtLink : 'button'"
15
+ :key="item.name"
16
+ :class="{'--active': item.name === modelValue}"
17
+ :to="item.to"
18
+ :type="item.to ? undefined : 'button'"
19
+ class="ui-switcher__item"
20
+ @click="item.to || emit('update:modelValue', item.name)"
21
+ >
22
+ <span class="ui-switcher__title">{{ item.title }}</span>
23
+
24
+ <span
25
+ v-if="item.icon || item.subtitle"
26
+ class="ui-switcher__meta"
27
+ >
28
+ <UiIcon
29
+ v-if="item.icon"
30
+ :name="item.icon"
31
+ class="ui-switcher__icon"
32
+ size="16"
33
+ />
34
+ <span
35
+ v-if="item.subtitle"
36
+ class="ui-switcher__subtitle"
37
+ >{{ item.subtitle }}</span>
38
+ </span>
39
+ </component>
40
+ </div>
41
+ </template>
42
+
43
+ <script setup lang="ts">
44
+ import { NuxtLink } from '#components';
45
+ import type { ISwitcherItem } from '../../types';
46
+
47
+ defineProps<{
48
+ items: ISwitcherItem[]
49
+ modelValue: string
50
+ }>();
51
+
52
+ const emit = defineEmits<{
53
+ 'update:modelValue': [value: string]
54
+ }>();
55
+ </script>
56
+
57
+ <style lang="scss">
58
+ .ui-switcher {
59
+ display: inline-flex;
60
+ align-items: center;
61
+ padding: 4px;
62
+ background-color: $switcher-bg;
63
+ border: 1px solid $switcher-border;
64
+ border-radius: $radius-full;
65
+ }
66
+
67
+ .ui-switcher__item {
68
+ @include font-sm;
69
+
70
+ display: flex;
71
+ align-items: center;
72
+ gap: 10px;
73
+ padding: 8px 16px;
74
+ font-weight: 600;
75
+ color: $switcher-text;
76
+ border-radius: $radius-full;
77
+ transition: var(--transition) color, var(--transition) background-color;
78
+
79
+ &:is(:hover, :focus-visible) {
80
+ color: $switcher-text--hover;
81
+ }
82
+
83
+ &.--active {
84
+ color: $switcher-text--active;
85
+ background-color: $switcher-bg--active;
86
+ }
87
+
88
+ @include from-br(xm) {
89
+ padding-inline: 20px;
90
+ }
91
+ }
92
+
93
+ .ui-switcher__meta {
94
+ display: flex;
95
+ align-items: center;
96
+ gap: 6px;
97
+ }
98
+
99
+ .ui-switcher__icon {
100
+ color: $switcher-icon;
101
+ }
102
+
103
+ .ui-switcher__item.--active .ui-switcher__icon {
104
+ color: $switcher-icon--active;
105
+ }
106
+
107
+ .ui-switcher__subtitle {
108
+ color: $switcher-subtitle;
109
+ }
110
+
111
+ .ui-switcher__item.--active .ui-switcher__subtitle {
112
+ color: $switcher-subtitle--active;
113
+ }
114
+ </style>
@@ -0,0 +1,370 @@
1
+ <!-- Раздел вебкам-моделей: заголовок с переключателем разделов, строка фильтров,
2
+ сетка тумб и пагинация.
3
+
4
+ Фильтры — фасетные, как на боевом движке: набор контролов и их варианты приходят
5
+ от провайдера и пересчитываются под уже применённые условия, а состояние живёт в
6
+ query-параметрах `filter_*`. Формат провайдера приводится к общему формату фильтров
7
+ (`utils/webcams/normalize-facets.ts`), поэтому строку фильтров, модалку и чипсы рисуют
8
+ готовые фильтр-компоненты — свои для вебкамов не нужны.
9
+
10
+ Сортировки у провайдера нет: «новые» — это его тег `new` (см. `lib/scheme/sort-webcams.ts`).
11
+ Переключатель «только онлайн» пока ничего не меняет в запросе: API под него не готово,
12
+ а других моделей провайдер и не отдаёт.
13
+
14
+ Клики и показы тумб вью отдаёт событиями — статистику подключает сайт. -->
15
+ <template>
16
+ <div class="view-webcams _page _container">
17
+ <header class="view-webcams__header">
18
+ <div class="view-webcams__intro">
19
+ <h1 class="view-webcams__title _title">{{ h1 }}</h1>
20
+
21
+ <UiSwitcher
22
+ :items="sections"
23
+ :model-value="'webcams'"
24
+ class="view-webcams__sections"
25
+ />
26
+ </div>
27
+
28
+ <div class="view-webcams__options">
29
+ <div class="view-webcams__sort">
30
+ <SectionNavLinks
31
+ :items="sortWebcams"
32
+ theme="secondary"
33
+ />
34
+
35
+ <span
36
+ v-if="filters.length"
37
+ class="view-webcams__divider"
38
+ />
39
+
40
+ <FilterBtn
41
+ v-if="filters.length"
42
+ v-model="showFilters"
43
+ :count="filterCount"
44
+ theme="primary"
45
+ />
46
+ </div>
47
+
48
+ <div class="view-webcams__quick">
49
+ <span class="view-webcams__online">
50
+ <UiToggle v-model="onlineOnly">{{ t('webcams.online_only') }}</UiToggle>
51
+ </span>
52
+
53
+ <FilterSelects
54
+ v-if="filters.length"
55
+ :filters="filters"
56
+ />
57
+ </div>
58
+ </div>
59
+
60
+ <FilterModel
61
+ v-if="filters.length && (showFilters || filterCount > 0)"
62
+ v-model="showFilters"
63
+ :data="filterData"
64
+ :status="status"
65
+ />
66
+
67
+ </header>
68
+
69
+ <div class="view-webcams__subheader">
70
+ <h2 class="view-webcams__section-title">
71
+ <WebcamIcon/>
72
+ {{ t(sortTitle) }}
73
+ </h2>
74
+
75
+ <NuxtLink
76
+ v-if="tag"
77
+ :to="tagResetLink"
78
+ class="ui-chips view-webcams__tag"
79
+ >
80
+ {{ tagTitle }}
81
+ <UiIcon
82
+ class="ui-chips__close"
83
+ name="close"
84
+ size="16"
85
+ />
86
+ </NuxtLink>
87
+ </div>
88
+
89
+ <GridWebcams
90
+ v-if="items.length"
91
+ :class="{'_loading': status === 'pending'}"
92
+ :items="items"
93
+ class="view-webcams__grid"
94
+ id="anchor"
95
+ @model-click="payload => emit('model-click', payload)"
96
+ @model-view="payload => emit('model-view', payload)"
97
+ />
98
+
99
+ <div
100
+ v-else-if="status === 'pending'"
101
+ class="view-webcams__loading _loading"
102
+ />
103
+ <UiEmptyState v-else/>
104
+
105
+ <LazyUiPagination
106
+ v-if="allPages > 1"
107
+ :key="`pagination-${allPages}`"
108
+ :total="allPages"
109
+ arrow-icon
110
+ class="_pagination"
111
+ hydrate-on-visible
112
+ />
113
+ </div>
114
+ </template>
115
+
116
+ <script setup lang="ts">
117
+ import type { IModelCard, IModelFilter, ISwitcherItem, IStripcashModel, IWebcamModelsResponse, PaginatedResponse } from '../../types';
118
+ import { countWebcamFilters } from '../../utils/webcams/count-filters';
119
+ import { normalizeWebcamFacets } from '../../utils/webcams/normalize-facets';
120
+ import { toWebcamFilterPayload } from '../../utils/webcams/to-filter-payload';
121
+ import { WEBCAM_FILTER_ANY } from '../../utils/webcams/facets';
122
+ import { sortWebcams } from '../../lib';
123
+
124
+ const props = defineProps<{
125
+ data: IWebcamModelsResponse | null
126
+ status: string
127
+ h1: string
128
+ /** Тег из пути раздела (`/webcams/{tag}`), если он есть. */
129
+ tag?: string
130
+ /** Сколько всего видео-моделей в нише — подпись у пункта «Видео». */
131
+ modelsTotal?: number
132
+ }>();
133
+
134
+ /**
135
+ * Клики и показы тумб уходят наружу: индексы знает сетка, а куда их отправлять —
136
+ * дело сайта (у статистики свой контекст страницы и устройства).
137
+ */
138
+ const emit = defineEmits<{
139
+ 'model-click': [payload: { model: IStripcashModel, index: number }]
140
+ 'model-view': [payload: { model: IStripcashModel, index: number, element: HTMLElement }]
141
+ }>();
142
+
143
+ const { t, te, n, locale } = useI18n();
144
+ const route = useRoute();
145
+ const router = useRouter();
146
+ const { generateLink } = useGenerateLink();
147
+
148
+ const showFilters = ref(false);
149
+
150
+ /**
151
+ * Провайдер отдаёт только моделей в эфире, так что переключатель всегда включён:
152
+ * он про будущий фильтр, а не про текущую выдачу.
153
+ */
154
+ const onlineOnly = ref(true);
155
+
156
+ const items = computed(() => props.data?.items || []);
157
+ const allPages = computed(() => props.data?.allPages || 0);
158
+ const filterCount = computed(() => countWebcamFilters(route.query));
159
+
160
+ /**
161
+ * Фасеты провайдера → фильтры слоя. Подписи фильтров переводим по имени (провайдер
162
+ * отдаёт их только по-английски), варианты оставляем как есть — их у него сотни.
163
+ */
164
+ const filters = computed<IModelFilter[]>(() => normalizeWebcamFacets(props.data?.filters, props.data?.total).map(filter => ({
165
+ ...filter,
166
+ title: translate(`webcams.filters.${filter.name}`, filter.title),
167
+ options: filter.options.map(option => (option.name === WEBCAM_FILTER_ANY ? { ...option, title: t('all') } : option)),
168
+ group: { ...filter.group, title: translate(`webcams.filter_groups.${filter.group.name}`, filter.group.name) },
169
+ })));
170
+
171
+ /** Модалка фильтра слоя ждёт ответ листинга — ей нужны только фасеты. */
172
+ const filterData = computed(() => ({ filters: filters.value }) as unknown as PaginatedResponse<IModelCard>);
173
+
174
+ const sortTitle = computed(() => {
175
+ const current = String(route.query.sort || '');
176
+
177
+ return sortWebcams.find(item => item.value === current)?.title || sortWebcams[0].title;
178
+ });
179
+
180
+ /** Теги провайдера приходят слагами (`cheap-privates`) — показываем словами. */
181
+ const tagTitle = computed(() => String(props.tag || '').replace(/[-_]+/g, ' '));
182
+
183
+ /**
184
+ * Сброс тега — обычная ссылка на корень раздела: остальные фильтры сохраняются
185
+ * (тег такой же фильтр, только живёт в пути). Путь считаем от текущего, чтобы не
186
+ * собирать заново нишу и локаль.
187
+ */
188
+ const tagResetLink = computed(() => {
189
+ const query = { ...route.query };
190
+ delete query.page;
191
+
192
+ return { path: route.path.replace(/\/[^/]+$/, ''), query };
193
+ });
194
+
195
+ /**
196
+ * Переключатель разделов: видео-модели ↔ вебкам-модели, у каждого свой счётчик
197
+ * (у вебкамов — сколько сейчас в эфире).
198
+ */
199
+ const sections = computed<ISwitcherItem[]>(() => [
200
+ {
201
+ name: 'videos',
202
+ title: t('webcams.sections.videos'),
203
+ icon: 'user-group',
204
+ subtitle: props.modelsTotal ? n(props.modelsTotal) : '',
205
+ to: generateLink('/models'),
206
+ },
207
+ {
208
+ name: 'webcams',
209
+ title: t('webcams.sections.webcams'),
210
+ icon: 'webcam',
211
+ subtitle: props.data?.total ? t('webcams.online_short', { count: n(props.data.total) }) : '',
212
+ },
213
+ ]);
214
+
215
+ /**
216
+ * Вход в раздел: если интерфейс не английский, сразу показываем моделей, которые
217
+ * говорят на языке пользователя. Только при первом заходе за сессию — иначе
218
+ * снятый пользователем фильтр возвращался бы сам, — и только если такой язык у
219
+ * провайдера в выдаче есть (иначе получили бы пустую страницу).
220
+ */
221
+ const isLanguageFilterApplied = useState('webcams-language-filter', () => false);
222
+
223
+ onMounted(() => {
224
+ if (isLanguageFilterApplied.value) return;
225
+ isLanguageFilterApplied.value = true;
226
+
227
+ const code = locale.value.slice(0, 2).toLowerCase();
228
+ if (code === 'en' || filterCount.value > 0) return;
229
+
230
+ const languageFilter = filters.value.find(item => item.name === 'language');
231
+ if (!languageFilter?.options.some(option => option.name === code)) return;
232
+
233
+ router.replace({ query: { ...route.query, filter_language: code } });
234
+ });
235
+
236
+ function translate(key: string, fallback: string) {
237
+ return te(key) ? t(key) : fallback;
238
+ }
239
+
240
+ /**
241
+ * Смена фильтра сбрасывает пагинацию: на второй странице новой выборки может не
242
+ * быть вовсе, и пользователь упирался бы в пустую выдачу (правило боевого движка).
243
+ */
244
+ watch(() => JSON.stringify(toWebcamFilterPayload(route.query)), () => {
245
+ if (!route.query.page) return;
246
+
247
+ const query = { ...route.query };
248
+ delete query.page;
249
+ router.replace({ query });
250
+ });
251
+ </script>
252
+
253
+ <style lang="scss">
254
+ .view-webcams__header {
255
+ display: grid;
256
+ row-gap: 32px;
257
+ margin-bottom: 32px;
258
+ }
259
+
260
+ .view-webcams__intro {
261
+ display: flex;
262
+ flex-wrap: wrap;
263
+ align-items: center;
264
+ column-gap: 20px;
265
+ row-gap: 16px;
266
+ }
267
+
268
+ .view-webcams__title {
269
+ margin-bottom: 0;
270
+ }
271
+
272
+ .view-webcams__options {
273
+ display: flex;
274
+ flex-wrap: wrap;
275
+ align-items: center;
276
+ justify-content: space-between;
277
+ gap: 12px;
278
+ }
279
+
280
+ .view-webcams__sort,
281
+ .view-webcams__quick {
282
+ display: flex;
283
+ flex-wrap: wrap;
284
+ align-items: center;
285
+ gap: 8px;
286
+ }
287
+
288
+ // на узких экранах контролы строки компактнее (в макете `px-3` вместо `px-4`)
289
+ .view-webcams__sort .ui-btn {
290
+ @include to-br(xm) {
291
+ padding-inline: 12px;
292
+ }
293
+ }
294
+
295
+ // в макете быстрые фильтры видны и на мобиле, а слой прячет их до `sm`
296
+ .view-webcams__quick > .filter-selects > ._from-sm {
297
+ @include to-br(sm) {
298
+ display: block !important;
299
+ }
300
+ }
301
+
302
+ .view-webcams__divider {
303
+ width: 1px;
304
+ height: 24px;
305
+ margin-inline: 4px;
306
+ background-color: $page-webcams-divider;
307
+ }
308
+
309
+ .view-webcams__online {
310
+ display: flex;
311
+ align-items: center;
312
+ padding: 8px 12px;
313
+ color: $page-webcams-control-text;
314
+ background-color: $page-webcams-control-bg;
315
+ border: 1px solid $page-webcams-control-border;
316
+ border-radius: $radius-lg;
317
+ }
318
+
319
+ .view-webcams__online .ui-toggle__text {
320
+ @include font-sm;
321
+
322
+ padding-left: 46px;
323
+ }
324
+
325
+ // строка фильтров в макете тише кнопок сортировки: подложка и рамка на тон слабее,
326
+ // подпись без жирности, высота — как у остальных контролов строки
327
+ .view-webcams .filter-selects .filter-selects__select-button {
328
+ padding: 8px 12px;
329
+ font-weight: 400;
330
+ }
331
+
332
+ .view-webcams .filter-selects .filter-selects__select-button:not(.--active-select) {
333
+ background-color: $page-webcams-control-bg;
334
+ border-color: $page-webcams-control-border;
335
+ }
336
+
337
+
338
+ // заголовок выдачи — только на десктопе: в мобильном макете его нет
339
+ .view-webcams__subheader {
340
+ display: none;
341
+ flex-wrap: wrap;
342
+ align-items: center;
343
+ gap: 12px;
344
+ margin-bottom: 20px;
345
+
346
+ @include from-br(xm) {
347
+ display: flex;
348
+ }
349
+ }
350
+
351
+ .view-webcams__section-title {
352
+ @include font-lg;
353
+
354
+ display: flex;
355
+ align-items: center;
356
+ gap: 8px;
357
+ margin-bottom: 0;
358
+ font-weight: 700;
359
+ letter-spacing: var(--tracking-wide);
360
+ }
361
+
362
+ .view-webcams__header .filter-model {
363
+ margin-top: 0;
364
+ }
365
+
366
+ .grid-webcams.view-webcams__grid {
367
+ // ресет обнуляет отступы у `ul[class]`, одиночный класс его не перебивает
368
+ margin-bottom: 40px;
369
+ }
370
+ </style>
@@ -7,32 +7,7 @@
7
7
  <template>
8
8
  <header class="webcam-header">
9
9
  <h2 class="webcam-header__title">
10
- <span class="webcam-header__icon">
11
- <!-- иконка вебкам-камеры из макета -->
12
- <svg
13
- viewBox="0 0 24 24"
14
- fill="none"
15
- stroke="currentColor"
16
- stroke-width="2"
17
- stroke-linecap="round"
18
- stroke-linejoin="round"
19
- aria-hidden="true"
20
- >
21
- <circle
22
- cx="12"
23
- cy="9"
24
- r="6.5"
25
- />
26
- <circle
27
- cx="12"
28
- cy="9"
29
- r="2.5"
30
- fill="currentColor"
31
- />
32
- <path d="M12 15.5V19"/>
33
- <path d="M8 20h8"/>
34
- </svg>
35
- </span>
10
+ <WebcamIcon/>
36
11
  {{ title }}
37
12
  </h2>
38
13
 
@@ -115,23 +90,6 @@ onMounted(() => {
115
90
  font-weight: 600;
116
91
  }
117
92
 
118
- .webcam-header__icon {
119
- display: grid;
120
- flex-shrink: 0;
121
- place-items: center;
122
- width: 24px;
123
- height: 24px;
124
- color: $webcam-header-icon;
125
- background-color: $webcam-header-icon-bg;
126
- border-radius: $radius-md;
127
-
128
- & > svg {
129
- width: 16px;
130
- height: 16px;
131
- stroke-width: 2;
132
- }
133
- }
134
-
135
93
  .webcam-header__promo {
136
94
  @include font-sm;
137
95
 
@@ -0,0 +1,49 @@
1
+ <!-- Иконка вебкамов в плашке — знак раздела: стоит перед заголовком блока на
2
+ листингах и перед заголовком выдачи в разделе `/webcams`. Нарисована руками:
3
+ в спрайте проекта камеры такого вида (объектив на штативе) нет. -->
4
+ <template>
5
+ <span class="webcam-icon">
6
+ <svg
7
+ viewBox="0 0 24 24"
8
+ fill="none"
9
+ stroke="currentColor"
10
+ stroke-width="2"
11
+ stroke-linecap="round"
12
+ stroke-linejoin="round"
13
+ aria-hidden="true"
14
+ >
15
+ <circle
16
+ cx="12"
17
+ cy="9"
18
+ r="6.5"
19
+ />
20
+ <circle
21
+ cx="12"
22
+ cy="9"
23
+ r="2.5"
24
+ fill="currentColor"
25
+ />
26
+ <path d="M12 15.5V19"/>
27
+ <path d="M8 20h8"/>
28
+ </svg>
29
+ </span>
30
+ </template>
31
+
32
+ <style lang="scss">
33
+ .webcam-icon {
34
+ display: grid;
35
+ flex-shrink: 0;
36
+ place-items: center;
37
+ width: 24px;
38
+ height: 24px;
39
+ color: $webcam-icon-text;
40
+ background-color: $webcam-icon-bg;
41
+ border-radius: $radius-6;
42
+
43
+ & > svg {
44
+ width: 16px;
45
+ height: 16px;
46
+ stroke-width: 2;
47
+ }
48
+ }
49
+ </style>
@@ -2,16 +2,11 @@
2
2
  провайдера. Стоит после основного листинга — как на боевом purple-haze.
3
3
 
4
4
  Слои ответственности (чтобы дизайн и провайдер менялись независимо):
5
- section (этот файл) — где блок стоит, сколько тумб, заголовок и счётчики;
6
- адаптер провайдера — приводит его формат к нормализованной модели;
5
+ main (этот файл) — где блок стоит, сколько тумб, заголовок и счётчики;
6
+ grid — раскладка и выбор обёртки провайдера;
7
7
  card — дизайн и поведение тумбы.
8
8
 
9
- Адаптер выбирается ПО КОНФИГУ: имя компонента-обёртки берётся из мапы
10
- `lib/webcam-adapters.ts` и рендерится через `<component :is>`. Здесь нет ни
11
- импортов обёрток, ни условий по провайдеру — сменить фид можно значением
12
- `webcamsProvider` (config/webcams.config.ts или env), не трогая блок.
13
-
14
- Сетка повторяет основной грид сайта (2 / 3 / 4 колонки), а неполная последняя
9
+ Сетка (общая с разделом вебкамов) повторяет основной грид сайта, а неполная последняя
15
10
  строка не рисуется вовсе: дырок в ней быть не должно, поэтому число тумб
16
11
  округляется вниз до кратного числу колонок, а на десктопе ограничено тремя
17
12
  линиями. Всё под флагом `FeatureWebcamsEnabled`. -->
@@ -22,29 +17,17 @@
22
17
  >
23
18
  <WebcamHeader :counts="counts"/>
24
19
 
25
- <ul class="webcam-main__grid">
26
- <li
27
- v-for="(model, index) in visibleItems"
28
- :key="`webcam-${model.id || model.username}`"
29
- class="webcam-main__item"
30
- >
31
- <component
32
- :is="adapter"
33
- :item="model"
34
- :touch-preview="isMobile"
35
- :first-lang="locale"
36
- @click="onModelClick(model, index)"
37
- @poster="element => onModelPoster(element, model, index)"
38
- />
39
- </li>
40
- </ul>
20
+ <GridWebcams
21
+ :items="visibleItems"
22
+ @model-click="payload => emit('model-click', payload)"
23
+ @model-view="payload => emit('model-view', payload)"
24
+ />
41
25
  </section>
42
26
  </template>
43
27
 
44
28
  <script setup lang="ts">
45
29
  import type { IStripcashModel, IWebcamsConfig } from '../../types';
46
30
  import { WEBCAMS_DEFAULTS } from '../../runtime';
47
- import { resolveWebcamAdapter } from '../../utils/webcams/adapters';
48
31
 
49
32
  const props = defineProps<{
50
33
  /** Сколько моделей просить у провайдера; по умолчанию — из конфига сайта. */
@@ -64,13 +47,6 @@ const emit = defineEmits<{
64
47
 
65
48
  const webcamsConfig = computed(() => (useRuntimeConfig().public.webcamsConfig || {}) as IWebcamsConfig);
66
49
 
67
- const { locale } = useI18n();
68
- const isMobile = useState<boolean>('isMobile');
69
-
70
- /** Обёртка активного провайдера: ленивый импорт из мапы адаптеров. */
71
- const provider = computed(() => String(webcamsConfig.value.provider || WEBCAMS_DEFAULTS.provider));
72
- const adapter = computed(() => defineAsyncComponent(resolveWebcamAdapter(provider.value)));
73
-
74
50
  const { items, counts, isEnabled } = await useFetchWebcams({
75
51
  limit: props.limit || webcamsConfig.value.limit || WEBCAMS_DEFAULTS.limit,
76
52
  category: props.category,
@@ -106,14 +82,6 @@ function syncColumns() {
106
82
  columns.value = MEDIA_COLUMNS.find(item => window.matchMedia(item.query).matches)?.columns || 2;
107
83
  }
108
84
 
109
- function onModelClick(model: IStripcashModel, index: number) {
110
- emit('model-click', { model, index });
111
- }
112
-
113
- function onModelPoster(element: HTMLElement, model: IStripcashModel, index: number) {
114
- emit('model-view', { model, index, element });
115
- }
116
-
117
85
  onMounted(() => {
118
86
  syncColumns();
119
87
  window.addEventListener('resize', syncColumns, { passive: true });
@@ -130,26 +98,4 @@ onBeforeUnmount(() => {
130
98
  row-gap: 20px;
131
99
  margin-bottom: 40px;
132
100
  }
133
-
134
- .webcam-main__grid {
135
- display: grid;
136
- gap: 12px;
137
- grid-template-columns: repeat(2, minmax(0, 1fr));
138
-
139
- @include from-br(xs) {
140
- grid-template-columns: repeat(3, minmax(0, 1fr));
141
- }
142
-
143
- @include from-br(xm) {
144
- gap: 24px;
145
- }
146
-
147
- @include from-br(md) {
148
- grid-template-columns: repeat(4, minmax(0, 1fr));
149
- }
150
- }
151
-
152
- .webcam-main__item {
153
- min-width: 0;
154
- }
155
101
  </style>