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.
- package/assets/scss/themes/dark/_theme.scss +31 -5
- package/assets/scss/themes/light/_theme.scss +31 -5
- package/assets/scss/vars/_palette.scss +3 -0
- package/components/card/webcam/index.vue +141 -15
- package/components/card/webcam/languages.vue +7 -3
- package/components/filter/model.vue +56 -1
- package/components/grid/webcams.vue +78 -0
- package/components/player/core.vue +37 -1
- package/components/ui/switcher.vue +114 -0
- package/components/view/webcams.vue +370 -0
- package/components/webcam/header.vue +1 -43
- package/components/webcam/icon.vue +49 -0
- package/components/webcam/main.vue +8 -62
- package/components/webcam/stripcash-card.vue +2 -2
- package/composables/fetch/use-fetch-models-count.ts +20 -0
- package/composables/fetch/use-fetch-webcam-models.ts +45 -0
- package/lib/index.ts +1 -0
- package/lib/scheme/sort-webcams.ts +22 -0
- package/package.json +1 -1
- package/pages/webcams/[[tag]].vue +75 -0
- package/server/api/models/count.get.ts +18 -0
- package/server/api/webcams/list.post.ts +41 -0
- package/services/api/models.service.ts +20 -0
- package/services/api/webcams.service.ts +34 -0
- package/types/index.d.ts +3 -0
- package/types/switcher-item.d.ts +13 -0
- package/types/webcam-facet.d.ts +7 -0
- package/types/webcam-models-response.d.ts +12 -0
- package/utils/webcams/__tests__/count-filters.test.ts +12 -0
- package/utils/webcams/__tests__/normalize-facets.test.ts +52 -0
- package/utils/webcams/__tests__/to-filter-payload.test.ts +17 -0
- package/utils/webcams/count-filters.ts +14 -0
- package/utils/webcams/facets.ts +31 -0
- package/utils/webcams/normalize-facets.ts +57 -0
- 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
|
-
<
|
|
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
|
-
|
|
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
|
-
<
|
|
26
|
-
|
|
27
|
-
|
|
28
|
-
|
|
29
|
-
|
|
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>
|