itube-specs 0.0.935 → 0.0.938

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 (68) 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 +14 -5
  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 +54 -23
  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/burger.vue +5 -0
  17. package/components/layout/footer-language.vue +19 -3
  18. package/components/layout/header-user.vue +1 -2
  19. package/components/layout/header.vue +1 -4
  20. package/components/layout/language.vue +21 -6
  21. package/components/layout/niche.vue +1 -3
  22. package/components/report/main.vue +1 -3
  23. package/components/search/main.vue +4 -3
  24. package/components/section/toolbar.vue +8 -0
  25. package/components/ui/chips-micro.vue +1 -2
  26. package/components/ui/chips.vue +1 -2
  27. package/components/ui/dropdown.vue +2 -4
  28. package/components/ui/follow-btn.vue +10 -4
  29. package/components/ui/notification.vue +6 -1
  30. package/components/ui/popup.vue +38 -22
  31. package/components/ui/snackbar.vue +1 -1
  32. package/components/ui/tooltip.vue +4 -6
  33. package/components/view/models.vue +14 -2
  34. package/components/view/video.vue +12 -1
  35. package/components/view/videos.vue +54 -55
  36. package/components/webcam/thumb.vue +40 -0
  37. package/composables/__tests__/use-model-filter-chips.test.ts +8 -4
  38. package/composables/fetch/use-fetch-quick-filter-groups.ts +35 -0
  39. package/composables/fetch/use-fetch-webcams.ts +5 -2
  40. package/composables/use-filter-pending.ts +72 -0
  41. package/composables/use-hover-guard.ts +28 -0
  42. package/composables/use-model-filter-chips.ts +5 -10
  43. package/composables/use-overlay-close.ts +92 -0
  44. package/composables/use-recaptcha.ts +0 -2
  45. package/package.json +1 -1
  46. package/pages/contacts.vue +1 -3
  47. package/pages/index.vue +8 -16
  48. package/pages/search/[slug]/videos.vue +0 -1
  49. package/pages/tag/[tag].vue +0 -4
  50. package/runtime/constants/async-data.ts +1 -1
  51. package/server/api/categories/quick-groups.get.ts +37 -0
  52. package/services/api/categories.service.ts +21 -22
  53. package/types/filter-chip-item.d.ts +12 -0
  54. package/types/index.d.ts +2 -0
  55. package/types/model-filter.d.ts +5 -1
  56. package/types/quick-filter.d.ts +22 -0
  57. package/utils/filter-chips/__tests__/build-chip-parts.test.ts +28 -0
  58. package/utils/filter-chips/build-chip-parts.ts +19 -0
  59. package/utils/filter-model/__tests__/build-model-filter-query.test.ts +48 -0
  60. package/utils/filter-model/build-model-filter-query.ts +22 -0
  61. package/utils/filter-model/parse-model-filter-values.ts +19 -0
  62. package/utils/filter-selects/__tests__/build-categories-query.test.ts +52 -0
  63. package/utils/filter-selects/__tests__/to-quick-filters.test.ts +82 -0
  64. package/utils/filter-selects/build-categories-query.ts +32 -0
  65. package/utils/filter-selects/to-quick-filters.ts +47 -0
  66. package/utils/webcams/__tests__/insert-positions.test.ts +45 -0
  67. package/utils/webcams/insert-positions.ts +54 -0
  68. package/composables/fetch/use-fetch-top-random-categories.ts +0 -21
@@ -225,11 +225,10 @@ $video-card-action-bg--hover: $color-zinc-800;
225
225
  $video-card-action-border: $color-zinc-800-60;
226
226
  $video-card-action-icon: $color-zinc-300;
227
227
  $video-card-action-active-bg: $color-zinc-800;
228
- $video-card-action-active-bg--hover: $color-red-600;
229
228
  $video-card-action-active-border: $color-zinc-800;
230
229
  $video-card-action-active-border--hover: $color-red-600;
230
+ $video-card-action-active-ring--hover: 0 0 0 2px $color-red-600;
231
231
  $video-card-action-active-icon: $color-white;
