itube-specs 0.0.868 → 0.0.870

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.
@@ -13,8 +13,8 @@ select:-webkit-autofill,
13
13
  select:-webkit-autofill:hover,
14
14
  select:-webkit-autofill:focus {
15
15
  box-shadow: 0 0 0 1000px transparent inset !important;
16
- -webkit-text-fill-color: $input-autofill-text !important;
17
- color: $input-autofill-text !important;
16
+ -webkit-text-fill-color: var(--input-autofill-text, #{$input-autofill-text}) !important;
17
+ color: var(--input-autofill-text, #{$input-autofill-text}) !important;
18
18
  border: none !important;
19
19
  transition: background-color 5000s ease-in-out 0s !important;
20
20
  font-size: inherit !important;
@@ -37,7 +37,8 @@ $surface-roles: (
37
37
  control-bg: (--input-bg, --select-bg),
38
38
  control-compact-bg: (--input-s-bg,),
39
39
  control-bg--disabled: (--input-bg--disabled,),
40
- control-text: (--input-text, --select-text),
40
+ control-text: (--input-text, --input-autofill-text, --select-text),
41
+ control-compact-text: (--input-s-text,),
41
42
  control-text--disabled: (--input-text--disabled,),
42
43
  control-placeholder: (--input-placeholder, --select-placeholder),
43
44
  control-label: (--input-label, --select-label),
@@ -77,10 +77,10 @@ $input-label: $color-zinc-400;
77
77
  $input-icon: inherit;
78
78
  $input-pre-icon: $color-zinc-500;
79
79
  $input-s-bg: $color-zinc-800;
80
+ $input-s-text: $color-white;
80
81
  $input-s-border: transparent;
81
82
  $input-s-border--focus: $color-zinc-600;
82
83
  $input-text--disabled: $color-zinc-400;
83
- $input-text--error: $color-red-500;
84
84
  $input-border--error: $color-red-500;
85
85
  $input-autofill-text: $color-white;
86
86
  $input-button-text: $color-zinc-500;
@@ -218,8 +218,6 @@ $video-info-description-text: $color-zinc-400;
218
218
  // ─── Video interface ───
219
219
  $video-interface-button-bg: $color-zinc-800;
220
220
  $video-interface-button-text: $color-zinc-400;
221
- $video-interface-button-bg--hover: $color-zinc-700;
222
- $video-interface-button-text--hover: $color-white;
223
221
 
224
222
  // ─── Video channel ───
225
223
  $video-channel-bg: $color-zinc-800-60;
@@ -428,9 +426,7 @@ $dropdown-crest: $color-zinc-500;
428
426
  $dropdown-separator: $color-zinc-800-60;
429
427
 
430
428
  // ─── Snackbar ───
431
- $snackbar-bg: $color-zinc-800;
432
429
  $snackbar-text: $color-zinc-200;
433
- $snackbar-border: $color-zinc-700;
434
430
  $snackbar-close: $color-zinc-500;
435
431
  $snackbar-close--hover: $color-zinc-300;
436
432
  $snackbar-close-bg--hover: $color-zinc-700;
@@ -528,7 +524,6 @@ $radio-bg: $color-zinc-800;
528
524
  $radio-border--checked: $color-indigo-600;
529
525
  $radio-bg--checked: $color-indigo-600;
530
526
  $radio-label: $color-white;
531
- $radio-label--hover: $color-zinc-300;
532
527
  $radio-description: $color-zinc-500;
533
528
  $radio-border--disabled: $color-zinc-800;
534
529
  $radio-text--disabled: $color-zinc-400;
@@ -600,9 +595,6 @@ $expand-row-pre-text: $color-zinc-500;
600
595
  $full-description-text: $color-zinc-400;
601
596
  $full-description-bg: $color-zinc-800-50;
602
597
 
603
- // ─── Swiper items ───
604
- $swiper-items-all-text: $color-zinc-400;
605
-
606
598
  // ─── Report ───
607
599
  $report-category-border: $color-zinc-800;
608
600
  $report-category-bg--hover: $color-zinc-800-50;
@@ -612,9 +604,6 @@ $report-text: $color-zinc-400;
612
604
  $report-required: $color-red-500;
613
605
  $report-text--error: $color-red-500;
614
606
 
615
- // ─── Model filters ───
616
- $model-filters-border: $color-zinc-800;
617
-
618
607
  // ─── Share ───
619
608
  $share-cancel-border: $color-zinc-800;
620
609
  $share-button-copy-bg: $color-zinc-800;
@@ -689,10 +678,6 @@ $player-menu-scene-time: $color-zinc-500; // text-zinc-500
689
678
  $player-menu-scene-time--current: $color-zinc-200; // text-zinc-200
690
679
  $player-menu-section-title: $color-zinc-400;
691
680
 
692
- // ─── Filter page ───
693
- $filter-page-group-title: $color-white;
694
- $filter-page-group-border: $color-zinc-800;
695
-
696
681
  // ─── Search ───
697
682
  $search-close-bg: $color-zinc-700;
698
683
  $search-close-text: $color-zinc-300;
@@ -728,10 +713,8 @@ $filters-main-bg--hover: $color-zinc-700-70;
728
713
 
729
714
  // ─── Info grid ───
730
715
  $info-grid-group-title: $color-zinc-300;
731
- $info-grid-item-bg: $color-zinc-700-50;
732
716
  $info-grid-item-border: $color-zinc-700-50;
733
717
  $info-grid-item-bg--hover: $color-zinc-700-30;
734
- $info-grid-item-border--hover: $color-zinc-600;
735
718
  $info-grid-item-title: $color-zinc-500;
736
719
  $info-grid-item-title-bg: $color-zinc-900;
737
720
  $info-grid-item-value-bg: $color-zinc-900;
@@ -739,7 +722,6 @@ $info-grid-item-value-bg--link: $color-zinc-700-50;
739
722
  $info-grid-item-value-link-bg--hover: $color-zinc-700-50;
740
723
  $info-grid-item-value--link: $color-white;
741
724
  $info-grid-item-value-border: $color-zinc-700-50;
742
- $info-grid-item-values: $color-white;
743
725
  $info-grid-item-success: $color-green-400;
744
726
  $info-grid-item-warning: $color-red-400;
745
727
  $info-grid-item-icon: $color-zinc-400;
@@ -783,7 +765,6 @@ $shadow-color--active: $mono-black-42; // тень при drag
783
765
 
784
766
  // ─── Page models ───
785
767
  $page-models-popular-border: $color-zinc-800;
786
- $page-models-categories-border: $color-zinc-800-50;
787
768
 
788
769
  // ─── Page model videos ───
789
770
  $page-model-videos-section-border: $color-zinc-800-50;
@@ -809,7 +790,6 @@ $model-card-params-footer: $color-zinc-500;
809
790
  $model-card-name: $color-zinc-200;
810
791
  $model-card-name--hover: $color-indigo-400;
811
792
  $model-card-count: $color-zinc-500;
812
- $model-card-divider: $color-zinc-700;
813
793
 
814
794
  // ─── Playlist card ───
815
795
  $playlist-card-bg: $color-zinc-800-50;
@@ -896,7 +876,6 @@ $native-card-secondary: $color-zinc-500;
896
876
 
897
877
  // ─── Page: grouped categories ───
898
878
  $grouped-highlight-border: $color-zinc-800;
899
- $grouped-separator: $color-zinc-800;
900
879
  $grouped-category-icon: $color-zinc-500;
901
880
  $grouped-footer-text: $color-zinc-500;
902
881
  $grouped-section-border: $color-zinc-800;
@@ -944,16 +923,6 @@ $contacts-wrapper-bg: $color-zinc-900;
944
923
  $contacts-link: $color-indigo-400;
945
924
  $contacts-link--hover: $color-indigo-300;
946
925
 
947
- // ─── Page: playlist ───
948
- $page-playlist-edit: $color-zinc-500;
949
- $page-playlist-edit-border: $color-zinc-800;
950
- $page-playlist-header-border: $color-zinc-800;
951
- $page-playlist-avatar-bg: $color-indigo-600;
952
- $page-playlist-avatar-text: $color-white;
953
- $page-playlist-user-name: $color-zinc-400;
954
- $page-playlist-private-bg: $color-zinc-800-60;
955
- $page-playlist-like-bg: $color-zinc-900;
956
-
957
926
  // ─── Page: playlists ───
958
927
  $page-playlists-border: $color-zinc-800;
959
928
 
@@ -87,14 +87,14 @@ $input-label: $color-gray-700;
87
87
  $input-icon: $color-gray-500;
88
88
  $input-pre-icon: $color-gray-500;
89
89
  $input-s-bg: $color-slate-750;
90
+ $input-s-text: $color-white;
90
91
  $input-s-border: transparent;
91
92
  $input-s-border--focus: $color-indigo-soft;
92
93
  $input-text--disabled: $color-gray-400;
93
- $input-text--error: $color-red-500;
94
94
  $input-border--error: $color-red-500;
95
- $input-autofill-text: $color-white;
95
+ $input-autofill-text: $color-gray-900;
96
96
  $input-button-text: $color-gray-500;
97
- $input-button-text--hover: $color-white;
97
+ $input-button-text--hover: $color-gray-500;
98
98
 
99
99
  // ─── Surfaces ("островки") ───
100
100
  // Только отличия от базовых токенов. Подложки нет в карте — нет и оверрайда.
@@ -207,7 +207,7 @@ $popup-auth-icon-bg: $color-indigo-600;
207
207
  $popup-border: transparent;
208
208
  $popup-footer-border: $color-slate-750;
209
209
  $popup-text: $color-white;
210
- $popup-scrollbar: $color-gray-800;
210
+ $popup-scrollbar: $color-gray-400;
211
211
  $popup-close-icon: $color-gray-400;
212
212
  $popup-close-icon--hover: $color-white;
213
213
 
@@ -275,8 +275,6 @@ $video-info-description-text: $color-gray-700;
275
275
  // ─── Video interface ───
276
276
  $video-interface-button-bg: $color-slate-750;
277
277
  $video-interface-button-text: $color-gray-400;
278
- $video-interface-button-bg--hover: $color-slate-700;
279
- $video-interface-button-text--hover: $color-white;
280
278
 
281
279
  // ─── Video channel ───
282
280
  $video-channel-bg: $color-gray-200;
@@ -485,9 +483,7 @@ $dropdown-crest: $color-gray-400;
485
483
  $dropdown-separator: $color-slate-750;
486
484
 
487
485
  // ─── Snackbar ───
488
- $snackbar-bg: $color-slate-750;
489
486
  $snackbar-text: $color-gray-200;
490
- $snackbar-border: $color-slate-700;
491
487
  $snackbar-close: $color-gray-500;
492
488
  $snackbar-close--hover: $color-gray-300;
493
489
  $snackbar-close-bg--hover: $color-slate-700;
@@ -568,8 +564,8 @@ $checkbox-border: $color-gray-400;
568
564
  $checkbox-color: $color-white;
569
565
  $checkbox-bg--checked: $color-indigo-soft;
570
566
  $checkbox-border--checked: $color-indigo-600;
571
- $checkbox-label: $color-gray-400;
572
- $checkbox-label--hover: $color-gray-300;
567
+ $checkbox-label: $color-gray-700;
568
+ $checkbox-label--hover: $color-gray-500;
573
569
  $checkbox-description: $color-gray-500;
574
570
  $checkbox-border--disabled: transparent;
575
571
  $checkbox-text--disabled: $color-gray-400;
@@ -581,7 +577,6 @@ $radio-bg: transparent;
581
577
  $radio-border--checked: $color-indigo-soft;
582
578
  $radio-bg--checked: $color-indigo-soft;
583
579
  $radio-label: $color-gray-700;
584
- $radio-label--hover: $color-gray-300;
585
580
  $radio-description: $color-gray-500;
586
581
  $radio-border--disabled: $color-slate-700;
587
582
  $radio-text--disabled: $color-gray-400;
@@ -653,9 +648,6 @@ $expand-row-pre-text: $color-gray-500;
653
648
  $full-description-text: $color-gray-600;
654
649
  $full-description-bg: $color-gray-100;
655
650
 
656
- // ─── Swiper items ───
657
- $swiper-items-all-text: $color-gray-400;
658
-
659
651
  // ─── Report ───
660
652
  $report-category-border: $color-slate-700;
661
653
  $report-category-bg--hover: $color-slate-750;
@@ -665,9 +657,6 @@ $report-text: $color-gray-400;
665
657
  $report-required: $color-red-500;
666
658
  $report-text--error: $color-red-500;
667
659
 
668
- // ─── Model filters ───
669
- $model-filters-border: transparent;
670
-
671
660
  // ─── Share ───
672
661
  $share-cancel-border: transparent;
673
662
  $share-button-copy-bg: $color-slate-750;
@@ -742,10 +731,6 @@ $player-menu-scene-time: $color-gray-500; // text-zinc-500
742
731
  $player-menu-scene-time--current: $color-gray-200; // text-zinc-200
743
732
  $player-menu-section-title: $color-gray-400;
744
733
 
745
- // ─── Filter page ───
746
- $filter-page-group-title: $color-white;
747
- $filter-page-group-border: transparent;
748
-
749
734
  // ─── Filters main ───
750
735
  $filters-main-slider-bg: $color-night;
751
736
  $filters-main-slider-border: transparent;
@@ -758,10 +743,8 @@ $filters-main-bg--hover: $button-secondary-bg--hover;
758
743
 
759
744
  // ─── Info grid ───
760
745
  $info-grid-group-title: $color-gray-600;
761
- $info-grid-item-bg: $color-white;
762
746
  $info-grid-item-border: $color-gray-300;
763
747
  $info-grid-item-bg--hover: $color-slate-700;
764
- $info-grid-item-border--hover: $color-gray-600;
765
748
  $info-grid-item-title: $color-gray-500;
766
749
  $info-grid-item-title-bg: $color-white;
767
750
  $info-grid-item-value-bg: $color-gray-100;
@@ -769,7 +752,6 @@ $info-grid-item-value-bg--link: $color-slate-700;
769
752
  $info-grid-item-value-link-bg--hover: $color-slate-750;
770
753
  $info-grid-item-value--link: $color-white;
771
754
  $info-grid-item-value-border: $color-gray-300;
772
- $info-grid-item-values: $color-white;
773
755
  $info-grid-item-success: $color-green-400;
774
756
  $info-grid-item-warning: $color-red-400;
775
757
  $info-grid-item-icon: $color-gray-400;
@@ -813,7 +795,6 @@ $shadow-color--active: $mono-black-42; // тень при drag
813
795
 
814
796
  // ─── Page models ───
815
797
  $page-models-popular-border: transparent;
816
- $page-models-categories-border: transparent;
817
798
 
818
799
  // ─── Page model videos ───
819
800
  $page-model-videos-section-border: transparent;
@@ -839,7 +820,6 @@ $model-card-params-footer: $color-gray-600;
839
820
  $model-card-name: $color-gray-900;
840
821
  $model-card-name--hover: $model-card-name;
841
822
  $model-card-count: $color-gray-600;
842
- $model-card-divider: $color-gray-400;
843
823
 
844
824
  // ─── Playlist card ───
845
825
  $playlist-card-bg: $color-gray-100;
@@ -883,8 +863,8 @@ $channel-card-mini-name: $color-white;
883
863
  $channel-card-mini-info: $color-gray-400;
884
864
 
885
865
  // ─── Playlist card mini ───
886
- $playlist-card-mini-empty-gradient-from: $color-gray-700;
887
- $playlist-card-mini-empty-gradient-to: $color-gray-800;
866
+ $playlist-card-mini-empty-gradient-from: $color-slate-700;
867
+ $playlist-card-mini-empty-gradient-to: $color-slate-750;
888
868
  $playlist-card-mini-empty-text: $color-white;
889
869
  $playlist-card-mini-name: $color-white;
890
870
  $playlist-card-mini-user: $color-gray-500;
@@ -920,13 +900,12 @@ $auth-register-link--hover: $color-indigo-blue;
920
900
  $auth-register-login: $color-gray-400;
921
901
 
922
902
  // ─── Native card ───
923
- $native-card-bg: $color-black;
903
+ $native-card-bg: $color-slate-800;
924
904
  $native-card-primary: $color-gray-400;
925
905
  $native-card-secondary: $color-gray-500;
926
906
 
927
907
  // ─── Page: grouped categories ───
928
908
  $grouped-highlight-border: transparent;
929
- $grouped-separator: $color-gray-800;
930
909
  $grouped-category-icon: $color-gray-500;
931
910
  $grouped-footer-text: $color-gray-500;
932
911
  $grouped-section-border: $color-gray-200;
@@ -974,16 +953,6 @@ $contacts-wrapper-bg: $color-gray-200;
974
953
  $contacts-link: $color-indigo-400;
975
954
  $contacts-link--hover: $color-indigo-300;
976
955
 
977
- // ─── Page: playlist ───
978
- $page-playlist-edit: $color-gray-500;
979
- $page-playlist-edit-border: $color-slate-700;
980
- $page-playlist-header-border: $color-slate-700;
981
- $page-playlist-avatar-bg: $color-indigo-600;
982
- $page-playlist-avatar-text: $color-white;
983
- $page-playlist-user-name: $color-gray-400;
984
- $page-playlist-private-bg: $color-slate-750;
985
- $page-playlist-like-bg: $color-slate-800;
986
-
987
956
  // ─── Page: playlists ───
988
957
  $page-playlists-border: transparent;
989
958
 
@@ -27,8 +27,8 @@
27
27
  @click="showLanguageMenu"
28
28
  >
29
29
  <img
30
- v-if="clientCountryIcon"
31
- :src="`/icons/flags/${clientCountryIcon}.svg`"
30
+ v-if="clientCountryFlag"
31
+ :src="clientCountryFlag"
32
32
  :alt="clientCountryIcon"
33
33
  class="layout-header__language-icon"
34
34
  width="24"
@@ -96,14 +96,8 @@
96
96
  import { AuthSteps } from '../../runtime';
97
97
  import { onClickOutside } from '@vueuse/core';
98
98
  import { AuthorizationApiService } from '../../services/api/authorization.service';
99
- import { isoCodeMap } from '../../utils/iso-code-map';
100
99
 
101
- const clientCountryCode = useState<string>('clientCountryCode', () => '');
102
- const clientCountryIcon = computed(() => {
103
- const code = clientCountryCode.value;
104
- if (!code) return '';
105
- return isoCodeMap[code] || code;
106
- });
100
+ const { icon: clientCountryIcon, flagSrc: clientCountryFlag } = useClientCountry();
107
101
 
108
102
  const burgerShow = ref(false);
109
103
  const showLanguage = ref(false);
@@ -42,9 +42,9 @@
42
42
  >
43
43
  <summary class="layout-language__summary">
44
44
  <img
45
- v-if="clientCountryIcon"
45
+ v-if="clientCountryFlag"
46
46
  :alt="clientCountryName"
47
- :src="`/icons/flags/${clientCountryIcon}.svg`"
47
+ :src="clientCountryFlag"
48
48
  class="layout-language__icon"
49
49
  width="20"
50
50
  height="20"
@@ -109,8 +109,6 @@
109
109
 
110
110
  <script setup lang="ts">
111
111
  import type { ISelectItem } from '../../types';
112
- import { isoCodeMap } from '../../utils/iso-code-map';
113
- import { DictionariesApiService } from '../../services/api/dictionaries.service';
114
112
 
115
113
  const { locale, locales } = useI18n();
116
114
  const switchLocalePath = useSwitchLocalePath();
@@ -119,22 +117,14 @@ defineProps<{
119
117
  iconSize: string
120
118
  }>();
121
119
 
122
- const countriesItems = useState<ISelectItem[]>('countriesItems', () => []);
123
- const clientCountryCode = useState<string>('clientCountryCode', () => '');
124
-
125
- const clientCountryIcon = computed(() => {
126
- const code = clientCountryCode.value;
127
- if (!code) return '';
128
- return isoCodeMap[code] || code;
129
- });
130
-
131
- const clientCountryNameState = useState<string>('clientCountryName', () => '');
132
-
133
- const clientCountryName = computed(() => {
134
- if (!clientCountryIcon.value) return '';
135
- const fromList = countriesItems.value.find(item => item.icon === clientCountryIcon.value)?.value;
136
- return fromList || clientCountryNameState.value || '';
137
- });
120
+ const {
121
+ icon: clientCountryIcon,
122
+ name: clientCountryName,
123
+ flagSrc: clientCountryFlag,
124
+ countriesItems,
125
+ setCountry,
126
+ loadCountries,
127
+ } = useClientCountry();
138
128
 
139
129
  const isCountryOpen = ref(false);
140
130
  const countryValue = ref('');
@@ -144,26 +134,6 @@ const filteredCountries = computed(() => {
144
134
  return countriesItems.value.filter(item => item.title.toLowerCase().includes(query));
145
135
  });
146
136
 
147
- const isCountryLoading = ref(false);
148
-
149
- // Список стран не приходит в payload (см. plugins/countries.server.ts) — догружаем
150
- // его лениво на клиенте при первом открытии селектора.
151
- async function loadCountries() {
152
- if (countriesItems.value.length || isCountryLoading.value) return;
153
- isCountryLoading.value = true;
154
- try {
155
- const data = await DictionariesApiService.getCountries();
156
- countriesItems.value = (data?.countries ?? []).map((item) => {
157
- const iso = item.isoCodes[0].toLowerCase();
158
- return { title: item.title, value: item.title, icon: isoCodeMap[iso] || iso, key: iso };
159
- });
160
- } catch {
161
- // оставляем пустым — следующее открытие попробует снова
162
- } finally {
163
- isCountryLoading.value = false;
164
- }
165
- }
166
-
167
137
  function onCountryToggle(e: Event) {
168
138
  isCountryOpen.value = (e.target as HTMLDetailsElement).open;
169
139
  if (isCountryOpen.value) loadCountries();
@@ -191,18 +161,10 @@ function changeLocale(locale: string) {
191
161
  window.location.href = switchLocalePath(locale);
192
162
  }
193
163
 
194
- const COUNTRY_COOKIE_OPTS = { maxAge: 60 * 60 * 24 * 365, path: '/', secure: true, sameSite: 'lax' as const };
195
- const countryCookie = useCookie('clientCountryCode', COUNTRY_COOKIE_OPTS);
196
- const countryNameCookie = useCookie('clientCountryName', COUNTRY_COOKIE_OPTS);
197
- const countryNameCodeCookie = useCookie('clientCountryNameCode', COUNTRY_COOKIE_OPTS);
198
-
199
- function changeCountry(item: ISelectItem) {
200
- const code = item.key || '';
201
- clientCountryCode.value = code;
202
- clientCountryNameState.value = item.value;
203
- countryCookie.value = code;
204
- countryNameCookie.value = item.value;
205
- countryNameCodeCookie.value = code;
164
+ async function changeCountry(item: ISelectItem) {
165
+ setCountry(item);
166
+ // куку пишет вотчер useCookie — ждём флаш, иначе навигация может срезать запись
167
+ await nextTick();
206
168
  window.location.href = '/';
207
169
  }
208
170
  </script>
@@ -121,12 +121,16 @@ $icon-size: 16px;
121
121
  padding-block: 12px;
122
122
 
123
123
  @include to-br(sm) {
124
+ --input-autofill-text: #{$search-text--mobile};
125
+
124
126
  color: $search-text--mobile;
125
127
  }
126
128
 
127
129
  @include from-br(sm) {
128
130
  @include font-sm;
129
131
 
132
+ --input-autofill-text: #{$search-text};
133
+
130
134
  color: $search-text;
131
135
  }
132
136
  }
@@ -336,6 +336,11 @@ const normalizedValue = computed(() => {
336
336
  color: var(--input-pre-icon, #{$input-pre-icon});
337
337
  }
338
338
 
339
+ .ui-input.ui-input--s {
340
+ --input-text: var(--input-s-text, #{$input-s-text});
341
+ --input-autofill-text: var(--input-s-text, #{$input-s-text});
342
+ }
343
+
339
344
  .ui-input.ui-input--s .ui-input__input {
340
345
  padding: 8px 16px;
341
346
  background-color: var(--input-s-bg, #{$input-s-bg});
@@ -0,0 +1,121 @@
1
+ // @vitest-environment nuxt
2
+ import { describe, it, expect, beforeEach, vi } from 'vitest';
3
+ import { useClientCountry } from '../use-client-country';
4
+
5
+ const getCountries = vi.fn();
6
+
7
+ vi.mock('../../services/api/dictionaries.service', () => ({
8
+ DictionariesApiService: { getCountries: () => getCountries() },
9
+ }));
10
+
11
+ const setCode = (code: string) => useState<string>('clientCountryCode', () => '').value = code;
12
+ const setResolvedName = (name: string) => useState<string>('clientCountryName', () => '').value = name;
13
+
14
+ describe('useClientCountry', () => {
15
+ beforeEach(() => {
16
+ useNuxtApp().payload.state = {};
17
+ getCountries.mockReset();
18
+ });
19
+
20
+ it('без кода не отдаёт ни флаг, ни имя', () => {
21
+ const { icon, name, flagSrc } = useClientCountry();
22
+
23
+ expect(icon.value).toBe('');
24
+ expect(name.value).toBe('');
25
+ expect(flagSrc.value).toBe('');
26
+ });
27
+
28
+ it('строит иконку и путь к флагу из кода', () => {
29
+ setCode('ad');
30
+ setResolvedName('Andorra');
31
+ const { icon, flagSrc } = useClientCountry();
32
+
33
+ expect(icon.value).toBe('ad');
34
+ expect(flagSrc.value).toBe('/icons/flags/ad.svg');
35
+ });
36
+
37
+ it('нормализует код: регистр и пробелы', () => {
38
+ setCode(' AD ');
39
+ setResolvedName('Andorra');
40
+ const { icon, flagSrc } = useClientCountry();
41
+
42
+ expect(icon.value).toBe('ad');
43
+ expect(flagSrc.value).toBe('/icons/flags/ad.svg');
44
+ });
45
+
46
+ it('учитывает страны, где код флага отличается от ISO-кода', () => {
47
+ setCode('cn');
48
+ setResolvedName('China');
49
+ const { icon, flagSrc } = useClientCountry();
50
+
51
+ expect(icon.value).toBe('zh');
52
+ expect(flagSrc.value).toBe('/icons/flags/zh.svg');
53
+ });
54
+
55
+ it('не показывает флаг для служебного кода, которого нет в словаре', () => {
56
+ // Cloudflare отдаёт XX для неизвестного IP и T1 для Tor — файла такого флага нет
57
+ setCode('xx');
58
+ const { icon, name, flagSrc } = useClientCountry();
59
+
60
+ expect(icon.value).toBe('xx');
61
+ expect(name.value).toBe('');
62
+ expect(flagSrc.value).toBe('');
63
+ });
64
+
65
+ it('имя следует за кодом и не может остаться от другой страны', async () => {
66
+ setCode('ad');
67
+ // имя, доставшееся от предыдущей страны — именно этот рассинхрон ловим
68
+ setResolvedName('USA');
69
+ getCountries.mockResolvedValue({
70
+ countries: [
71
+ { title: 'Andorra', isoCodes: ['AD', 'AND'] },
72
+ { title: 'USA', isoCodes: ['US', 'USA'] },
73
+ ],
74
+ });
75
+
76
+ const { name, flagSrc, loadCountries } = useClientCountry();
77
+ await loadCountries();
78
+
79
+ expect(name.value).toBe('Andorra');
80
+ expect(flagSrc.value).toBe('/icons/flags/ad.svg');
81
+ });
82
+
83
+ it('до загрузки списка берёт имя, отрезолвленное на сервере', () => {
84
+ setCode('ad');
85
+ setResolvedName('Andorra');
86
+ const { name } = useClientCountry();
87
+
88
+ expect(name.value).toBe('Andorra');
89
+ });
90
+
91
+ it('выбор страны меняет и код, и имя', () => {
92
+ setCode('ad');
93
+ setResolvedName('Andorra');
94
+ const { code, name, flagSrc, setCountry } = useClientCountry();
95
+
96
+ setCountry({ title: 'USA', value: 'USA', icon: 'us', key: 'us' });
97
+
98
+ expect(code.value).toBe('us');
99
+ expect(name.value).toBe('USA');
100
+ expect(flagSrc.value).toBe('/icons/flags/us.svg');
101
+ });
102
+
103
+ it('не перезапрашивает список, если он уже загружен', async () => {
104
+ getCountries.mockResolvedValue({ countries: [{ title: 'Andorra', isoCodes: ['AD'] }] });
105
+ const { loadCountries } = useClientCountry();
106
+
107
+ await loadCountries();
108
+ await loadCountries();
109
+
110
+ expect(getCountries).toHaveBeenCalledTimes(1);
111
+ });
112
+
113
+ it('падение запроса оставляет список пустым — следующее открытие попробует снова', async () => {
114
+ getCountries.mockRejectedValue(new Error('500'));
115
+ const { countriesItems, loadCountries } = useClientCountry();
116
+
117
+ await loadCountries();
118
+
119
+ expect(countriesItems.value).toEqual([]);
120
+ });
121
+ });
@@ -0,0 +1,73 @@
1
+ import type { ISelectItem } from '../types';
2
+ import { isoCodeMap } from '../utils/iso-code-map';
3
+ import { DictionariesApiService } from '../services/api/dictionaries.service';
4
+
5
+ /** Ручной выбор страны переживает перезагрузку год — читает его server/middleware/country.ts */
6
+ const COOKIE_OPTS = { maxAge: 60 * 60 * 24 * 365, path: '/', secure: true, sameSite: 'lax' as const };
7
+
8
+ /**
9
+ * Единый источник страны клиента: ISO-код (гео-заголовок прокси или ручной выбор
10
+ * в селекторе), иконка флага и локализованное имя.
11
+ *
12
+ * Имя всегда резолвится ОТ ТЕКУЩЕГО кода — своего кеша у него нет. Раньше имя
13
+ * лежало в отдельной куке `clientCountryName` рядом с парной `clientCountryNameCode`,
14
+ * и пара могла разъехаться: заголовок писал одну страну, флаг и селектор показывали
15
+ * другую (реальный кейс на проде: `clientCountryName=USA` при коде `ad`).
16
+ */
17
+ export const useClientCountry = () => {
18
+ const code = useState<string>('clientCountryCode', () => '');
19
+ const resolvedName = useState<string>('clientCountryName', () => '');
20
+ const countriesItems = useState<ISelectItem[]>('countriesItems', () => []);
21
+ const codeCookie = useCookie<string | null>('clientCountryCode', COOKIE_OPTS);
22
+
23
+ /** ISO-код → имя файла флага (у части стран код локали отличается от кода страны) */
24
+ const toIcon = (value: string) => {
25
+ const iso = value.trim().toLowerCase();
26
+ return iso ? isoCodeMap[iso] || iso : '';
27
+ };
28
+
29
+ const icon = computed(() => toIcon(code.value));
30
+
31
+ // Список стран догружается лениво на клиенте (см. layout/language.vue) — если он
32
+ // уже есть, имя берём из него: там оно точно совпадает с текущим кодом.
33
+ const name = computed(() => {
34
+ if (!icon.value) return '';
35
+ return countriesItems.value.find(item => item.icon === icon.value)?.title || resolvedName.value;
36
+ });
37
+
38
+ // Флаг только для страны, которую смогли назвать: гео-заголовок умеет отдавать
39
+ // служебные коды (Cloudflare `XX` для неизвестного IP и `T1` для Tor, MaxMind `AP`),
40
+ // их нет ни в словаре стран, ни среди файлов флагов — вышла бы битая картинка.
41
+ const flagSrc = computed(() => (icon.value && name.value ? `/icons/flags/${icon.value}.svg` : ''));
42
+
43
+ /** Ручной выбор страны в селекторе: наружу переживает перезагрузку только код. */
44
+ const setCountry = (item: ISelectItem) => {
45
+ code.value = item.key || '';
46
+ resolvedName.value = item.title;
47
+ codeCookie.value = code.value || null;
48
+ };
49
+
50
+ const isLoading = ref(false);
51
+
52
+ /**
53
+ * Полный список стран в payload не кладётся (см. plugins/countries.server.ts) —
54
+ * догружаем лениво на клиенте, когда открыли селектор.
55
+ */
56
+ const loadCountries = async () => {
57
+ if (countriesItems.value.length || isLoading.value) return;
58
+ isLoading.value = true;
59
+ try {
60
+ const data = await DictionariesApiService.getCountries();
61
+ countriesItems.value = (data?.countries ?? []).map((item) => {
62
+ const iso = item.isoCodes[0].toLowerCase();
63
+ return { title: item.title, value: item.title, icon: toIcon(iso), key: iso };
64
+ });
65
+ } catch {
66
+ // оставляем пустым — следующее открытие попробует снова
67
+ } finally {
68
+ isLoading.value = false;
69
+ }
70
+ };
71
+
72
+ return { code, icon, name, flagSrc, countriesItems, setCountry, loadCountries };
73
+ };
@@ -3,6 +3,16 @@ import type { Ref } from 'vue';
3
3
  import { useRoute } from 'vue-router';
4
4
  import type { IChipsItem } from '../types';
5
5
 
6
+ type MetaOptions = {
7
+ /**
8
+ * Подменяет i18n-ключ заголовка (по умолчанию 'h1') — вызывается с фактической
9
+ * сортировкой, потому что варианты заголовка есть не у каждой из них.
10
+ * Нужен, когда подставляемого текста может не быть и в шаблоне остаётся
11
+ * висячий предлог (`… Videos in ` без страны).
12
+ */
13
+ h1Key?: (sortType: string) => string
14
+ };
15
+
6
16
  /**
7
17
  * Генерирует реактивные meta (title, description) и h1 с учётом сортировки, страницы и i18n-ключей.
8
18
  * @param t - функция перевода (i18n)
@@ -13,8 +23,9 @@ import type { IChipsItem } from '../types';
13
23
  * @param secondText - второй подставляемый текст
14
24
  * @param thirdText - третий подставляемый текст
15
25
  * @param fourthText - четвёртый подставляемый текст
26
+ * @param options - уточнения (подмена ключа h1)
16
27
  */
17
- export function useMeta(t: (key: string, params?: Record<string, unknown>) => string, page: string, brandName: string, sortOptions?: IChipsItem[], text?: Ref<string>, secondText?: Ref<string>, thirdText?: Ref<string>, fourthText?: Ref<string>) {
28
+ export function useMeta(t: (key: string, params?: Record<string, unknown>) => string, page: string, brandName: string, sortOptions?: IChipsItem[], text?: Ref<string>, secondText?: Ref<string>, thirdText?: Ref<string>, fourthText?: Ref<string>, options?: MetaOptions) {
18
29
  const route = useRoute();
19
30
 
20
31
  const sortType = computed(() => {
@@ -46,7 +57,7 @@ export function useMeta(t: (key: string, params?: Record<string, unknown>) => st
46
57
  }
47
58
 
48
59
  const metaTitle = computed(() => getPath('title'));
49
- const h1 = computed(() => getPath('h1'));
60
+ const h1 = computed(() => getPath(options?.h1Key?.(sortType.value) || 'h1'));
50
61
 
51
62
  const meta = computed(() => {
52
63
  const description = getPath('meta_description');
package/package.json CHANGED
@@ -1,7 +1,7 @@
1
1
  {
2
2
  "name": "itube-specs",
3
3
  "type": "module",
4
- "version": "0.0.868",
4
+ "version": "0.0.870",
5
5
  "main": "./nuxt.config.ts",
6
6
  "types": "./types/index.d.ts",
7
7
  "scripts": {
@@ -117,17 +117,6 @@ const breadcrumbs = [
117
117
  }
118
118
  }
119
119
 
120
- .grouped__options-separate {
121
- display: block;
122
- width: 1px;
123
- height: 24px;
124
- background-color: $grouped-separator;
125
-
126
- @include from-br(sm) {
127
- height: 32px;
128
- }
129
- }
130
-
131
120
  .grouped__loading {
132
121
  --size: 24px;
133
122
 
package/pages/index.vue CHANGED
@@ -21,9 +21,8 @@ import { convertQueryCategories, convertString, AsyncData } from '../runtime';
21
21
  import { VideosApiService } from '../services/api/videos.service';
22
22
  import { ModelsApiService } from '../services/api/models.service';
23
23
  import { CategoriesApiService } from '../services/api/categories.service';
24
- import { isoCodeMap } from '../utils/iso-code-map';
25
24
  import { sortItemsDefault } from '../lib';
26
- import type { ISelectItem, IChipsItem } from '../types';
25
+ import type { IChipsItem } from '../types';
27
26
 
28
27
  const { t } = useI18n();
29
28
 
@@ -62,28 +61,28 @@ const routeQueryCategories = computed(() => {
62
61
  return '';
63
62
  });
64
63
 
65
- const clientCountryCode = useState<string>('clientCountryCode', () => '');
66
- const countriesItems = useState<ISelectItem[]>('countriesItems', () => []);
67
- const clientCountryNameState = useState<string>('clientCountryName', () => '');
64
+ const clientCountry = useClientCountry();
68
65
 
69
- const countryName = computed(() => {
70
- if (route.query['sort']) return '';
71
- const code = clientCountryCode.value;
72
- if (!code) return '';
73
- const icon = isoCodeMap[code] || code;
74
- const fromList = countriesItems.value.find(item => item.icon === icon)?.title;
75
- return fromList || clientCountryNameState.value || '';
76
- });
66
+ // Страна в заголовке уместна только на дефолтной сортировке (в остальных шаблонах
67
+ // её нет) — при явной сортировке гасим и подпись, и флаг.
68
+ const isCountryShown = computed(() => !route.query['sort'] && !!clientCountry.name.value);
77
69
 
78
- const { meta, h1 } = useMeta(t, pageName, useAppConfig().brandName, sortItemsDefault, routeQueryCategories, countryName);
70
+ const countryName = computed(() => (isCountryShown.value ? clientCountry.name.value : ''));
79
71
 
80
- const countryFlagImg = computed(() => {
81
- if (route.query['sort']) return undefined;
82
- const code = clientCountryCode.value;
83
- if (!code) return undefined;
84
- const icon = isoCodeMap[code] || code;
85
- return `/icons/flags/${icon}.svg`;
86
- });
72
+ const { meta, h1 } = useMeta(
73
+ t,
74
+ pageName,
75
+ useAppConfig().brandName,
76
+ sortItemsDefault,
77
+ routeQueryCategories,
78
+ countryName,
79
+ undefined,
80
+ undefined,
81
+ // Страну не определили — берём заголовок без неё, иначе останется висячий предлог.
82
+ { h1Key: sortType => (sortType === 'trending' && !countryName.value ? 'h1_no_country' : 'h1') },
83
+ );
84
+
85
+ const countryFlagImg = computed(() => (isCountryShown.value ? clientCountry.flagSrc.value : undefined));
87
86
 
88
87
  watchEffect(() => {
89
88
  useHead(meta.value);
@@ -1,45 +1,32 @@
1
1
  // SSR-плагин: по ISO-коду клиента резолвит имя страны в useState('clientCountryName')
2
2
  // для гео-заголовка. Полный список стран в payload не кладётся (его лениво догружает
3
- // селектор языка на клиенте). Результат кэшируется в куку и в память процесса.
4
- import type { ICountries, ISelectItem } from '../types';
5
- import { isoCodeMap } from '../utils/iso-code-map';
3
+ // селектор языка на клиенте). Результат кэшируется в память процесса.
4
+ import type { ICountries } from '../types';
6
5
 
7
- const COOKIE_OPTS = { maxAge: 60 * 60 * 24 * 365, path: '/', secure: true, sameSite: 'lax' as const };
6
+ /** Опции легаси-кук — нужны только чтобы их погасить (см. ниже) */
7
+ const LEGACY_COOKIE_OPTS = { path: '/', secure: true, sameSite: 'lax' as const };
8
8
 
9
- type CountriesCache = {
10
- items: ISelectItem[];
11
- nameByIso: Map<string, string>;
12
- };
9
+ const cache = new Map<string, Promise<Map<string, string> | null>>();
13
10
 
14
- const cache = new Map<string, Promise<CountriesCache | null>>();
15
-
16
- function buildCache(data: ICountries): CountriesCache {
17
- const items: ISelectItem[] = [];
11
+ function buildNameByIso(data: ICountries): Map<string, string> {
18
12
  const nameByIso = new Map<string, string>();
19
13
 
20
14
  for (const item of data.countries) {
21
- const iso = item.isoCodes[0].toLowerCase();
22
- items.push({
23
- title: item.title,
24
- value: item.title,
25
- icon: isoCodeMap[iso] || iso,
26
- key: iso,
27
- });
28
15
  for (const code of item.isoCodes) {
29
16
  nameByIso.set(code.toLowerCase(), item.title);
30
17
  }
31
18
  }
32
- return { items, nameByIso };
19
+ return nameByIso;
33
20
  }
34
21
 
35
- function getCountries(language: string, domain: string): Promise<CountriesCache | null> {
22
+ function getNameByIso(language: string, domain: string): Promise<Map<string, string> | null> {
36
23
  const key = `${language}|${domain}`;
37
24
  let promise = cache.get(key);
38
25
  if (!promise) {
39
26
  promise = $fetch<ICountries>('/api/dictionaries/countries', {
40
27
  headers: { 'x-language': language, 'x-domain': domain },
41
28
  })
42
- .then(data => (data?.countries?.length ? buildCache(data) : null))
29
+ .then(data => (data?.countries?.length ? buildNameByIso(data) : null))
43
30
  .catch(() => {
44
31
  cache.delete(key);
45
32
  return null;
@@ -51,34 +38,29 @@ function getCountries(language: string, domain: string): Promise<CountriesCache
51
38
 
52
39
  export default defineNuxtPlugin(async () => {
53
40
  const event = useRequestEvent();
54
- const code = (event?.context?.clientCountryCode as string | undefined) || '';
41
+ const code = ((event?.context?.clientCountryCode as string | undefined) || '').trim().toLowerCase();
55
42
 
56
43
  const countryName = useState<string>('clientCountryName', () => '');
57
44
 
45
+ // Имя страны больше не кешируется в куках: пара `clientCountryName` +
46
+ // `clientCountryNameCode` умела разъезжаться с кодом страны (заголовок писал одну
47
+ // страну, флаг и селектор показывали другую), а выигрыш был только в одном
48
+ // сетевом запросе на холодный процесс. Старые куки гасим, чтобы не висели год.
49
+ const legacyName = useCookie<string | null>('clientCountryName', LEGACY_COOKIE_OPTS);
50
+ const legacyNameCode = useCookie<string | null>('clientCountryNameCode', LEGACY_COOKIE_OPTS);
51
+ if (legacyName.value) legacyName.value = null;
52
+ if (legacyNameCode.value) legacyNameCode.value = null;
53
+
58
54
  // Резолвим только имя текущей страны для гео-заголовка. Полный список стран
59
55
  // НЕ кладём в useState (не сериализуем в payload) — language.vue догружает
60
56
  // его лениво на клиенте при открытии селектора.
61
57
  if (!code) return;
62
58
 
63
- const codeCookie = useCookie<string>('clientCountryNameCode', COOKIE_OPTS);
64
- const nameCookie = useCookie<string>('clientCountryName', COOKIE_OPTS);
65
-
66
- // Тёплая кука → имя уже известно, серверный фетч стран не нужен.
67
- if (nameCookie.value && codeCookie.value === code) {
68
- countryName.value = nameCookie.value;
69
- return;
70
- }
71
-
72
59
  const language = useRequestHeaders(['x-language'])['x-language'] ?? '';
73
60
  const domain = useRuntimeConfig().public.xDomain as string;
74
61
 
75
- const result = await getCountries(language, domain);
76
- if (!result) return;
62
+ const nameByIso = await getNameByIso(language, domain);
63
+ if (!nameByIso) return;
77
64
 
78
- const name = result.nameByIso.get(code.toLowerCase());
79
- if (name) {
80
- countryName.value = name;
81
- nameCookie.value = name;
82
- codeCookie.value = code;
83
- }
65
+ countryName.value = nameByIso.get(code) || '';
84
66
  });
@@ -1,10 +1,10 @@
1
1
  // Определяет страну клиента и кладёт ISO-код в event.context.clientCountryCode:
2
- // сначала из cookie `clientCountryCode`, иначе из заголовков прокси (Cloudflare
3
- // `cf-ipcountry` / `x-country-code` / `x-country`).
2
+ // сначала из cookie `clientCountryCode` (ручной выбор в селекторе), иначе из
3
+ // заголовков прокси (Cloudflare `cf-ipcountry` / `x-country-code` / `x-country`).
4
4
  export default defineEventHandler((event) => {
5
5
  const cookie = getCookie(event, 'clientCountryCode');
6
6
  if (cookie) {
7
- event.context.clientCountryCode = cookie;
7
+ event.context.clientCountryCode = cookie.trim().toLowerCase();
8
8
  return;
9
9
  }
10
10