itube-specs 0.0.939 → 0.0.940

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.
@@ -713,6 +713,7 @@ $filter-chips-bg--hover: $color-zinc-800-60;
713
713
  $filter-chips-close: $color-zinc-500;
714
714
  $filter-chips-close--hover: $color-white;
715
715
  $filter-chips-label: $color-zinc-500;
716
+ $filter-chips-flag-border: $color-white-20;
716
717
  $filter-chips-row-clear-text: $color-zinc-500;
717
718
  $filter-chips-row-clear-text--hover: $color-white;
718
719
 
@@ -762,6 +762,7 @@ $filter-chips-bg--hover: $color-gray-300;
762
762
  $filter-chips-close: $color-gray-500;
763
763
  $filter-chips-close--hover: $color-gray-950;
764
764
  $filter-chips-label: $color-gray-500;
765
+ $filter-chips-flag-border: $color-gray-300;
765
766
  $filter-chips-row-clear-text: $color-gray-500;
766
767
  $filter-chips-row-clear-text--hover: $color-gray-950;
767
768
 
@@ -1,10 +1,25 @@
1
- <!-- Чипса выбранного фильтра: приглушённое название фильтра, значение и сброс по клику -->
1
+ <!-- Чипса выбранного фильтра: приглушённое название фильтра, значение и сброс по клику.
2
+
3
+ У языковой чипсы вебкамов вместо названия фильтра идёт флаг страны языка, а подпись
4
+ уже собрана переводом («Russian speaking») — поэтому у неё гасится капитализация слов.
5
+ Файла флага для языка может не быть (Китай, Корея) — тогда чипса остаётся без него. -->
2
6
  <template>
3
7
  <button
8
+ :class="{'--exact-case': item.flag}"
4
9
  class="filter-chips"
5
10
  type="button"
6
11
  @click="emit('click')"
7
12
  >
13
+ <img
14
+ v-if="item.flag && !isFlagBroken"
15
+ :src="`/icons/flags/${item.flag}.svg`"
16
+ :alt="item.flag"
17
+ class="filter-chips__flag"
18
+ width="16"
19
+ height="16"
20
+ loading="lazy"
21
+ @error="isFlagBroken = true"
22
+ >
8
23
  <span class="filter-chips__text">
9
24
  <span
10
25
  v-if="item.label && !withoutLabel"
@@ -31,6 +46,8 @@ defineProps<{
31
46
  const emit = defineEmits<{
32
47
  (eventName: 'click'): void
33
48
  }>();
49
+
50
+ const isFlagBroken = ref(false);
34
51
  </script>
35
52
 
36
53
  <style lang="scss">
@@ -58,6 +75,21 @@ const emit = defineEmits<{
58
75
  }
59
76
  }
60
77
 
78
+ .filter-chips.--exact-case {
79
+ text-transform: none;
80
+ }
81
+
82
+ // «бабл» как у флагов в тумбе модели, только мельче: круг со срезанным левым нижним углом
83
+ .filter-chips__flag {
84
+ flex-shrink: 0;
85
+ width: 16px;
86
+ height: 16px;
87
+ object-fit: cover;
88
+ border: 1px solid $filter-chips-flag-border;
89
+ border-radius: 50%;
90
+ border-end-start-radius: 0;
91
+ }
92
+
61
93
  // подпись и значение читаются одной фразой, поэтому не отдельный флекс-элемент