232
- $video-card-action-active-icon--hover: $color-white;
233
232
 
234
233
  // ─── Video card dropdown ───
235
234
  $video-card-dropdown-size--hover: 1;
@@ -704,11 +703,18 @@ $seo-footer-bg: $color-zinc-800-50;
704
703
  $empty-text: $color-zinc-500;
705
704
 
706
705
  // ─── Filter chips ───
707
- $filter-chips-border: $color-zinc-700;
708
- $filter-chips-bg: $color-zinc-800;
706
+ $filter-chips-border: $color-zinc-700-50;
707
+ $filter-chips-border--hover: $color-zinc-600;
708
+ $filter-chips-bg: $color-zinc-800-60;
709
709
  $filter-chips-text: $color-zinc-300;
710
- $filter-chips-bg--hover: $color-zinc-700;
710
+
711
+ // на ховер тут ведёт рамка, фон остаётся прежним
712
+ $filter-chips-bg--hover: $color-zinc-800-60;
711
713
  $filter-chips-close: $color-zinc-500;
714
+ $filter-chips-close--hover: $color-white;
715
+ $filter-chips-label: $color-zinc-500;
716
+ $filter-chips-row-clear-text: $color-zinc-500;
717
+ $filter-chips-row-clear-text--hover: $color-white;
712
718
 
713
719
  // ─── Filter button ───
714
720
  $filter-button-count-bg: $color-zinc-300;
@@ -852,8 +858,6 @@ $page-model-videos-section-border: $color-zinc-800-50;
852
858
  // ─── Page videos ───
853
859
  $page-videos-title-border: $color-zinc-800-50;
854
860
  $page-videos-footer-border: $color-zinc-800;
855
- $page-videos-categories-border: $color-indigo-600;
856
- $page-videos-categories-icon: $color-indigo-600;
857
861
 
858
862
  // ─── Градиенты fade ───
859
863
  $bg-fade-90: $color-zinc-950-90; // почти непрозрачный
@@ -1134,10 +1138,6 @@ $switcher-icon--active: $color-emerald-400;
1134
1138
  $switcher-subtitle: $color-zinc-600;
1135
1139
  $switcher-subtitle--active: $color-white-60;
1136
1140
 
1137
- // ─── Filter: сброс всех фильтров в облаке чипсов ───
1138
- $filter-model-clear-text: $color-zinc-500;
1139
- $filter-model-clear-text--hover: $color-white;
1140
-
1141
1141
  // ─── Вебкам-карточка (продолжение) ───
1142
1142
  $card-webcam-languages-border: $color-white-20;
1143
1143
  $card-webcam-languages-fallback-bg: $color-zinc-800;
@@ -283,11 +283,10 @@ $video-card-action-bg--hover: $color-white;
283
283
  $video-card-action-border: $color-gray-300-50;
284
284
  $video-card-action-icon: $color-gray-700;
285
285
  $video-card-action-active-bg: $color-white;
286
- $video-card-action-active-bg--hover: $color-red-600;
287
286
  $video-card-action-active-border: $color-gray-300;
288
287
  $video-card-action-active-border--hover: $color-red-600;
288
+ $video-card-action-active-ring--hover: 0 0 0 2px $color-red-600;
289
289
  $video-card-action-active-icon: $color-gray-900;
290
- $video-card-action-active-icon--hover: $color-white;
291
290
 
292
291
  // ─── Video menu (меню действий над видео) ───
293
292
  $video-menu-preview-border: transparent;
@@ -754,10 +753,17 @@ $empty-text: $color-gray-500;
754
753
 
755
754
  // ─── Filter chips ───
756
755
  $filter-chips-border: transparent;
756
+
757
+ // рамки нет — на ховер ведёт фон
758
+ $filter-chips-border--hover: transparent;
757
759
  $filter-chips-bg: $color-gray-200;
758
760
  $filter-chips-text: $color-gray-700;
759
761
  $filter-chips-bg--hover: $color-gray-300;
760
762
  $filter-chips-close: $color-gray-500;
