itube-specs 0.0.936 → 0.0.939
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 +11 -11
- package/assets/scss/themes/light/_theme.scss +8 -8
- package/assets/scss/vars/_tokens.scss +1 -1
- package/components/auth/recovery.vue +0 -1
- package/components/auth/register.vue +0 -1
- package/components/card/video/actions.vue +2 -2
- package/components/filter/chips-row.vue +56 -0
- package/components/filter/chips.vue +48 -28
- package/components/filter/main.vue +53 -106
- package/components/filter/model.vue +25 -71
- package/components/filter/popup.vue +11 -15
- package/components/filter/selects.vue +41 -12
- package/components/filter/videos-chips.vue +51 -20
- package/components/grid/videos.vue +143 -39
- package/components/grid/webcams.vue +6 -20
- package/components/layout/footer-language.vue +19 -1
- package/components/layout/language.vue +21 -6
- package/components/report/main.vue +1 -3
- package/components/section/toolbar.vue +8 -0
- package/components/ui/chips.vue +1 -2
- package/components/ui/popup.vue +32 -21
- package/components/ui/snackbar.vue +1 -1
- package/components/view/models.vue +14 -2
- package/components/view/video.vue +12 -1
- package/components/view/videos.vue +54 -55
- package/components/webcam/thumb.vue +40 -0
- package/composables/__tests__/use-model-filter-chips.test.ts +8 -4
- package/composables/fetch/use-fetch-quick-filter-groups.ts +35 -0
- package/composables/fetch/use-fetch-webcams.ts +5 -2
- package/composables/use-filter-pending.ts +72 -0
- package/composables/use-model-filter-chips.ts +5 -10
- package/composables/use-recaptcha.ts +0 -2
- package/package.json +1 -1
- package/pages/contacts.vue +1 -3
- package/pages/index.vue +8 -16
- package/pages/search/[slug]/videos.vue +0 -1
- package/pages/tag/[tag].vue +0 -4
- package/runtime/constants/async-data.ts +1 -1
- package/server/api/categories/quick-groups.get.ts +37 -0
- package/services/api/categories.service.ts +21 -22
- package/types/filter-chip-item.d.ts +12 -0
- package/types/index.d.ts +2 -0
- package/types/model-filter.d.ts +5 -1
- package/types/quick-filter.d.ts +22 -0
- package/utils/filter-chips/__tests__/build-chip-parts.test.ts +28 -0
- package/utils/filter-chips/build-chip-parts.ts +19 -0
- package/utils/filter-model/__tests__/build-model-filter-query.test.ts +48 -0
- package/utils/filter-model/build-model-filter-query.ts +22 -0
- package/utils/filter-model/parse-model-filter-values.ts +19 -0
- package/utils/filter-selects/__tests__/build-categories-query.test.ts +52 -0
- package/utils/filter-selects/__tests__/to-quick-filters.test.ts +82 -0
- package/utils/filter-selects/build-categories-query.ts +32 -0
- package/utils/filter-selects/to-quick-filters.ts +47 -0
- package/utils/webcams/__tests__/insert-positions.test.ts +45 -0
- package/utils/webcams/insert-positions.ts +54 -0
- package/composables/fetch/use-fetch-top-random-categories.ts +0 -21
|
@@ -1,17 +1,11 @@
|
|
|
1
1
|
<!-- Контейнер фильтра моделей с отложенным применением (pending) перед навигацией -->
|
|
2
2
|
<template>
|
|
3
3
|
<div class="filter-model">
|
|
4
|
-
<
|
|
4
|
+
<FilterChipsRow
|
|
5
5
|
:items="chipsItems"
|
|
6
6
|
@click="onChipsClick"
|
|
7
|
-
|
|
8
|
-
|
|
9
|
-
v-if="chipsItems.length > 1"
|
|
10
|
-
class="filter-model__clear"
|
|
11
|
-
type="button"
|
|
12
|
-
@click="onClearAllClick"
|
|
13
|
-
>{{ t('reset_all') }}</button>
|
|
14
|
-
</FilterChips>
|
|
7
|
+
@clear="onClearAllClick"
|
|
8
|
+
/>
|
|
15
9
|
<transition appear>
|
|
16
10
|
<FilterPopup
|
|
17
11
|
v-model="model"
|
|
@@ -23,7 +17,6 @@
|
|
|
23
17
|
models-page
|
|
24
18
|
:loading="status === 'pending'"
|
|
25
19
|
@chip-click="onPendingChipsClick"
|
|
26
|
-
@field-update="onFieldUpdate"
|
|
27
20
|
>
|
|
28
21
|
<template #quick-field="{ item, index }">
|
|
29
22
|
<FilterModelField
|
|
@@ -72,9 +65,11 @@
|
|
|
72
65
|
</template>
|
|
73
66
|
|
|
74
67
|
<script setup lang="ts">
|
|
75
|
-
import type {
|
|
68
|
+
import type { IFilterChipItem, IModelCard, IModelFilter, PaginatedResponse } from '../../types';
|
|
76
69
|
import type { LocationQuery } from '#vue-router';
|
|
77
70
|
import { getSortedFilterGroups } from '../../utils/get-sorted-filter-groups';
|
|
71
|
+
import { parseModelFilterValues } from '../../utils/filter-model/parse-model-filter-values';
|
|
72
|
+
import { buildModelFilterQuery } from '../../utils/filter-model/build-model-filter-query';
|
|
78
73
|
|
|
79
74
|
const props = defineProps<{
|
|
80
75
|
data: PaginatedResponse<IModelCard>,
|
|
@@ -93,12 +88,18 @@ const EXCLUDED_GROUP_NAMES = ['tags', 'quick'];
|
|
|
93
88
|
|
|
94
89
|
const filters = computed<IModelFilter[]>(() => (props.data as any).filters ?? []);
|
|
95
90
|
|
|
96
|
-
const
|
|
97
|
-
|
|
98
|
-
|
|
99
|
-
|
|
100
|
-
|
|
101
|
-
|
|
91
|
+
const {
|
|
92
|
+
values: pendingQuery,
|
|
93
|
+
isFiltered: isPendingFiltered,
|
|
94
|
+
remove: removeField,
|
|
95
|
+
replace: replacePending,
|
|
96
|
+
reset: onResetClick,
|
|
97
|
+
apply: onApplyClick,
|
|
98
|
+
} = useFilterPending({
|
|
99
|
+
open: model,
|
|
100
|
+
decode: parseModelFilterValues,
|
|
101
|
+
encode: buildModelFilterQuery,
|
|
102
|
+
navigate: 'replace',
|
|
102
103
|
});
|
|
103
104
|
|
|
104
105
|
const pendingRouteLike = reactive({
|
|
@@ -110,8 +111,6 @@ const pendingRouteLike = reactive({
|
|
|
110
111
|
const { chipsItems } = useFilterChipsItems(filters, route, t, locale);
|
|
111
112
|
const { chipsItems: pendingChipsItems } = useFilterChipsItems(filters, pendingRouteLike, t, locale);
|
|
112
113
|
|
|
113
|
-
const isPendingFiltered = computed(() => Object.keys(pendingQuery.value).some(item => item.startsWith('filter')));
|
|
114
|
-
|
|
115
114
|
const quickFilters = computed(() =>
|
|
116
115
|
filters.value
|
|
117
116
|
.filter((f): f is IModelFilter => f.group.name === 'quick'),
|
|
@@ -158,13 +157,11 @@ const unitItems = computed(() => [
|
|
|
158
157
|
]);
|
|
159
158
|
|
|
160
159
|
watch(units, () => {
|
|
161
|
-
const next = { ...pendingQuery.value };
|
|
162
160
|
const unitFields = ['height_cm', 'height_in', 'weight_kg', 'weight_lb'];
|
|
163
161
|
for (const field of unitFields) {
|
|
164
|
-
|
|
165
|
-
|
|
162
|
+
removeField(`filter_${field}_from`);
|
|
163
|
+
removeField(`filter_${field}_to`);
|
|
166
164
|
}
|
|
167
|
-
pendingQuery.value = next;
|
|
168
165
|
});
|
|
169
166
|
|
|
170
167
|
/** Быстрые поля в модалку приходят уже маппингом — исходный фильтр ищем по имени. */
|
|
@@ -180,17 +177,7 @@ function selectItems(item: IModelFilter) {
|
|
|
180
177
|
}
|
|
181
178
|
|
|
182
179
|
function onQueryUpdate(newQuery: LocationQuery) {
|
|
183
|
-
|
|
184
|
-
}
|
|
185
|
-
|
|
186
|
-
function updatePendingFilter(name: string, val: any) {
|
|
187
|
-
const next = { ...pendingQuery.value };
|
|
188
|
-
if (!val || val === 'all') {
|
|
189
|
-
delete next[`filter_${name}`];
|
|
190
|
-
} else {
|
|
191
|
-
next[`filter_${name}`] = val;
|
|
192
|
-
}
|
|
193
|
-
pendingQuery.value = next;
|
|
180
|
+
replacePending(parseModelFilterValues(newQuery));
|
|
194
181
|
}
|
|
195
182
|
|
|
196
183
|
function isPendingActive(name: string) {
|
|
@@ -211,7 +198,7 @@ function getPendingValue(name: string) {
|
|
|
211
198
|
}
|
|
212
199
|
|
|
213
200
|
|
|
214
|
-
function onChipsClick(item:
|
|
201
|
+
function onChipsClick(item: IFilterChipItem) {
|
|
215
202
|
const query = { ...route.query };
|
|
216
203
|
if (Array.isArray(item.value) && item.value.length > 1) {
|
|
217
204
|
item.value.forEach((text: string) => {
|
|
@@ -223,24 +210,9 @@ function onChipsClick(item: IChipsItem) {
|
|
|
223
210
|
router.replace({ query });
|
|
224
211
|
}
|
|
225
212
|
|
|
226
|
-
function onPendingChipsClick(item:
|
|
227
|
-
const
|
|
228
|
-
|
|
229
|
-
item.value.forEach((text: string) => {
|
|
230
|
-
delete next[`${text}`];
|
|
231
|
-
});
|
|
232
|
-
} else {
|
|
233
|
-
delete next[`${item.value}`];
|
|
234
|
-
}
|
|
235
|
-
pendingQuery.value = next;
|
|
236
|
-
}
|
|
237
|
-
|
|
238
|
-
function onFieldUpdate({ name, value }: { name: string, value: any }) {
|
|
239
|
-
updatePendingFilter(name, value);
|
|
240
|
-
}
|
|
241
|
-
|
|
242
|
-
function onResetClick() {
|
|
243
|
-
pendingQuery.value = {};
|
|
213
|
+
function onPendingChipsClick(item: IFilterChipItem) {
|
|
214
|
+
const names = Array.isArray(item.value) ? item.value : [String(item.value)];
|
|
215
|
+
names.forEach((name: string) => removeField(name));
|
|
244
216
|
}
|
|
245
217
|
|
|
246
218
|
/** «Сбросить всё» в облаке чипсов: снимает фильтры, сортировку не трогает. */
|
|
@@ -253,11 +225,6 @@ function onClearAllClick() {
|
|
|
253
225
|
|
|
254
226
|
router.replace({ query });
|
|
255
227
|
}
|
|
256
|
-
|
|
257
|
-
function onApplyClick() {
|
|
258
|
-
model.value = false;
|
|
259
|
-
router.replace({ query: pendingQuery.value });
|
|
260
|
-
}
|
|
261
228
|
</script>
|
|
262
229
|
|
|
263
230
|
<style lang="scss">
|
|
@@ -265,19 +232,6 @@ function onApplyClick() {
|
|
|
265
232
|
margin-top: 8px;
|
|
266
233
|
}
|
|
267
234
|
|
|
268
|
-
// в облаке чипсов кнопка идёт последней, а слот `FilterChips` кладёт её первой
|
|
269
|
-
.filter-model__clear {
|
|
270
|
-
@include font-sm;
|
|
271
|
-
|
|
272
|
-
order: 1;
|
|
273
|
-
padding: 6px 8px;
|
|
274
|
-
color: $filter-model-clear-text;
|
|
275
|
-
|
|
276
|
-
&:is(:hover, :focus) {
|
|
277
|
-
color: $filter-model-clear-text--hover;
|
|
278
|
-
}
|
|
279
|
-
}
|
|
280
|
-
|
|
281
235
|
// диапазон в сетке быстрых фильтров занимает обе колонки: слайдеру нужна вся
|
|
282
236
|
// ширина под шкалу и подписи границ
|
|
283
237
|
.filter-popup__grid > .filter-model-field__slider {
|
|
@@ -26,13 +26,12 @@
|
|
|
26
26
|
>
|
|
27
27
|
<span class="filter-popup__active-filters-title">{{ t('filter_popup.active_title') }}</span>
|
|
28
28
|
<div class="filter-popup__active-chips">
|
|
29
|
-
<
|
|
29
|
+
<FilterChips
|
|
30
30
|
v-for="(chip, index) in activeChips"
|
|
31
31
|
:key="`s-filter-popup-active-${index}`"
|
|
32
|
-
:index="`s-filter-popup-chips-${index}`"
|
|
33
32
|
:item="chip"
|
|
34
33
|
class="filter-popup__chips"
|
|
35
|
-
|
|
34
|
+
without-label
|
|
36
35
|
@click="$emit('chip-click', chip)"
|
|
37
36
|
/>
|
|
38
37
|
</div>
|
|
@@ -126,7 +125,7 @@
|
|
|
126
125
|
</template>
|
|
127
126
|
|
|
128
127
|
<script setup lang="ts" generic="TField">
|
|
129
|
-
import type {
|
|
128
|
+
import type { IFilterChipItem } from '../../types';
|
|
130
129
|
|
|
131
130
|
export interface FilterGroup<T> {
|
|
132
131
|
key: string
|
|
@@ -139,7 +138,7 @@ export interface FilterGroup<T> {
|
|
|
139
138
|
defineProps<{
|
|
140
139
|
title?: string
|
|
141
140
|
activeCount?: number
|
|
142
|
-
activeChips?:
|
|
141
|
+
activeChips?: IFilterChipItem[]
|
|
143
142
|
quick?: TField[]
|
|
144
143
|
groups?: FilterGroup<TField>[]
|
|
145
144
|
loading?: boolean
|
|
@@ -147,7 +146,7 @@ defineProps<{
|
|
|
147
146
|
}>();
|
|
148
147
|
|
|
149
148
|
defineEmits<{
|
|
150
|
-
(e: 'chip-click', item:
|
|
149
|
+
(e: 'chip-click', item: IFilterChipItem): void
|
|
151
150
|
(e: 'field-update', payload: { name: string, value: any }): void
|
|
152
151
|
}>();
|
|
153
152
|
|
|
@@ -225,22 +224,19 @@ const { t } = useI18n();
|
|
|
225
224
|
gap: 8px;
|
|
226
225
|
}
|
|
227
226
|
|
|
228
|
-
.
|
|
229
|
-
|
|
230
|
-
|
|
231
|
-
border-radius: $radius-lg;
|
|
232
|
-
border: 1px solid $filter-popup-active-chip-border;
|
|
227
|
+
// на подложке попапа своя палитра, геометрия — общая из .filter-chip
|
|
228
|
+
.filter-chips.filter-popup__chips {
|
|
229
|
+
border-color: $filter-popup-active-chip-border;
|
|
233
230
|
background-color: $filter-popup-active-chip-bg;
|
|
234
231
|
color: $filter-popup-active-chip-text;
|
|
235
|
-
pointer-events: all;
|
|
236
232
|
|
|
237
|
-
@include hover(background-color) {
|
|
233
|
+
@include hover(background-color, border-color) {
|
|
238
234
|
background-color: $filter-popup-active-chip-bg--hover;
|
|
235
|
+
border-color: $filter-popup-active-chip-border;
|
|
239
236
|
}
|
|
240
237
|
}
|
|
241
238
|
|
|
242
|
-
.
|
|
243
|
-
pointer-events: all;
|
|
239
|
+
.filter-popup__chips .filter-chips__close {
|
|
244
240
|
color: $filter-popup-active-chip-close;
|
|
245
241
|
}
|
|
246
242
|
|
|
@@ -7,7 +7,7 @@
|
|
|
7
7
|
>
|
|
8
8
|
<div
|
|
9
9
|
v-if="item.kind==='select'"
|
|
10
|
-
:class="{'_from-sm': !
|
|
10
|
+
:class="{'_from-sm': !isMobileVisible(item)}"
|
|
11
11
|
class="filter-selects__wrapper"
|
|
12
12
|
>
|
|
13
13
|
<!-- Прозрачный select -->
|
|
@@ -24,7 +24,7 @@
|
|
|
24
24
|
>
|
|
25
25
|
<hr v-if="option.name === 'unspecified' && item.options.length > 1">
|
|
26
26
|
<option :value="option.name">
|
|
27
|
-
{{
|
|
27
|
+
{{ getOptionLabel(option) }}
|
|
28
28
|
</option>
|
|
29
29
|
</template>
|
|
30
30
|
</select>
|
|
@@ -46,7 +46,7 @@
|
|
|
46
46
|
<div
|
|
47
47
|
v-if="item.kind==='range'"
|
|
48
48
|
:ref="el => sliderRefs[index] = el"
|
|
49
|
-
:class="{'_from-sm': !
|
|
49
|
+
:class="{'_from-sm': !isMobileVisible(item)}"
|
|
50
50
|
class="filter-selects__wrapper"
|
|
51
51
|
>
|
|
52
52
|
<UiBtn
|
|
@@ -106,18 +106,30 @@
|
|
|
106
106
|
</template>
|
|
107
107
|
|
|
108
108
|
<script setup lang="ts">
|
|
109
|
-
import type { IModelFilter } from '../../types';
|
|
109
|
+
import type { IModelFilter, IModelFilterOptions } from '../../types';
|
|
110
110
|
import { convertString, checkDeviceWidth } from '../../runtime';
|
|
111
111
|
import { CSS_BREAKPOINTS } from '../../config/css-breakpoints';
|
|
112
|
+
import { parseFilterCategories } from '../../utils/parse-filter-categories';
|
|
113
|
+
import { parseCategoryFilterValues } from '../../utils/filter-main/parse-category-filter-values';
|
|
114
|
+
import { buildCategoriesQuery } from '../../utils/filter-selects/build-categories-query';
|
|
112
115
|
|
|
113
116
|
const props = defineProps<{
|
|
114
117
|
filters: IModelFilter[]
|
|
118
|
+
/** Где живёт состояние: свой query-параметр `filter_<name>` (модели, вебкамы) или общая
|
|
119
|
+
* строка `categories=<группа>_<значение>` (каталог видео, синхронно с попапом фильтра). */
|
|
120
|
+
mode?: 'param' | 'categories'
|
|
115
121
|
}>();
|
|
116
122
|
|
|
117
123
|
const route = useRoute();
|
|
118
124
|
const router = useRouter();
|
|
119
125
|
|
|
120
|
-
const
|
|
126
|
+
const isCategoriesMode = computed(() => props.mode === 'categories');
|
|
127
|
+
|
|
128
|
+
// В мобильную строку влезает не всё, остальное остаётся в попапе фильтра. Какие фильтры
|
|
129
|
+
// в ней оставить — знает только вызывающая сторона: набор зависит и от раздела, и от ниши.
|
|
130
|
+
function isMobileVisible(item: IModelFilter) {
|
|
131
|
+
return !!item.showOnMobile;
|
|
132
|
+
}
|
|
121
133
|
|
|
122
134
|
const mainFilters = computed(() => {
|
|
123
135
|
return props.filters.filter(item => item.group.name === 'quick');
|
|
@@ -128,11 +140,15 @@ const selectItems = computed(() => mainFilters.value.filter(item => item.kind ==
|
|
|
128
140
|
const selectValues = ref<Record<string, string>>({});
|
|
129
141
|
const sliderRefs = ref<(HTMLDivElement | null)[]>([]);
|
|
130
142
|
|
|
143
|
+
function readQueryValue(name: string, query: Record<string, any>) {
|
|
144
|
+
if (isCategoriesMode.value) return parseCategoryFilterValues(parseFilterCategories(query))[name];
|
|
145
|
+
return query[`filter_${name}`] as string | undefined;
|
|
146
|
+
}
|
|
147
|
+
|
|
131
148
|
function getSelectValue(name: string, query: Record<string, any>) {
|
|
132
149
|
const item = selectItems.value.find(i => i.name === name);
|
|
133
150
|
if (!item) return '';
|
|
134
|
-
|
|
135
|
-
return value ?? item.options[0]?.name ?? '';
|
|
151
|
+
return readQueryValue(name, query) ?? item.options[0]?.name ?? '';
|
|
136
152
|
}
|
|
137
153
|
|
|
138
154
|
onMounted(() => {
|
|
@@ -144,21 +160,26 @@ onMounted(() => {
|
|
|
144
160
|
// Проверка, активен ли фильтр
|
|
145
161
|
function isActive(name: string) {
|
|
146
162
|
const baseName = name.replace(/_(from|to)$/, '');
|
|
163
|
+
if (isCategoriesMode.value) return !!readQueryValue(baseName, route.query);
|
|
147
164
|
return Object.keys(route.query).some(key => key.startsWith(`filter_${baseName}`));
|
|
148
165
|
}
|
|
149
166
|
|
|
150
167
|
// Хендлер изменения select
|
|
151
168
|
function onChange(name: string) {
|
|
152
169
|
const val = selectValues.value[name];
|
|
153
|
-
const
|
|
170
|
+
const isReset = !val || val === 'all';
|
|
171
|
+
let query: Record<string, any> = { ...route.query };
|
|
154
172
|
|
|
155
|
-
if (
|
|
173
|
+
if (isCategoriesMode.value) {
|
|
174
|
+
query = buildCategoriesQuery(route.query, name, val);
|
|
175
|
+
} else if (isReset) {
|
|
156
176
|
delete query[`filter_${name}`];
|
|
157
|
-
selectValues.value[name] = getSelectValue(name, query);
|
|
158
177
|
} else {
|
|
159
178
|
query[`filter_${name}`] = val;
|
|
160
179
|
}
|
|
161
180
|
|
|
181
|
+
if (isReset) selectValues.value[name] = getSelectValue(name, query);
|
|
182
|
+
|
|
162
183
|
router.replace({ query });
|
|
163
184
|
}
|
|
164
185
|
|
|
@@ -276,11 +297,17 @@ function capitalize(text: string) {
|
|
|
276
297
|
}
|
|
277
298
|
|
|
278
299
|
function getSelectLabel(item: IModelFilter) {
|
|
279
|
-
const current =
|
|
300
|
+
const current = readQueryValue(item.name, route.query);
|
|
280
301
|
if (!current) return item.title;
|
|
281
302
|
const opt = item.options.find(o => o.name === current);
|
|
282
303
|
return opt ? capitalize(opt.title) : item.title;
|
|
283
304
|
}
|
|
305
|
+
|
|
306
|
+
// Количество бек считает не для всех опций (у синтетической «all» его нет) — скобки без числа не рисуем
|
|
307
|
+
function getOptionLabel(option: IModelFilterOptions) {
|
|
308
|
+
const title = capitalize(option.title);
|
|
309
|
+
return typeof option.quantity === 'number' ? `${title} (${option.quantity})` : title;
|
|
310
|
+
}
|
|
284
311
|
</script>
|
|
285
312
|
|
|
286
313
|
<style lang="scss">
|
|
@@ -333,7 +360,9 @@ function getSelectLabel(item: IModelFilter) {
|
|
|
333
360
|
text-transform: none;
|
|
334
361
|
}
|
|
335
362
|
|
|
336
|
-
.
|
|
363
|
+
// .ui-btn в селекторе не для красоты: без него специфичность равна .ui-btn.--secondary,
|
|
364
|
+
// который правит те же свойства, и кто победит — решает порядок блоков в бандле
|
|
365
|
+
.ui-btn.filter-selects__select-button.--active-select {
|
|
337
366
|
padding: 6px 14px;
|
|
338
367
|
border: 1px solid $filters-main-select-border--active;
|
|
339
368
|
color: $filters-main-select-text--active;
|
|
@@ -1,16 +1,20 @@
|
|
|
1
1
|
<!-- Чипсы фильтра видео (категории/длительность/дата) с парсингом из query -->
|
|
2
2
|
<template>
|
|
3
|
-
<
|
|
3
|
+
<FilterChipsRow
|
|
4
4
|
:items="chipsItems"
|
|
5
|
-
@click="(e:
|
|
5
|
+
@click="(e: IFilterChipItem) => onChipsClick(e)"
|
|
6
|
+
@clear="onClearAllClick"
|
|
6
7
|
>
|
|
7
8
|
<slot/>
|
|
8
|
-
</
|
|
9
|
+
</FilterChipsRow>
|
|
9
10
|
</template>
|
|
10
11
|
|
|
11
12
|
<script setup lang="ts">
|
|
12
|
-
import type {
|
|
13
|
+
import type { IFilterChipItem, ISelectItem, IFilterScheme } from '../../types';
|
|
14
|
+
import type { LocationQuery } from '#vue-router';
|
|
13
15
|
import { parseFilterCategories } from '../../utils/parse-filter-categories';
|
|
16
|
+
import { buildChipParts } from '../../utils/filter-chips/build-chip-parts';
|
|
17
|
+
import { convertString } from '../../runtime';
|
|
14
18
|
|
|
15
19
|
const route = useRoute();
|
|
16
20
|
const router = useRouter();
|
|
@@ -22,8 +26,24 @@ const props = defineProps<{
|
|
|
22
26
|
scheme: IFilterScheme[]
|
|
23
27
|
}>();
|
|
24
28
|
|
|
29
|
+
const quickFilterScheme = useAppConfig().quickVideoFilters || [];
|
|
30
|
+
|
|
31
|
+
const findGroup = (name: string) => props.scheme.find(item => item.name.toLowerCase() === name.toLowerCase());
|
|
32
|
+
|
|
33
|
+
/**
|
|
34
|
+
* Название группы для подписи чипсы. Сначала короткий заголовок из конфига сайта — тот же,
|
|
35
|
+
* что стоит на кнопке в строке главных фильтров, иначе название группы с бека. Схема грузится
|
|
36
|
+
* лениво, и до неё остаётся только ключ из query.
|
|
37
|
+
*/
|
|
38
|
+
function groupLabel(name: string) {
|
|
39
|
+
const quick = quickFilterScheme.find(item => item.name.toLowerCase() === name.toLowerCase());
|
|
40
|
+
if (quick?.titleKey) return t(quick.titleKey);
|
|
41
|
+
|
|
42
|
+
return findGroup(name)?.title || convertString().toCapitalize(name);
|
|
43
|
+
}
|
|
44
|
+
|
|
25
45
|
const chipsItems = computed(() => {
|
|
26
|
-
const items:
|
|
46
|
+
const items: IFilterChipItem[] = [];
|
|
27
47
|
|
|
28
48
|
// categories
|
|
29
49
|
const categories = parseFilterCategories(route.query);
|
|
@@ -31,12 +51,10 @@ const chipsItems = computed(() => {
|
|
|
31
51
|
categories.forEach(cat => {
|
|
32
52
|
const value = cat.split('_')[1] ?? cat;
|
|
33
53
|
const groupCategory = cat.split('_')[0];
|
|
34
|
-
const title =
|
|
35
|
-
return item.name.toLowerCase() === groupCategory.toLowerCase();
|
|
36
|
-
})?.items.find((item: ISelectItem) => item.value === value)?.title;
|
|
54
|
+
const title = findGroup(groupCategory)?.items.find((item: ISelectItem) => item.value === value)?.title;
|
|
37
55
|
|
|
38
56
|
items.push({
|
|
39
|
-
|
|
57
|
+
...buildChipParts(groupLabel(groupCategory), title || convertString().toCapitalize(value)),
|
|
40
58
|
value: cat,
|
|
41
59
|
key: 'categories'
|
|
42
60
|
});
|
|
@@ -48,7 +66,7 @@ const chipsItems = computed(() => {
|
|
|
48
66
|
if (durationParam) {
|
|
49
67
|
const title = props.durationItems.find(item => item.value === durationParam)?.title;
|
|
50
68
|
items.push({
|
|
51
|
-
|
|
69
|
+
...buildChipParts(t('duration'), t(title || '')),
|
|
52
70
|
value: String(durationParam),
|
|
53
71
|
key: 'duration'
|
|
54
72
|
});
|
|
@@ -59,7 +77,7 @@ const chipsItems = computed(() => {
|
|
|
59
77
|
if (addedParam) {
|
|
60
78
|
const title = props.addedItems.find(item => item.value === addedParam)?.title;
|
|
61
79
|
items.push({
|
|
62
|
-
|
|
80
|
+
...buildChipParts(t('added'), t(title || '')),
|
|
63
81
|
value: String(addedParam),
|
|
64
82
|
key: 'added'
|
|
65
83
|
});
|
|
@@ -68,17 +86,14 @@ const chipsItems = computed(() => {
|
|
|
68
86
|
return items;
|
|
69
87
|
});
|
|
70
88
|
|
|
71
|
-
|
|
72
|
-
|
|
73
|
-
|
|
89
|
+
// Снятие одной чипсы и «сбросить всё» — одна и та же операция, поэтому правило,
|
|
90
|
+
// за какой параметр query отвечает чипса, живёт в одном месте
|
|
91
|
+
function removeChipFromQuery(query: LocationQuery, item: IFilterChipItem) {
|
|
74
92
|
if (item.key === 'categories') {
|
|
75
|
-
const categories = parseFilterCategories(
|
|
76
|
-
if (!categories.length) return;
|
|
93
|
+
const categories = parseFilterCategories(query).filter(cat => cat !== item.value);
|
|
77
94
|
|
|
78
|
-
|
|
79
|
-
|
|
80
|
-
if (updatedCategories.length > 0) {
|
|
81
|
-
query.categories = updatedCategories.join(',');
|
|
95
|
+
if (categories.length > 0) {
|
|
96
|
+
query.categories = categories.join(',');
|
|
82
97
|
} else {
|
|
83
98
|
delete query.categories;
|
|
84
99
|
}
|
|
@@ -91,6 +106,22 @@ function onChipsClick(item: IChipsItem & { key?: string }) {
|
|
|
91
106
|
if (item.key === 'added') {
|
|
92
107
|
delete query.added;
|
|
93
108
|
}
|
|
109
|
+
}
|
|
110
|
+
|
|
111
|
+
function onChipsClick(item: IFilterChipItem) {
|
|
112
|
+
const query = { ...route.query };
|
|
113
|
+
|
|
114
|
+
removeChipFromQuery(query, item);
|
|
115
|
+
|
|
116
|
+
router.replace({ query });
|
|
117
|
+
}
|
|
118
|
+
|
|
119
|
+
/** «Сбросить всё»: снимает фильтры, сортировку не трогает. */
|
|
120
|
+
function onClearAllClick() {
|
|
121
|
+
const query = { ...route.query };
|
|
122
|
+
|
|
123
|
+
chipsItems.value.forEach(item => removeChipFromQuery(query, item));
|
|
124
|
+
delete query['page'];
|
|
94
125
|
|
|
95
126
|
router.replace({ query });
|
|
96
127
|
}
|