62
94
  .filter-chips__label {
63
95
  padding-right: 4px;
@@ -78,7 +78,7 @@ const props = defineProps<{
78
78
 
79
79
  const model = defineModel<boolean>();
80
80
 
81
- const { t, locale } = useI18n();
81
+ const { t, te, locale } = useI18n();
82
82
  const route = useRoute();
83
83
  const router = useRouter();
84
84
 
@@ -108,8 +108,12 @@ const pendingRouteLike = reactive({
108
108
  },
109
109
  });
110
110
 
111
- const { chipsItems } = useFilterChipsItems(filters, route, t, locale);
112
- const { chipsItems: pendingChipsItems } = useFilterChipsItems(filters, pendingRouteLike, t, locale);
111
+ /** Пустая строка вместо имени ключа: у сайта перевода может не быть, и тогда
112
+ * подпись чипсы соберётся без него (см. `useFilterChipsItems`). */
113
+ const translate = (key: string, params?: Record<string, unknown>) => (te(key) ? t(key, params || {}) : '');
114
+
115
+ const { chipsItems } = useFilterChipsItems(filters, route, translate, locale);
116
+ const { chipsItems: pendingChipsItems } = useFilterChipsItems(filters, pendingRouteLike, translate, locale);
113
117
 
114
118
  const quickFilters = computed(() =>
115
119
  filters.value
@@ -41,12 +41,16 @@ function linkTo(item: INavLinkItem, index: number) {
41
41
  return { path: route.path, query: query };
42
42
  }
43
43
 
44
- if (!item.key) return { query: { ...route.query } };
44
+ // путь всегда явный: локацию без него роутер разрешает сам и у нишевых разделов
45
+ // (они живут на алиасах записей) терял префикс — а из `resolve` собирается `href`,
46
+ // так что промах уезжал в разметку ссылки
47
+ if (!item.key) return { path: route.path, query: { ...route.query } };
45
48
 
46
49
  const oldQuery = { ...route.query };
47
50
  delete oldQuery['page'];
48
51
 
49
52
  return {
53
+ path: route.path,
50
54
  query: {
51
55
  ...oldQuery,
52
56
  [item.key]: item.value
@@ -213,10 +213,10 @@ const sections = computed<ISwitcherItem[]>(() => [
213
213
  ]);
214
214
 
215
215
  /**
216
- * Вход в раздел: если интерфейс не английский, сразу показываем моделей, которые
217
- * говорят на языке пользователя. Только при первом заходе за сессию — иначе
218
- * снятый пользователем фильтр возвращался бы сам, — и только если такой язык у
219
- * провайдера в выдаче есть (иначе получили бы пустую страницу).
216
+ * Вход в раздел: сразу показываем моделей, которые говорят на языке пользователя.
217
+ * Только при первом заходе за сессию — иначе снятый пользователем фильтр
218
+ * возвращался бы сам, — и только если такой язык у провайдера в выдаче есть
219
+ * (иначе получили бы пустую страницу).
220
220
  */
221
221
  const isLanguageFilterApplied = useState('webcams-language-filter', () => false);
222
222
 
@@ -225,12 +225,12 @@ onMounted(() => {
225
225
  isLanguageFilterApplied.value = true;
226
226
 
227
227
  const code = locale.value.slice(0, 2).toLowerCase();
228
- if (code === 'en' || filterCount.value > 0) return;
228
+ if (filterCount.value > 0) return;
229
229
 
230
230
  const languageFilter = filters.value.find(item => item.name === 'language');
231
231
  if (!languageFilter?.options.some(option => option.name === code)) return;
232
232
 
233
- router.replace({ query: { ...route.query, filter_language: code } });
233
+ router.replace({ path: route.path, query: { ...route.query, filter_language: code } });
234
234
  });
235
235
 
236
236
  function translate(key: string, fallback: string) {
@@ -36,6 +36,42 @@ describe('useFilterChipsItems', () => {
36
36
  expect(chipsItems.value[0].value).toEqual(['filter_hair_color']);
37
37
  });
38
38
 
39
+ it('языковая чипса: флаг страны языка и подпись переводом, без названия фильтра', () => {
40
+ const filters = ref([{
41
+ name: 'language',
42
+ title: 'Language',
43
+ options: [
44
+ { name: 'es', title: 'Spanish' },
45
+ { name: 'ru', title: 'Russian' },
46
+ ],
47
+ }]);
48
+ const route = { query: { filter_language: 'es' } };
49
+ const translate = (key: string, params?: Record<string, unknown>) => `${params?.lang} speaking (${key})`;
50
+ const { chipsItems } = useFilterChipsItems(filters, route, translate, ref('en'));
51
+
52
+ expect(chipsItems.value[0]).toEqual({
53
+ title: 'Spanish speaking (webcams.speaking)',
54
+ flag: 'es',
55
+ value: ['filter_language'],
56
+ });
57
+ });
58
+
59
+ it('языковая чипса: язык без своей страны берёт флаг по мапе, без перевода — только язык', () => {
60
+ const filters = ref([{
61
+ name: 'language',
62
+ title: 'Language',
63
+ options: [{ name: 'ja', title: 'Japanese' }],
64
+ }]);
65
+ const route = { query: { filter_language: 'ja' } };
66
+ const { chipsItems } = useFilterChipsItems(filters, route, () => '', ref('en'));
67
+
68
+ expect(chipsItems.value[0]).toEqual({
69
+ title: 'Japanese',
70
+ flag: 'jp',
71
+ value: ['filter_language'],
72
+ });
73
+ });
74
+
39
75
  it('группирует from/to фильтры', () => {
40
76
  const filters = ref([{
41
77
  name: 'age',
@@ -1,5 +1,6 @@
1
1
  import { useRoute } from 'vue-router';
2
- import { AsyncData, type Language } from '../../runtime';
2
+ import { AsyncData } from '../../runtime';
3
+ import type { Language } from '../../runtime';
3
4
  import type { IPlaylistData } from '../../types';
4
5
 
5
6
  export const useFetchPlaylistsById = async (apiMethod: (...args: any[]) => Promise<IPlaylistData>) => {
@@ -1,4 +1,5 @@
1
- import { AsyncData, type Language } from '../../runtime';
1
+ import { AsyncData } from '../../runtime';
2
+ import type { Language } from '../../runtime';
2
3
  import type { IVideoCard, PaginatedResponse } from '../../types';
3
4
  import { useRoute } from 'vue-router';
4
5
 
@@ -64,8 +64,10 @@ export function useFilterPending(options: {
64
64
  options.open.value = false;
65
65
  const query = options.encode(values.value, route.query);
66
66
 
67
- if (options.navigate === 'replace') router.replace({ query });
68
- else router.push({ query });
67
+ // путь явно: локация без него разрешается роутером, и у нишевых разделов
68
+ // (алиасы записей) префикс ниши пропадал
69
+ if (options.navigate === 'replace') router.replace({ path: route.path, query });
70
+ else router.push({ path: route.path, query });
69
71
  }
70
72
 
71
73
  return { values, count, isFiltered, set, remove, replace, reset, apply };
@@ -2,12 +2,16 @@ import type { IFilterChipItem, IModelFilter, IModelFilterOptions } from '../type
2
2
  import type { LocationQuery } from '#vue-router';
3
3
  import { getMonth } from '../runtime';
4
4
  import { buildChipParts } from '../utils/filter-chips/build-chip-parts';
5
+ import { LANG_TO_FLAG } from '../utils/webcams/lang-to-flag';
5
6
  import type { Ref } from 'vue';
6
7
 
8
+ /** Единственный фильтр-язык у нас в вебкамах: у моделей такого поля нет. */
9
+ const LANGUAGE_FILTER = 'language';
10
+
7
11
  export function useFilterChipsItems(
8
12
  filters: Ref<IModelFilter[]>,
9
13
  route: { query: LocationQuery },
10
- t: (key: string) => string,
14
+ t: (key: string, params?: Record<string, unknown>) => string,
11
15
  locale: Ref<string>
12
16
  ) {
13
17
  const chipsItems = computed<IFilterChipItem[]>(() => {
@@ -83,6 +87,22 @@ export function useFilterChipsItems(
83
87
  const getValueText = (items: string[]) =>
84
88
  [...new Set(items.map(getValue).filter(v => v !== undefined && v !== null && v !== ''))].join(' - ');
85
89
 
90
+ /**
91
+ * Язык — не просто значение фильтра, а свойство модели: в облаке он читается
92
+ * как «Russian speaking» и с флагом страны языка, без приглушённой подписи
93
+ * фильтра (по макету раздела вебкамов). Флаг лежит файлом по коду СТРАНЫ, а
94
+ * фильтр приходит языком — переводим по мапе; нет файла — чипса просто без
95
+ * флага. Если у сайта ключа перевода ещё нет, остаётся название языка.
96
+ */
97
+ const buildLanguageChip = (valueText: string) => {
98
+ const code = String(route.query[`filter_${LANGUAGE_FILTER}`] || '').toLowerCase();
99
+
100
+ return {
101
+ title: t('webcams.speaking', { lang: valueText }) || valueText,
102
+ flag: LANG_TO_FLAG[code] || code,
103
+ };
104
+ };
105
+
86
106
  return groups
87
107
  .map(item => {
88
108
  const filter = findFilter(item);
@@ -90,7 +110,9 @@ export function useFilterChipsItems(
90
110
  const valueText = getValueText(item);
91
111
  if (!valueText) return null;
92
112
  return {
93
- ...buildChipParts(filter.title, valueText),
113
+ ...(filter.name === LANGUAGE_FILTER
114
+ ? buildLanguageChip(valueText)
115
+ : buildChipParts(filter.title, valueText)),
94
116
  value: item.map(
95
117
  sub => `filter_${sub.replace(/ /g, '_')}`
96
118
  ),
package/package.json CHANGED
@@ -1,7 +1,7 @@
1
1
  {
2
2
  "name": "itube-specs",
3
3
  "type": "module",
4
- "version": "0.0.939",
4
+ "version": "0.0.940",
5
5
  "main": "./nuxt.config.ts",
6
6
  "types": "./types/index.d.ts",
7
7
  "scripts": {
@@ -9,4 +9,8 @@ export interface IFilterChipItem {
9
9
  value?: string | string[] | number
10
10
  /** Тип фильтра, к которому относится чипса. */
11
11
  key?: string
12
+ /** Код страны для флага (`/icons/flags/<код>.svg`) — у языковой чипсы вебкамов.
13
+ * У такой чипсы подпись собрана переводом («Russian speaking»), поэтому
14
+ * капитализация слов к ней не применяется. */
15
+ flag?: string
12
16
  }
@@ -7,9 +7,14 @@ describe('getPaginationLink', () => {
7
7
  expect(result).toEqual({ path: '/videos', query: { sort: 'new' } });
8
8
  });
9
9
 
10
- it('для прочих страниц сохраняет query и проставляет номер (без path)', () => {
10
+ it('для прочих страниц сохраняет query и проставляет номер', () => {
11
11
  const result = getPaginationLink(5, 'page', '/videos', { sort: 'new' });
12
- expect(result).toEqual({ query: { sort: 'new', page: 5 } });
12
+ expect(result).toEqual({ path: '/videos', query: { sort: 'new', page: 5 } });
13
+ });
14
+
15
+ it('путь есть у любой страницы: относительную локацию роутер разрешал бы сам', () => {
16
+ const result = getPaginationLink(3, 'page', '/gay/webcams', { filter_language: 'ru' });
17
+ expect(result).toEqual({ path: '/gay/webcams', query: { filter_language: 'ru', page: 3 } });
13
18
  });
14
19
 
15
20
  it('зажимает значение < 1 к первой странице', () => {
@@ -19,6 +24,6 @@ describe('getPaginationLink', () => {
19
24
 
20
25
  it('перезаписывает существующий префикс', () => {
21
26
  const result = getPaginationLink(7, 'page', '/videos', { page: '2', q: 'x' });
22
- expect(result).toEqual({ query: { page: 7, q: 'x' } });
27
+ expect(result).toEqual({ path: '/videos', query: { page: 7, q: 'x' } });
23
28
  });
24
29
  });
@@ -3,10 +3,17 @@ type QueryRecord = Record<string, QueryParam>;
3
3
 
4
4
  /**
5
5
  * Строит объект ссылки для страницы пагинации: для первой страницы убирает параметр `prefix`
6
- * из query (и отдаёт path), для остальных — проставляет номер. Значение < 1 зажимается к 1.
6
+ * из query, для остальных — проставляет номер. Значение < 1 зажимается к 1.
7
+ *
8
+ * Путь проставляем ВСЕГДА, даже когда он равен текущему. Локация без `path` — относительная,
9
+ * и её разрешает роутер: у нишевых разделов (они живут на алиасах записей) так пропадал
10
+ * префикс ниши, а `href` у ссылки вообще собирается из результата `resolve` — то есть любой
11
+ * промах разрешения уезжает в разметку. Клик мышью при этом работает (навигация идёт по
12
+ * объекту), а «открыть в новой вкладке» — уже по `href`, и открывалось не то.
13
+ *
7
14
  * @param value - номер страницы
8
15
  * @param prefix - имя query-параметра страницы (напр. `page`)
9
- * @param path - текущий путь (используется только для первой страницы)
16
+ * @param path - текущий путь
10
17
  * @param query - текущий query
11
18
  */
12
19
  export function getPaginationLink(
@@ -14,7 +21,7 @@ export function getPaginationLink(
14
21
  prefix: string,
15
22
  path: string,
16
23
  query: QueryRecord,
17
- ): { path?: string; query: QueryRecord } {
24
+ ): { path: string; query: QueryRecord } {
18
25
  const safeValue = Math.max(1, Number(value));
19
26
 
20
27
  if (safeValue === 1) {
@@ -26,6 +33,7 @@ export function getPaginationLink(
26
33
  }
27
34
 
28
35
  return {
36
+ path,
29
37
  query: {
30
38
  ...query,
31
39
  [prefix]: safeValue,