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.
Files changed (56) hide show
  1. package/assets/scss/themes/dark/_theme.scss +11 -11
  2. package/assets/scss/themes/light/_theme.scss +8 -8
  3. package/assets/scss/vars/_tokens.scss +1 -1
  4. package/components/auth/recovery.vue +0 -1
  5. package/components/auth/register.vue +0 -1
  6. package/components/card/video/actions.vue +2 -2
  7. package/components/filter/chips-row.vue +56 -0
  8. package/components/filter/chips.vue +48 -28
  9. package/components/filter/main.vue +53 -106
  10. package/components/filter/model.vue +25 -71
  11. package/components/filter/popup.vue +11 -15
  12. package/components/filter/selects.vue +41 -12
  13. package/components/filter/videos-chips.vue +51 -20
  14. package/components/grid/videos.vue +143 -39
  15. package/components/grid/webcams.vue +6 -20
  16. package/components/layout/footer-language.vue +19 -1
  17. package/components/layout/language.vue +21 -6
  18. package/components/report/main.vue +1 -3
  19. package/components/section/toolbar.vue +8 -0
  20. package/components/ui/chips.vue +1 -2
  21. package/components/ui/popup.vue +32 -21
  22. package/components/ui/snackbar.vue +1 -1
  23. package/components/view/models.vue +14 -2
  24. package/components/view/video.vue +12 -1
  25. package/components/view/videos.vue +54 -55
  26. package/components/webcam/thumb.vue +40 -0
  27. package/composables/__tests__/use-model-filter-chips.test.ts +8 -4
  28. package/composables/fetch/use-fetch-quick-filter-groups.ts +35 -0
  29. package/composables/fetch/use-fetch-webcams.ts +5 -2
  30. package/composables/use-filter-pending.ts +72 -0
  31. package/composables/use-model-filter-chips.ts +5 -10
  32. package/composables/use-recaptcha.ts +0 -2
  33. package/package.json +1 -1
  34. package/pages/contacts.vue +1 -3
  35. package/pages/index.vue +8 -16
  36. package/pages/search/[slug]/videos.vue +0 -1
  37. package/pages/tag/[tag].vue +0 -4
  38. package/runtime/constants/async-data.ts +1 -1
  39. package/server/api/categories/quick-groups.get.ts +37 -0
  40. package/services/api/categories.service.ts +21 -22
  41. package/types/filter-chip-item.d.ts +12 -0
  42. package/types/index.d.ts +2 -0
  43. package/types/model-filter.d.ts +5 -1
  44. package/types/quick-filter.d.ts +22 -0
  45. package/utils/filter-chips/__tests__/build-chip-parts.test.ts +28 -0
  46. package/utils/filter-chips/build-chip-parts.ts +19 -0
  47. package/utils/filter-model/__tests__/build-model-filter-query.test.ts +48 -0
  48. package/utils/filter-model/build-model-filter-query.ts +22 -0
  49. package/utils/filter-model/parse-model-filter-values.ts +19 -0
  50. package/utils/filter-selects/__tests__/build-categories-query.test.ts +52 -0
  51. package/utils/filter-selects/__tests__/to-quick-filters.test.ts +82 -0
  52. package/utils/filter-selects/build-categories-query.ts +32 -0
  53. package/utils/filter-selects/to-quick-filters.ts +47 -0
  54. package/utils/webcams/__tests__/insert-positions.test.ts +45 -0
  55. package/utils/webcams/insert-positions.ts +54 -0
  56. 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
- <FilterChips
4
+ <FilterChipsRow
5
5
  :items="chipsItems"
6
6
  @click="onChipsClick"
7
- >
8
- <button
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 { IChipsItem, IModelCard, IModelFilter, PaginatedResponse } from '../../types';
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 pendingQuery = ref<LocationQuery>({ ...route.query });
97
-
98
- watch(() => model.value, (open) => {
99
- if (open) {
100
- pendingQuery.value = { ...route.query };
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
- delete next[`filter_${field}_from`];
165
- delete next[`filter_${field}_to`];
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
- pendingQuery.value = { ...newQuery };
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: IChipsItem) {
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: IChipsItem) {
227
- const next = { ...pendingQuery.value };
228
- if (Array.isArray(item.value) && item.value.length > 1) {
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
- <UiChips
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
- with-close
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 { IChipsItem } from '../../types';
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?: IChipsItem[]
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: IChipsItem): void
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
- .ui-chips.filter-popup__chips {
229
- @include font-sm;
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
- .ui-chips.filter-popup__chips .ui-chips__close {
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': !desktopOnlyFilters.includes(item.name)}"
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
- {{ `${capitalize(option.title)} (${option.quantity})` }}
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': !desktopOnlyFilters.includes(item.name)}"
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 desktopOnlyFilters = ['guy_type', 'body_build', 'ethnicity'];
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
- const value = query[`filter_${name}`] as string | undefined;
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 query = { ...route.query };
170
+ const isReset = !val || val === 'all';
171
+ let query: Record<string, any> = { ...route.query };
154
172
 
155
- if (!val || val === 'all') {
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 = route.query[`filter_${item.name}`] as string | undefined;
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
- .filter-selects__select-button.--active-select {
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
- <FilterChips
3
+ <FilterChipsRow
4
4
  :items="chipsItems"
5
- @click="(e: IChipsItem) => onChipsClick(e)"
5
+ @click="(e: IFilterChipItem) => onChipsClick(e)"
6
+ @clear="onClearAllClick"
6
7
  >
7
8
  <slot/>
8
- </FilterChips>
9
+ </FilterChipsRow>
9
10
  </template>
10
11
 
11
12
  <script setup lang="ts">
12
- import type { IChipsItem, ISelectItem, IFilterScheme } from '../../types';
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: IChipsItem[] = [];
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 = props.scheme.find(item => {
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
- title: title || '',
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
- title: t(title || ''),
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
- title: t(title || ''),
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
- function onChipsClick(item: IChipsItem & { key?: string }) {
72
- const query = { ...route.query };
73
-
89
+ // Снятие одной чипсы и «сбросить всё» — одна и та же операция, поэтому правило,
90
+ // за какой параметр query отвечает чипса, живёт в одном месте
91
+ function removeChipFromQuery(query: LocationQuery, item: IFilterChipItem) {
74
92
  if (item.key === 'categories') {
75
- const categories = parseFilterCategories(route.query);
76
- if (!categories.length) return;
93
+ const categories = parseFilterCategories(query).filter(cat => cat !== item.value);
77
94
 
78
- const updatedCategories = categories.filter(cat => cat !== item.value);
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
  }