763
+ $filter-chips-close--hover: $color-gray-950;
764
+ $filter-chips-label: $color-gray-500;
765
+ $filter-chips-row-clear-text: $color-gray-500;
766
+ $filter-chips-row-clear-text--hover: $color-gray-950;
761
767
 
762
768
  // ─── Filter button ───
763
769
  $filter-button-count-bg: $color-white-20;
@@ -878,8 +884,6 @@ $page-model-videos-section-border: transparent;
878
884
  // ─── Page videos ───
879
885
  $page-videos-title-border: $color-gray-200-50;
880
886
  $page-videos-footer-border: transparent;
881
- $page-videos-categories-border: $color-indigo-600;
882
- $page-videos-categories-icon: $color-indigo-600;
883
887
 
884
888
  // ─── Градиенты fade ───
885
889
  $bg-fade-90: $color-gray-950-90; // почти непрозрачный
@@ -1160,10 +1164,6 @@ $switcher-icon--active: $color-emerald-400;
1160
1164
  $switcher-subtitle: $color-gray-500;
1161
1165
  $switcher-subtitle--active: $color-white-60;
1162
1166
 
1163
- // ─── Filter: сброс всех фильтров в облаке чипсов ───
1164
- $filter-model-clear-text: $color-gray-500;
1165
- $filter-model-clear-text--hover: $color-gray-950;
1166
-
1167
1167
  // ─── Вебкам-карточка (продолжение) ───
1168
1168
  $card-webcam-languages-border: $color-gray-300;
1169
1169
  $card-webcam-languages-fallback-bg: $color-gray-200;
@@ -27,7 +27,7 @@
27
27
  --control-min-width: 126px;
28
28
  --container-padding-inline: 8px;
29
29
  --aside-videos-aspect-ratio: 403/593;
30
- --popup-padding-bottom-mobile: 32px;
30
+ --popup-padding-bottom-mobile: 16px;
31
31
  --card-round-size: 80px;
32
32
 
33
33
  @include from-br(xs) {
@@ -76,7 +76,6 @@ const { setErrorState, showError, show, resetSnackbar } = useSnackbar();
76
76
  onMounted(async () => {
77
77
  try {
78
78
  await initRecaptcha();
79
- console.log('reCAPTCHA готов для этой формы');
80
79
  } catch (err) {
81
80
  console.error('Ошибка инициализации reCAPTCHA:', err);
82
81
  }
@@ -134,7 +134,6 @@ const { setErrorState, showError, show, resetSnackbar } = useSnackbar();
134
134
  onMounted(async () => {
135
135
  try {
136
136
  await initRecaptcha();
137
- console.log('reCAPTCHA готов для этой формы');
138
137
  } catch (err) {
139
138
  console.error('Ошибка инициализации reCAPTCHA:', err);
140
139
  }
@@ -22,7 +22,8 @@
22
22
  :aria-pressed="later"
23
23
  :class="['card-video-actions__btn', '--watch-later', { '--active': later }]"
24
24
  type="button"
25
- @click="toggleWatchLater(card.guid, card.title)"
25
+ v-bind="hoverGuard"
26
+ @click="onWatchLaterClick"
26
27
  >
27
28
  <UiIcon
28
29
  :name="later ? 'clock-filled' : 'clock'"
@@ -44,9 +45,17 @@ const props = defineProps<{
44
45
  const { t } = useI18n();
45
46
 
46
47
  const { isFavorite, isWatchLater, toggleFavorite, toggleWatchLater } = useVideoCollections();
48
+ const { blockHover, hoverGuard } = useHoverGuard();
47
49
 
48
50
  const favorite = computed(() => isFavorite(props.card.guid));
49
51
  const later = computed(() => isWatchLater(props.card.guid));
52
+
53
+ /* The heart is only rendered while the video is in favorites, so a click always unmounts it — only
54
+ «watch later» stays under the cursor and needs the hover muted. */
55
+ function onWatchLaterClick() {
56
+ blockHover();
57
+ toggleWatchLater(props.card.guid, props.card.title);
58
+ }
50
59
  </script>
51
60
 
52
61
  <style lang="scss">
@@ -97,6 +106,7 @@ $action-size: 36px;
97
106
  var(--transition) opacity,
98
107
  var(--transition) background-color,
99
108
  var(--transition) border-color,
109
+ var(--transition) box-shadow,
100
110
  var(--transition) color;
101
111
  }
102
112
 
@@ -104,14 +114,13 @@ $action-size: 36px;
104
114
  opacity: 1;
105
115
  }
106
116
 
107
- .card-video-actions__btn:is(:hover, :focus-visible) {
117
+ .card-video-actions__btn:not(.--active):is(:hover, :focus-visible) {
108
118
  background-color: $video-card-action-bg--hover;
109
119
  }
110
120
 
111
- .card-video-actions__btn.--active:is(:hover, :focus-visible) {
112
- background-color: $video-card-action-active-bg--hover;
121
+ .card-video-actions__btn.--active:not(.--no-hover):is(:hover, :focus-visible) {
113
122
  border-color: $video-card-action-active-border--hover;
114
- color: $video-card-action-active-icon--hover;
123
+ box-shadow: $video-card-action-active-ring--hover;
115
124
  }
116
125
  }
117
126
 
@@ -0,0 +1,56 @@
1
+ <!-- Облако чипсов активных фильтров с кнопкой сброса -->
2
+ <template>
3
+ <div
4
+ v-if="items.length > 0"
5
+ class="filter-chips-row"
6
+ >
7
+ <slot/>
8
+ <FilterChips
9
+ v-for="(item, index) in items"
10
+ :key="`filter-chips-${index}`"
11
+ :item="item"
12
+ @click="emit('click', item)"
13
+ />
14
+ <button
15
+ v-if="items.length > 1"
16
+ class="filter-chips-row__clear"
17
+ type="button"
18
+ @click="emit('clear')"
19
+ >{{ t('reset_all') }}</button>
20
+ </div>
21
+ </template>
22
+
23
+ <script setup lang="ts">
24
+ import type { IFilterChipItem } from '../../types';
25
+
26
+ defineProps<{
27
+ items: IFilterChipItem[]
28
+ }>();
29
+
30
+ const { t } = useI18n();
31
+
32
+ const emit = defineEmits<{
33
+ (eventName: 'click', eventValue: IFilterChipItem): void
34
+ (eventName: 'clear'): void
35
+ }>();
36
+ </script>
37
+
38
+ <style lang="scss">
39
+ .filter-chips-row {
40
+ display: flex;
41
+ flex-wrap: wrap;
42
+ align-items: center;
43
+ gap: 8px;
44
+ }
45
+
46
+ .filter-chips-row__clear {
47
+ @include font-sm;
48
+
49
+ padding: 6px 8px;
50
+ color: $filter-chips-row-clear-text;
51
+
52
+ @include hover(color) {
53
+ color: $filter-chips-row-clear-text--hover;
54
+ }
55
+ }
56
+ </style>
@@ -1,58 +1,78 @@
1
- <!-- Чипсы активных фильтров с кнопкой сброса -->
1
+ <!-- Чипса выбранного фильтра: приглушённое название фильтра, значение и сброс по клику -->
2
2
  <template>
3
- <UiExpandRow v-if="items.length > 0">
4
- <template #prepend>
5
- <slot/>
6
- </template>
7
- <UiChips
8
- v-for="(item, index) in items"
9
- :key="index"
10
- :index="`s-filter-page-chips${index}`"
11
- :item="item"
12
- class="filter-chips"
13
- with-close
14
- @click="onChipsClick(item)"
3
+ <button
4
+ class="filter-chips"
5
+ type="button"
6
+ @click="emit('click')"
7
+ >
8
+ <span class="filter-chips__text">
9
+ <span
10
+ v-if="item.label && !withoutLabel"
11
+ class="filter-chips__label"
12
+ >{{ item.label }}:</span>{{ item.title }}
13
+ </span>
14
+ <UiIcon
15
+ class="filter-chips__close"
16
+ name="close"
17
+ size="16"
15
18
  />
16
- </UiExpandRow>
19
+ </button>
17
20
  </template>
18
21
 
19
22
  <script setup lang="ts">
20
- import type { IChipsItem } from '../../types';
23
+ import type { IFilterChipItem } from '../../types';
21
24
 
22
25
  defineProps<{
23
- items: IChipsItem[]
26
+ item: IFilterChipItem
27
+ /** Без названия фильтра — в попапе фильтр уже подписан своим полем. */
28
+ withoutLabel?: boolean
24
29
  }>();
25
30
 
26
31
  const emit = defineEmits<{
27
- (eventName: 'click', eventValue: IChipsItem): void
32
+ (eventName: 'click'): void
28
33
  }>();
29
-
30
- function onChipsClick(item: IChipsItem) {
31
- emit('click', item);
32
- }
33
34
  </script>
34
35
 
35
36
  <style lang="scss">
36
37
  .filter-chips {
37
38
  @include font-sm;
38
39
 
39
- border-radius: $radius-lg;
40
+ display: flex;
41
+ align-items: center;
42
+ column-gap: 8px;
43
+ padding-block: 6px;
44
+ padding-left: 12px;
45
+
46
+ // справа отступ меньше — там иконка сброса со своим зазором
47
+ padding-right: 8px;
40
48
  border: 1px solid $filter-chips-border;
49
+ border-radius: $radius-md;
41
50
  background-color: $filter-chips-bg;
42
- pointer-events: all;
43
51
  color: $filter-chips-text;
52
+ white-space: nowrap;
53
+ text-transform: capitalize;
44
54
 
45
- @include hover(background-color) {
55
+ @include hover(background-color, border-color) {
46
56
  background-color: $filter-chips-bg--hover;
57
+ border-color: $filter-chips-border--hover;
47
58
  }
48
59
  }
49
60
 
50
- .filter-chips .ui-chips__close {
51
- pointer-events: all;
61
+ // подпись и значение читаются одной фразой, поэтому не отдельный флекс-элемент
62
+ .filter-chips__label {
63
+ padding-right: 4px;
64
+ color: $filter-chips-label;
65
+ }
66
+
67
+ .filter-chips__close {
52
68
  color: $filter-chips-close;
69
+ transition: var(--transition) color;
70
+ }
53
71
 
54
- @include hover(opacity) {
55
- opacity: 0.9;
72
+ // иконка подсвечивается вместе с чипсой, а не сама по себе
73
+ @media (hover: hover) {
74
+ .filter-chips:is(:hover, :focus-visible) .filter-chips__close {
75
+ color: $filter-chips-close--hover;
56
76
  }
57
77
  }
58
78
  </style>
@@ -43,8 +43,7 @@
43
43
  </template>
44
44
 
45
45
  <script setup lang="ts">
46
- import type { ButtonSizes, ButtonThemes, IChipsItem, IFilterScheme, ISelectItem } from '../../types';
47
- import type { LocationQuery } from '#vue-router';
46
+ import type { ButtonSizes, ButtonThemes, IFilterChipItem, IFilterScheme, ISelectItem } from '../../types';
48
47
  import { parseFilterCategories } from '../../utils/parse-filter-categories';
49
48
  import { parseCategoryFilterValues } from '../../utils/filter-main/parse-category-filter-values';
50
49
  import { getActiveFilterValues } from '../../utils/filter-main/get-active-filter-values';
@@ -64,23 +63,40 @@ const emit = defineEmits<{
64
63
 
65
64
  const { t } = useI18n();
66
65
  const route = useRoute();
67
- const router = useRouter();
68
66
 
69
67
  const filterOpen = ref(false);
70
68
 
71
- const durationValue = ref('');
72
- const addedValue = ref('');
73
- const filterValue = ref({} as Record<string, string>);
74
-
75
- function syncFromQuery(query: LocationQuery) {
76
- filterValue.value = parseCategoryFilterValues(parseFilterCategories(query));
77
- durationValue.value = (query.duration as string) || '';
78
- addedValue.value = (query.added as string) || '';
79
- }
80
-
81
- syncFromQuery(route.query);
82
-
83
- watch(() => route.query, (q) => syncFromQuery(q));
69
+ // Длительность и дата добавления живут в query своими параметрами, но в попапе это такие же
70
+ // поля, как категории — держим их в одном наборе выбора и разводим обратно при применении
71
+ const DURATION_FIELD = 'duration';
72
+ const ADDED_FIELD = 'added';
73
+
74
+ const {
75
+ values: filterValue,
76
+ count: activeCount,
77
+ isFiltered,
78
+ set: setField,
79
+ remove: removeField,
80
+ reset,
81
+ apply: saveResult,
82
+ } = useFilterPending({
83
+ open: filterOpen,
84
+ decode: query => ({
85
+ ...parseCategoryFilterValues(parseFilterCategories(query)),
86
+ ...(query[DURATION_FIELD] ? { [DURATION_FIELD]: String(query[DURATION_FIELD]) } : {}),
87
+ ...(query[ADDED_FIELD] ? { [ADDED_FIELD]: String(query[ADDED_FIELD]) } : {}),
88
+ }),
89
+ encode: (values, query) => {
90
+ const { [DURATION_FIELD]: durationValue, [ADDED_FIELD]: addedValue, ...categories } = values;
91
+
92
+ return buildFilterQuery({
93
+ currentQuery: query,
94
+ filterValue: categories,
95
+ durationValue: durationValue || '',
96
+ addedValue: addedValue || '',
97
+ });
98
+ },
99
+ });
84
100
 
85
101
  watch(filterOpen, (value) => {
86
102
  if (value) emit('open');
@@ -92,14 +108,6 @@ watch(() => route.query, (value) => {
92
108
  filterCount.value = getActiveFilterValues(value).length;
93
109
  });
94
110
 
95
- const activeCount = computed(() => {
96
- const hasDuration = durationValue.value ? 1 : 0;
97
- const hasAdded = addedValue.value ? 1 : 0;
98
- return Object.keys(filterValue.value).length + hasAdded + hasDuration;
99
- });
100
-
101
- const isFiltered = computed(() => !!route.query['categories'] || Object.keys(filterValue.value).length > 0 || !!durationValue.value || !!addedValue.value);
102
-
103
111
  const QUICK_NAMES = ['action', 'guy type', 'ppl involved', 'ethnicity & nationality'];
104
112
  const PERFORMER_NAMES = [
105
113
  'age',
@@ -180,9 +188,9 @@ const groupItems = computed(() => {
180
188
  name: 'duration',
181
189
  title: t('duration'),
182
190
  label: t('duration'),
183
- value: durationValue.value,
191
+ value: filterValue.value[DURATION_FIELD] ?? '',
184
192
  items: translateTitle(props.durationItems),
185
- active: !!durationValue.value,
193
+ active: !!filterValue.value[DURATION_FIELD],
186
194
  icon: 'time',
187
195
  placeholder: t('select'),
188
196
  } as any,
@@ -191,9 +199,9 @@ const groupItems = computed(() => {
191
199
  name: 'added',
192
200
  title: t('added'),
193
201
  label: t('added'),
194
- value: addedValue.value,
202
+ value: filterValue.value[ADDED_FIELD] ?? '',
195
203
  items: translateTitle(props.addedItems),
196
- active: !!addedValue.value,
204
+ active: !!filterValue.value[ADDED_FIELD],
197
205
  icon: 'date',
198
206
  placeholder: t('select'),
199
207
  } as any,
@@ -207,93 +215,32 @@ const groupItems = computed(() => {
207
215
  ];
208
216
  });
209
217
 
210
- const DURATION_CHIP_KEY = '__duration';
211
- const ADDED_CHIP_KEY = '__added';
212
-
213
- const activeChips = computed<IChipsItem[]>(() => {
214
- const chips: IChipsItem[] = [];
215
-
216
- for (const str of parseFilterCategories(route.query)) {
217
- const [key, ...rest] = str.split('_');
218
- const value = rest.join('_');
219
- if (!key || !value) continue;
220
- const schemeItem = props.scheme.find(s => s.name.toLowerCase() === key);
221
- const matched = schemeItem?.items.find(i => i.value === value);
222
- chips.push({ title: matched?.title || value, key });
223
- }
224
-
225
- const duration = route.query.duration as string;
226
- if (duration) {
227
- const item = props.durationItems.find(i => i.value === duration);
228
- chips.push({ title: item?.title ? t(item.title) : duration, key: DURATION_CHIP_KEY });
229
- }
230
-
231
- const added = route.query.added as string;
232
- if (added) {
233
- const item = props.addedItems.find(i => i.value === added);
234
- chips.push({ title: item?.title ? t(item.title) : added, key: ADDED_CHIP_KEY });
235
- }
236
-
237
- return chips;
238
- });
218
+ // Чипсы попапа показывают выбор, а не применённое: иначе только что отмеченный фильтр
219
+ // появится в них лишь после «Применить», хотя счётчик рядом уже посчитан
220
+ const activeChips = computed<IFilterChipItem[]>(() => Object.entries(filterValue.value)
221
+ .map(([key, value]) => ({ title: fieldValueTitle(key, value), key }))
222
+ );
239
223
 
240
- function onChipClick(item: IChipsItem) {
241
- const nextQuery = { ...route.query };
242
- delete nextQuery.page;
224
+ function fieldValueTitle(name: string, value: string) {
225
+ if (name === DURATION_FIELD || name === ADDED_FIELD) {
226
+ const items = name === DURATION_FIELD ? props.durationItems : props.addedItems;
227
+ const item = items.find(i => i.value === value);
243
228
 
244
- if (item.key === DURATION_CHIP_KEY) {
245
- delete nextQuery.duration;
246
- } else if (item.key === ADDED_CHIP_KEY) {
247
- delete nextQuery.added;
248
- } else if (item.key) {
249
- const remaining = parseFilterCategories(route.query).filter(str => {
250
- const [k] = str.split('_');
251
- return k !== item.key;
252
- });
253
- if (remaining.length) {
254
- nextQuery.categories = remaining.join(',');
255
- } else {
256
- delete nextQuery.categories;
257
- }
229
+ return item?.title ? t(item.title) : value;
258
230
  }
259
231
 
260
- router.push({ query: nextQuery });
261
- }
232
+ const schemeItem = props.scheme.find(s => s.name.toLowerCase() === name);
262
233
 
263
- function onFieldUpdate({ name, value }: { name: string, value: any }) {
264
- if (name === 'duration') {
265
- durationValue.value = value;
266
- } else if (name === 'added') {
267
- addedValue.value = value;
268
- } else {
269
- if (!value) {
270
- const next = { ...filterValue.value };
271
- delete next[name];
272
- filterValue.value = next;
273
- } else {
274
- filterValue.value = { ...filterValue.value, [name]: value };
275
- }
276
- }
234
+ return schemeItem?.items.find(i => i.value === value)?.title || value;
277
235
  }
278
236
 
279
- function saveResult() {
280
- router.push({
281
- query: buildFilterQuery({
282
- currentQuery: route.query,
283
- filterValue: filterValue.value,
284
- durationValue: durationValue.value,
285
- addedValue: addedValue.value,
286
- }),
287
- });
288
-
289
- filterOpen.value = false;
237
+ // Снятие чипсы — тоже выбор: уйдёт в query вместе с остальным по «Применить»
238
+ function onChipClick(item: IFilterChipItem) {
239
+ if (item.key) removeField(item.key);
290
240
  }
291
241
 
292
- function reset() {
293
- durationValue.value = '';
294
- addedValue.value = '';
295
- filterValue.value = {};
296
- router.push({ query: { categories: null } });
242
+ function onFieldUpdate({ name, value }: { name: string, value: any }) {
243
+ setField(name, value);
297
244
  }
298
245
  </script>
299
246