itube-specs 0.0.944 → 0.0.947
This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
- package/README.md +76 -0
- package/assets/scss/base/_global.scss +15 -0
- package/components/card/category.vue +2 -1
- package/components/card/channel-mini.vue +1 -1
- package/components/card/model-mini.vue +6 -4
- package/components/card/playlist-mini.vue +1 -1
- package/components/card/round.vue +1 -1
- package/components/card/video/actions.vue +2 -0
- package/components/category/list.vue +2 -1
- package/components/filter/btn.vue +5 -4
- package/components/filter/popup.vue +1 -1
- package/components/info/grid.vue +2 -1
- package/components/info/main.vue +20 -4
- package/components/layout/language.vue +9 -1
- package/components/library/feed.vue +2 -2
- package/components/player/follow.vue +1 -1
- package/components/playlist/add.vue +1 -1
- package/components/playlist/edit.vue +1 -1
- package/components/search/item.vue +1 -1
- package/components/ui/section-title.vue +7 -1
- package/components/view/model-videos.vue +2 -2
- package/components/view/videos.vue +1 -1
- package/components/view/webcams.vue +1 -1
- package/components/webcam/counters.vue +1 -1
- package/composables/__tests__/use-client-country.test.ts +27 -27
- package/composables/use-client-country.ts +22 -20
- package/composables/use-route-query-categories.ts +25 -0
- package/i18n/locales/de-DE.json +10 -3
- package/i18n/locales/en-US.json +4 -4
- package/i18n/locales/es-ES.json +11 -4
- package/i18n/locales/fr-FR.json +11 -4
- package/i18n/locales/it-IT.json +11 -4
- package/i18n/locales/ja-JP.json +12 -5
- package/i18n/locales/ko-KR.json +12 -5
- package/i18n/locales/ru-RU.json +12 -5
- package/i18n/locales/zh-CN.json +11 -4
- package/lib/countries/__tests__/countries.test.ts +60 -0
- package/lib/countries/de.json +251 -0
- package/lib/countries/en.json +251 -0
- package/lib/countries/es.json +251 -0
- package/lib/countries/fr.json +251 -0
- package/lib/countries/index.ts +51 -0
- package/lib/countries/it.json +251 -0
- package/lib/countries/ja.json +251 -0
- package/lib/countries/ko.json +251 -0
- package/lib/countries/ru.json +251 -0
- package/lib/countries/zh.json +251 -0
- package/package.json +1 -1
- package/pages/channels/[channel].vue +4 -8
- package/pages/history.vue +2 -2
- package/pages/index.vue +10 -12
- package/pages/models/[slug]/channel/[channel].vue +4 -8
- package/pages/models/[slug]/index.vue +4 -8
- package/pages/models/[slug]/tag/[tag].vue +4 -8
- package/pages/tag/[tag].vue +4 -8
- package/plugins/countries.server.ts +13 -47
- package/runtime/index.ts +1 -0
- package/runtime/utils/converters/__tests__/convert-query-categories.test.ts +17 -0
- package/runtime/utils/converters/convert-query-categories.ts +25 -3
- package/runtime/utils/server/__tests__/get-path-locale.test.ts +27 -0
- package/runtime/utils/server/get-path-locale.ts +10 -0
- package/utils/__tests__/iso-code-to-country-name.test.ts +30 -0
- package/utils/iso-code-to-country-name.ts +22 -0
package/README.md
CHANGED
|
@@ -64,6 +64,9 @@ npm run lint:css:fix # Stylelint --fix
|
|
|
64
64
|
npm run check:themes # сверяет наборы имён токенов во всех темах
|
|
65
65
|
npm run check:styles # компилирует стили каждого SFC каждой темой (~1.5s)
|
|
66
66
|
npm run check:breakpoints # сверяет JS-зеркало брейкпоинтов с $brkpnts в SCSS
|
|
67
|
+
npm run i18n:check # отчёт по общим переводам (не гейт)
|
|
68
|
+
npm run i18n:sheet:export # общие локали → i18n/i18n.csv для переводчика
|
|
69
|
+
npm run i18n:sheet:import # i18n/i18n.csv → общие локали
|
|
67
70
|
npm run test # vitest однократно
|
|
68
71
|
npm run patch # npm version patch — бамп версии перед публикацией
|
|
69
72
|
```
|
|
@@ -90,6 +93,7 @@ runtime/ # runtime-значения и хелперы (barrel index.ts)
|
|
|
90
93
|
utils/ # чистые хелперы, cleaners/, converters/
|
|
91
94
|
types/ # .d.ts типы (barrel index.d.ts — с расширением .d.ts в реэкспортах)
|
|
92
95
|
lib/ # продуктовые статические данные (barrel index.ts): scheme/*, *-items, *-scheme
|
|
96
|
+
i18n/locales/ # общие переводы, по файлу на локаль (см. «Переводы»)
|
|
93
97
|
config/ # build-time конфиг для приложения (barrel index.ts): themes, css-breakpoints
|
|
94
98
|
middleware/ # глобальные: auth.global, normalize.global, age.global
|
|
95
99
|
plugins/ # SSR/клиент: country/countries/mobile (SSR), services.client, sentry.client, adv
|
|
@@ -156,6 +160,78 @@ assets/scss/themes/dark/_theme.scss
|
|
|
156
160
|
|
|
157
161
|
---
|
|
158
162
|
|
|
163
|
+
## Переводы
|
|
164
|
+
|
|
165
|
+
Ключи разделены на два набора, как темы: общая часть здесь, пер-сайтовая в приложении.
|
|
166
|
+
|
|
167
|
+
| | |
|
|
168
|
+
|---|---|
|
|
169
|
+
| Слой, `i18n/locales/*.json` | обвязка интерфейса, одинаковая на всех сайтах: навигация, сортировки и фильтры, кнопки-глаголы, пагинация, плеер, авторизация, снекбары, формы репорта и контактов, ошибки, плюрали, длительности, единицы, атрибуты модели |
|
|
170
|
+
| Приложение, `i18n/locales/*.json` | только пер-сайтовое: SEO (`meta.*`, `h1_*`, `*_footer_text`, карты `*_footer_links`), нишевая лексика и брендовые формулировки, плюс точечные оверрайды общих ключей |
|
|
171
|
+
|
|
172
|
+
Граница проходит по **значению, а не по имени ключа**: `all_categories` («Alphabetical A-Z») — общий,
|
|
173
|
+
`main_categories` («All Gay Porn Categories (A-Z)») — сайтовый, хотя имена похожи.
|
|
174
|
+
|
|
175
|
+
### Как склеивается
|
|
176
|
+
|
|
177
|
+
`@nuxtjs/i18n` мержит локали слоёв сам, вручную ничего подключать не надо. Файл слоя попадает в `files`
|
|
178
|
+
локали раньше проектного, значит грузится первым, а проектный ложится поверх. Мерж глубокий, поэтому
|
|
179
|
+
приложение может перекрыть **один ключ внутри общей группы**, не переобъявляя всю группу.
|
|
180
|
+
|
|
181
|
+
Направление одно: приложение всегда сильнее. Слой не может «дожать» значение — если сайт держит свой ключ,
|
|
182
|
+
победит он.
|
|
183
|
+
|
|
184
|
+
Коды локалей выводятся из имён файлов здесь (`zh-CN.json` → `zh`), поэтому набор файлов слоя —
|
|
185
|
+
канонический. Код, которого нет в `LOCALES` приложения, всё равно доедет до него лишней локалью, так что
|
|
186
|
+
наборы должны совпадать.
|
|
187
|
+
|
|
188
|
+
### Куда добавлять новый ключ
|
|
189
|
+
|
|
190
|
+
- Строка видна на всех сайтах → сюда, в **начало** `i18n/locales/en-US.json` («выше — новее»), дальше
|
|
191
|
+
обычный цикл: коммит → `npm run patch` → пуш → мерж и публикация → в приложении `npm run spec`.
|
|
192
|
+
- Строка про нишу, бренд или SEO → в приложение, слой не трогаем.
|
|
193
|
+
|
|
194
|
+
⚠️ **Не копировать общий ключ в приложение «чтобы был».** Дубль с тем же значением ничего не меняет, но
|
|
195
|
+
замораживает перевод: правки в слое до этого сайта уже никогда не доедут. `i18n:check` в приложении зовёт
|
|
196
|
+
такое мёртвым оверрайдом и показывает списком.
|
|
197
|
+
|
|
198
|
+
### CSV-мост (работа с переводчиком)
|
|
199
|
+
|
|
200
|
+
Переводы никогда не правятся руками по локалям — только через CSV. Скрипты живут здесь
|
|
201
|
+
(`scripts/i18n-sheet.mjs`, `scripts/i18n-check.mjs`), берут пути от cwd и публикуются в пакете, поэтому
|
|
202
|
+
один и тот же код обслуживает оба набора: здесь `npm run i18n:*` работает с общими ключами, в приложении
|
|
203
|
+
те же команды зовут копию из `node_modules/itube-specs/scripts/` и работают с его собственными. У каждого
|
|
204
|
+
набора свой `i18n/i18n.csv` (в git не хранится, пересобирается экспортом).
|
|
205
|
+
|
|
206
|
+
1. `npm run i18n:sheet:export` — собирает CSV: колонка `key`, дальше по колонке на локаль, `en` первой.
|
|
207
|
+
Новые ключи (заполнен только `en`) поднимаются наверх файла — переводчику сразу видно TODO.
|
|
208
|
+
2. Переводчик правит в Excel / Numbers / Google Sheets и сохраняет обратно **в CSV** (`.numbers`/`.xlsx`
|
|
209
|
+
не читаются). Разделитель определяется сам — `,`, `;` или таб, редакторы пишут по-разному.
|
|
210
|
+
3. `npm run i18n:sheet:import` — раскладывает значения назад. Каждый файл пересобирается в своём порядке
|
|
211
|
+
ключей (минимальный diff), непереведённый ключ остаётся **отсутствующим**, а не подменяется английским,
|
|
212
|
+
чтобы отчёт продолжал его показывать.
|
|
213
|
+
4. `npm run i18n:check` — отчёт, не гейт: рассинхрон с эталоном `en-US`, мёртвые оверрайды, а в приложении
|
|
214
|
+
ещё и SEO keyword-ссылки.
|
|
215
|
+
|
|
216
|
+
Удаление ключей едет от `en-US`: импорт выкидывает лист, которого больше нет в эталоне, так что устаревший
|
|
217
|
+
CSV не воскресит удалённый ключ.
|
|
218
|
+
|
|
219
|
+
Важное следствие двух наборов: непереведённый **общий** ключ чинится в CSV слоя — в CSV приложения он не
|
|
220
|
+
появится вовсе. Отчёт приложения такие ключи показывает отдельным блоком «не переведено в слое» и не
|
|
221
|
+
считает их своим рассинхроном.
|
|
222
|
+
|
|
223
|
+
### Что мимо CSV
|
|
224
|
+
|
|
225
|
+
`*_footer_links` в приложении — это карты «keyword → URL категории», а не строки: сам ключ локализован
|
|
226
|
+
(en: `twinks`, ru: `твинки`), наборы по локалям разные by design. Они исключены из CSV и из сверки ключей,
|
|
227
|
+
курирует их разработчик/SEO прямо в JSON. Проверяется другое: каждый keyword должен встречаться в парном
|
|
228
|
+
`*_footer_text`, иначе ссылка не отрендерится — это и гоняет `i18n:check` в приложении.
|
|
229
|
+
|
|
230
|
+
Правила плюрализации (`i18n/plurals.ts`) и `numberFormats` остаются в приложении, хотя сами строки
|
|
231
|
+
`plural.*` общие: правила задаются на уровне локали, а не ключа.
|
|
232
|
+
|
|
233
|
+
---
|
|
234
|
+
|
|
159
235
|
## Импорты
|
|
160
236
|
|
|
161
237
|
Внутри слоя — **относительные пути** (`../../runtime`, `../types`, `../../services/api/...`), никогда не
|
|
@@ -34,6 +34,21 @@ body {
|
|
|
34
34
|
-moz-osx-font-smoothing: grayscale;
|
|
35
35
|
}
|
|
36
36
|
|
|
37
|
+
// В корейском слова разделены пробелами, но браузер по умолчанию всё равно рвёт строку
|
|
38
|
+
// посреди слова — keep-all запрещает разрыв между слогами, break-word остаётся страховкой
|
|
39
|
+
// от переполнения на длинных ссылках. Китайский и японский трогать не надо: там перенос
|
|
40
|
+
// между любыми двумя знаками — норма языка (clreq, JLReq), а не дефект.
|
|
41
|
+
html[lang^='ko'] body {
|
|
42
|
+
word-break: keep-all;
|
|
43
|
+
overflow-wrap: break-word;
|
|
44
|
+
}
|
|
45
|
+
|
|
46
|
+
// Японский умеет лучше: Chromium разбивает строку по границам фраз собственным словарём.
|
|
47
|
+
// Где значение не поддержано, оно игнорируется и остаётся штатный японский перенос.
|
|
48
|
+
html[lang^='ja'] body {
|
|
49
|
+
word-break: auto-phrase;
|
|
50
|
+
}
|
|
51
|
+
|
|
37
52
|
main {
|
|
38
53
|
background-color: $main-bg;
|
|
39
54
|
}
|
|
@@ -21,14 +21,15 @@
|
|
|
21
21
|
<div class="card-category__wrapper-header">
|
|
22
22
|
<span class="card-category__title">
|
|
23
23
|
{{ card.title }}
|
|
24
|
+
<!-- Флаг декоративный: страна уже названа в заголовке рядом -->
|
|
24
25
|
<img
|
|
25
26
|
v-if="card.icon"
|
|
26
27
|
:src="`/icons/flags/${card.icon}.svg`"
|
|
27
|
-
:alt="card.icon"
|
|
28
28
|
class="card-category__icon"
|
|
29
29
|
width="17"
|
|
30
30
|
height="17"
|
|
31
31
|
loading="lazy"
|
|
32
|
+
alt=""
|
|
32
33
|
>
|
|
33
34
|
</span>
|
|
34
35
|
<span
|
|
@@ -13,10 +13,12 @@
|
|
|
13
13
|
<div class="card-model-mini__wrapper">
|
|
14
14
|
<p class="card-model-mini__name">
|
|
15
15
|
{{ card.title }}
|
|
16
|
+
<!-- Название страны текстом рядом не продублировано, поэтому оно в `alt`.
|
|
17
|
+
Форма как в CardModel: `name` — код для файла флага, `title` — читаемое имя. -->
|
|
16
18
|
<img
|
|
17
|
-
v-if="card.country"
|
|
18
|
-
:alt="card.country"
|
|
19
|
-
:src="`/icons/flags/${card.country}.svg`"
|
|
19
|
+
v-if="card.country?.name"
|
|
20
|
+
:alt="card.country.title"
|
|
21
|
+
:src="`/icons/flags/${card.country.name}.svg`"
|
|
20
22
|
class="card-model-mini__flag"
|
|
21
23
|
width="16"
|
|
22
24
|
height="12"
|
|
@@ -31,7 +33,7 @@
|
|
|
31
33
|
name="video-camera"
|
|
32
34
|
size="14"
|
|
33
35
|
/>
|
|
34
|
-
{{ card.videosCount }}
|
|
36
|
+
{{ $n(card.videosCount, 'compact') }}
|
|
35
37
|
</span>
|
|
36
38
|
<span
|
|
37
39
|
v-if="card.age"
|
|
@@ -4,6 +4,7 @@
|
|
|
4
4
|
<button
|
|
5
5
|
v-if="showFavorites && favorite"
|
|
6
6
|
:aria-label="t('video_actions.remove_aria', { title: card.title, name: t('favorites') })"
|
|
7
|
+
:title="t('video_actions.remove_favorites')"
|
|
7
8
|
class="card-video-actions__btn --active"
|
|
8
9
|
type="button"
|
|
9
10
|
aria-pressed="true"
|
|
@@ -19,6 +20,7 @@
|
|
|
19
20
|
:aria-label="later
|
|
20
21
|
? t('video_actions.remove_aria', { title: card.title, name: t('video_submenu.watch_later') })
|
|
21
22
|
: t('video_actions.add_aria', { title: card.title, name: t('video_submenu.watch_later') })"
|
|
23
|
+
:title="later ? t('video_actions.remove_watch_later') : t('video_actions.add_watch_later')"
|
|
22
24
|
:aria-pressed="later"
|
|
23
25
|
:class="['card-video-actions__btn', '--watch-later', { '--active': later }]"
|
|
24
26
|
type="button"
|
|
@@ -9,14 +9,15 @@
|
|
|
9
9
|
class="category-list__link"
|
|
10
10
|
>
|
|
11
11
|
{{ category.title }}
|
|
12
|
+
<!-- Флаг декоративный: страна уже названа в тексте ссылки -->
|
|
12
13
|
<img
|
|
13
14
|
v-if="category.icon"
|
|
14
15
|
:src="`/icons/flags/${category.icon}.svg`"
|
|
15
|
-
:alt="category.icon"
|
|
16
16
|
class="category-list__link-image"
|
|
17
17
|
width="17"
|
|
18
18
|
height="17"
|
|
19
19
|
loading="lazy"
|
|
20
|
+
alt=""
|
|
20
21
|
>
|
|
21
22
|
</NuxtLink>
|
|
22
23
|
<NuxtLink
|
|
@@ -3,7 +3,7 @@
|
|
|
3
3
|
<UiBtn
|
|
4
4
|
:size="size"
|
|
5
5
|
:theme="theme"
|
|
6
|
-
:title="
|
|
6
|
+
:title="label"
|
|
7
7
|
class="filter-btn"
|
|
8
8
|
aria-label="Filter"
|
|
9
9
|
@click="emit('update:modelValue', true)"
|
|
@@ -12,11 +12,10 @@
|
|
|
12
12
|
name="adjustments-horizontal"
|
|
13
13
|
size="16"
|
|
14
14
|
/>
|
|
15
|
-
<span class="_from-sm">{{
|
|
16
|
-
</span>
|
|
15
|
+
<span class="_from-sm">{{ label }}</span>
|
|
17
16
|
<span
|
|
18
17
|
v-if="count > 0"
|
|
19
|
-
class="filter-btn__count"
|
|
18
|
+
class="filter-btn__count _to-sm"
|
|
20
19
|
>{{ mobileCount }}</span>
|
|
21
20
|
</UiBtn>
|
|
22
21
|
</template>
|
|
@@ -38,6 +37,8 @@ const emit = defineEmits<{
|
|
|
38
37
|
const { t } = useI18n();
|
|
39
38
|
|
|
40
39
|
const mobileCount = computed(() => props.count > 9 ? '9+' : props.count);
|
|
40
|
+
|
|
41
|
+
const label = computed(() => props.count > 0 ? t('plural.filter', props.count) : t('filter'));
|
|
41
42
|
</script>
|
|
42
43
|
|
|
43
44
|
<style lang="scss">
|
|
@@ -83,7 +83,7 @@
|
|
|
83
83
|
class="filter-popup__summary-arrow"
|
|
84
84
|
/>
|
|
85
85
|
<span class="filter-popup__summary-title">{{ group.title }}</span>
|
|
86
|
-
<span class="filter-popup__summary-count">{{ t('
|
|
86
|
+
<span class="filter-popup__summary-count">{{ t('plural.filter', group.count ?? group.fields.length) }}</span>
|
|
87
87
|
<span
|
|
88
88
|
v-if="group.description"
|
|
89
89
|
class="filter-popup__summary-description"
|
package/components/info/grid.vue
CHANGED
|
@@ -41,13 +41,14 @@
|
|
|
41
41
|
]"
|
|
42
42
|
class="info-grid__item-value"
|
|
43
43
|
>
|
|
44
|
+
<!-- Флаг декоративный: страна названа в `value.title` ниже -->
|
|
44
45
|
<img
|
|
45
46
|
v-if="item.name === 'nationality_iso_codes' && isoCodes"
|
|
46
47
|
:src="`/icons/flags/${isoCodes[vIndex]}.svg`"
|
|
47
|
-
:alt="isoCodes[vIndex]"
|
|
48
48
|
class="info-grid__item-flag"
|
|
49
49
|
width="20"
|
|
50
50
|
height="14"
|
|
51
|
+
alt=""
|
|
51
52
|
>
|
|
52
53
|
<UiIcon
|
|
53
54
|
v-else-if="item.icon"
|
package/components/info/main.vue
CHANGED
|
@@ -29,7 +29,7 @@
|
|
|
29
29
|
<UiCount
|
|
30
30
|
class="info-main__count"
|
|
31
31
|
icon="video-camera"
|
|
32
|
-
>{{ videosCount || card.videosCount }}</UiCount>
|
|
32
|
+
>{{ $n(videosCount || card.videosCount, 'compact') }}</UiCount>
|
|
33
33
|
<UiBtn
|
|
34
34
|
theme="muted"
|
|
35
35
|
size="s"
|
|
@@ -77,11 +77,13 @@
|
|
|
77
77
|
size="14"
|
|
78
78
|
/>
|
|
79
79
|
<span class="info-main__born-text">{{ card.birthSummary }}</span>
|
|
80
|
+
<!-- Национальность нигде больше не подписана — флаг единственный её носитель,
|
|
81
|
+
поэтому в `alt` идёт название страны, а не ISO-код. -->
|
|
80
82
|
<img
|
|
81
83
|
v-for="(item, index) in card.nationalityIsoCodes"
|
|
82
84
|
:key="`nationalityIsoCodes-${index}`"
|
|
83
85
|
:src="`/icons/flags/${item}.svg`"
|
|
84
|
-
:alt="item"
|
|
86
|
+
:alt="isoCodeToCountryName(item, locale)"
|
|
85
87
|
class="info-main__born-flag"
|
|
86
88
|
width="20"
|
|
87
89
|
height="14"
|
|
@@ -157,8 +159,9 @@ import type {
|
|
|
157
159
|
} from '../../types';
|
|
158
160
|
import { AuthorizationApiService } from '../../services/api/authorization.service';
|
|
159
161
|
import { groupParametersByGroup } from '../../utils/info-main/group-parameters-by-group';
|
|
162
|
+
import { isoCodeToCountryName } from '../../utils/iso-code-to-country-name';
|
|
160
163
|
|
|
161
|
-
const { t } = useI18n();
|
|
164
|
+
const { t, locale } = useI18n();
|
|
162
165
|
|
|
163
166
|
const props = defineProps<{
|
|
164
167
|
card: ICardInfo
|
|
@@ -211,13 +214,26 @@ function getParameter(name: string) {
|
|
|
211
214
|
return props.card.parameters?.find(p => p.name === name)?.values?.[ 0 ]?.name;
|
|
212
215
|
}
|
|
213
216
|
|
|
217
|
+
/**
|
|
218
|
+
* `IModelCard.country` — пара «код файла флага + читаемое имя», как её отдаёт
|
|
219
|
+
* cleanModelCard. Раньше сюда уезжал целиком массив `nationalityIsoCodes`: у модели с
|
|
220
|
+
* двумя национальностями путь к флагу выходил `/icons/flags/us,gb.svg`, а у модели без
|
|
221
|
+
* национальностей пустой массив проходил `v-if` и давал битую картинку. Берём первый код.
|
|
222
|
+
*/
|
|
223
|
+
const shareCountry = computed(() => {
|
|
224
|
+
const iso = props.card.nationalityIsoCodes?.[0] || '';
|
|
225
|
+
if (!iso) return undefined;
|
|
226
|
+
|
|
227
|
+
return { name: iso, title: isoCodeToCountryName(iso, locale.value) };
|
|
228
|
+
});
|
|
229
|
+
|
|
214
230
|
const shareModelCard = computed<IModelCard>(() => ({
|
|
215
231
|
title: props.card.title,
|
|
216
232
|
videosCount: props.card.videosCount,
|
|
217
233
|
thumbUrl: props.card.img || '',
|
|
218
234
|
guid: props.card.guid || '',
|
|
219
235
|
parameters: [],
|
|
220
|
-
country:
|
|
236
|
+
country: shareCountry.value,
|
|
221
237
|
age: getParameter('age'),
|
|
222
238
|
}));
|
|
223
239
|
|
|
@@ -132,6 +132,7 @@
|
|
|
132
132
|
|
|
133
133
|
<script setup lang="ts">
|
|
134
134
|
import type { ISelectItem } from '../../types';
|
|
135
|
+
import { isoCodeToCountryName } from '../../utils/iso-code-to-country-name';
|
|
135
136
|
|
|
136
137
|
const { locale, locales } = useI18n();
|
|
137
138
|
const switchLocalePath = useSwitchLocalePath();
|
|
@@ -143,14 +144,21 @@ defineProps<{
|
|
|
143
144
|
}>();
|
|
144
145
|
|
|
145
146
|
const {
|
|
147
|
+
code: clientCountryCode,
|
|
146
148
|
icon: clientCountryIcon,
|
|
147
|
-
|
|
149
|
+
place: clientCountryPlace,
|
|
148
150
|
flagSrc: clientCountryFlag,
|
|
149
151
|
countriesItems,
|
|
150
152
|
setCountry,
|
|
151
153
|
loadCountries,
|
|
152
154
|
} = useClientCountry();
|
|
153
155
|
|
|
156
|
+
// Именительный падеж считает ICU, своей таблицы имён нет. Пустой `place` = служебный код
|
|
157
|
+
// гео-заголовка (`XX`, `T1`, `AP`), такую «страну» не подписываем.
|
|
158
|
+
const clientCountryName = computed(() => (
|
|
159
|
+
clientCountryPlace.value ? isoCodeToCountryName(clientCountryCode.value, locale.value) : ''
|
|
160
|
+
));
|
|
161
|
+
|
|
154
162
|
const isCountryOpen = ref(false);
|
|
155
163
|
const countryValue = ref('');
|
|
156
164
|
const filteredCountries = computed(() => {
|
|
@@ -159,7 +159,7 @@ const emit = defineEmits<{
|
|
|
159
159
|
(eventName: 'toggle-follow', item: ILibraryFeedItem): void,
|
|
160
160
|
}>();
|
|
161
161
|
|
|
162
|
-
const { t } = useI18n();
|
|
162
|
+
const { t, n } = useI18n();
|
|
163
163
|
|
|
164
164
|
const avatarError = ref(false);
|
|
165
165
|
|
|
@@ -173,7 +173,7 @@ const initials = computed(() => props.item.title
|
|
|
173
173
|
.join('')
|
|
174
174
|
.toUpperCase());
|
|
175
175
|
|
|
176
|
-
const videosText = computed(() => t('plural.videos', props.item.videosCount));
|
|
176
|
+
const videosText = computed(() => t('plural.videos', { n: n(props.item.videosCount, 'compact') }, props.item.videosCount));
|
|
177
177
|
|
|
178
178
|
const parametersText = computed(() => (props.item.parameters ?? []).filter(Boolean).join(' · '));
|
|
179
179
|
|
|
@@ -14,7 +14,7 @@
|
|
|
14
14
|
/>
|
|
15
15
|
<div class="player-follow__wrapper">
|
|
16
16
|
<p class="player-follow__title">{{ card.name }}</p>
|
|
17
|
-
<p class="player-follow__count">{{ $t('plural.videos',
|
|
17
|
+
<p class="player-follow__count">{{ $t('plural.videos', { n: $n(card.videosCount, 'compact') }, card.videosCount) }}</p>
|
|
18
18
|
</div>
|
|
19
19
|
<UiBtn
|
|
20
20
|
v-if="featureFlags.FeatureFollowEnabled"
|
|
@@ -16,7 +16,7 @@
|
|
|
16
16
|
<strong>{{ selectedPlaylist?.name }}</strong>
|
|
17
17
|
</i18n-t>
|
|
18
18
|
<p class="playlist-edit__confirm-note">
|
|
19
|
-
{{ $t('playlist.confirm_delete_note', { videos: $t('plural.videos', selectedPlaylist?.videosCount ?? 0) }) }}
|
|
19
|
+
{{ $t('playlist.confirm_delete_note', { videos: $t('plural.videos', { n: $n(selectedPlaylist?.videosCount ?? 0, 'compact') }, selectedPlaylist?.videosCount ?? 0) }) }}
|
|
20
20
|
</p>
|
|
21
21
|
</UiConfirm>
|
|
22
22
|
|
|
@@ -25,7 +25,7 @@
|
|
|
25
25
|
v-if="count || icon"
|
|
26
26
|
:icon="icon"
|
|
27
27
|
class="ui-section-title__count"
|
|
28
|
-
>{{
|
|
28
|
+
>{{ formattedCount }}</UiCount>
|
|
29
29
|
<!-- Доп. бейдж рядом с заголовком (например «editorial») -->
|
|
30
30
|
<slot/>
|
|
31
31
|
</div>
|
|
@@ -45,6 +45,12 @@ const props = withDefaults(defineProps<{
|
|
|
45
45
|
variant: 'page'
|
|
46
46
|
});
|
|
47
47
|
|
|
48
|
+
const { n } = useI18n();
|
|
49
|
+
|
|
50
|
+
const formattedCount = computed(() => (
|
|
51
|
+
typeof props.count === 'number' ? n(props.count, 'compact') : props.count
|
|
52
|
+
));
|
|
53
|
+
|
|
48
54
|
const titleClass = computed(() => [
|
|
49
55
|
'ui-section-title__title',
|
|
50
56
|
props.variant === 'section' ? '--section' : '_title'
|
|
@@ -13,7 +13,7 @@
|
|
|
13
13
|
|
|
14
14
|
<UiSectionTitle
|
|
15
15
|
v-else-if="isFilteredPage"
|
|
16
|
-
:count="data?.total
|
|
16
|
+
:count="data?.total"
|
|
17
17
|
:title="title"
|
|
18
18
|
class="view-model-videos__title _title"
|
|
19
19
|
icon="video-camera"
|
|
@@ -66,7 +66,7 @@
|
|
|
66
66
|
class="view-model-videos__other"
|
|
67
67
|
>
|
|
68
68
|
<UiSectionTitle
|
|
69
|
-
:count="props.dataOther
|
|
69
|
+
:count="props.dataOther?.total"
|
|
70
70
|
:title="otherItemsTitle"
|
|
71
71
|
class="view-model-videos__other-title"
|
|
72
72
|
title-tag="h2"
|
|
@@ -208,7 +208,7 @@ const sections = computed<ISwitcherItem[]>(() => [
|
|
|
208
208
|
name: 'webcams',
|
|
209
209
|
title: t('webcams.sections.webcams'),
|
|
210
210
|
icon: 'webcam',
|
|
211
|
-
subtitle: props.data?.total ? t('webcams.online_short', { count: n(props.data.total) }) : '',
|
|
211
|
+
subtitle: props.data?.total ? t('webcams.online_short', { count: n(props.data.total, 'compact') }) : '',
|
|
212
212
|
},
|
|
213
213
|
]);
|
|
214
214
|
|
|
@@ -9,7 +9,8 @@ vi.mock('../../services/api/dictionaries.service', () => ({
|
|
|
9
9
|
}));
|
|
10
10
|
|
|
11
11
|
const setCode = (code: string) => useState<string>('clientCountryCode', () => '').value = code;
|
|
12
|
-
|
|
12
|
+
/** Признак реальной страны: ставит SSR-плагин из lib/countries */
|
|
13
|
+
const setPlace = (place: string) => useState<string>('clientCountryPlace', () => '').value = place;
|
|
13
14
|
|
|
14
15
|
describe('useClientCountry', () => {
|
|
15
16
|
beforeEach(() => {
|
|
@@ -17,17 +18,17 @@ describe('useClientCountry', () => {
|
|
|
17
18
|
getCountries.mockReset();
|
|
18
19
|
});
|
|
19
20
|
|
|
20
|
-
it('без кода не отдаёт ни флаг, ни
|
|
21
|
-
const { icon,
|
|
21
|
+
it('без кода не отдаёт ни флаг, ни обстоятельство места', () => {
|
|
22
|
+
const { icon, place, flagSrc } = useClientCountry();
|
|
22
23
|
|
|
23
24
|
expect(icon.value).toBe('');
|
|
24
|
-
expect(
|
|
25
|
+
expect(place.value).toBe('');
|
|
25
26
|
expect(flagSrc.value).toBe('');
|
|
26
27
|
});
|
|
27
28
|
|
|
28
29
|
it('строит иконку и путь к флагу из кода', () => {
|
|
29
30
|
setCode('ad');
|
|
30
|
-
|
|
31
|
+
setPlace('в Андорре');
|
|
31
32
|
const { icon, flagSrc } = useClientCountry();
|
|
32
33
|
|
|
33
34
|
expect(icon.value).toBe('ad');
|
|
@@ -36,7 +37,7 @@ describe('useClientCountry', () => {
|
|
|
36
37
|
|
|
37
38
|
it('нормализует код: регистр и пробелы', () => {
|
|
38
39
|
setCode(' AD ');
|
|
39
|
-
|
|
40
|
+
setPlace('в Андорре');
|
|
40
41
|
const { icon, flagSrc } = useClientCountry();
|
|
41
42
|
|
|
42
43
|
expect(icon.value).toBe('ad');
|
|
@@ -45,7 +46,7 @@ describe('useClientCountry', () => {
|
|
|
45
46
|
|
|
46
47
|
it('учитывает страны, где код флага отличается от ISO-кода', () => {
|
|
47
48
|
setCode('cn');
|
|
48
|
-
|
|
49
|
+
setPlace('в Китае');
|
|
49
50
|
const { icon, flagSrc } = useClientCountry();
|
|
50
51
|
|
|
51
52
|
expect(icon.value).toBe('zh');
|
|
@@ -53,19 +54,28 @@ describe('useClientCountry', () => {
|
|
|
53
54
|
});
|
|
54
55
|
|
|
55
56
|
it('не показывает флаг для служебного кода, которого нет в словаре', () => {
|
|
56
|
-
// Cloudflare отдаёт XX для неизвестного IP и T1 для Tor — файла такого флага
|
|
57
|
+
// Cloudflare отдаёт XX для неизвестного IP и T1 для Tor — файла такого флага нет.
|
|
58
|
+
// Реальность страны определяет непустой `place`: по Intl.DisplayNames не проверить,
|
|
59
|
+
// она вернёт `XX` как `XX`, а `US` в en-локали — как `US`
|
|
57
60
|
setCode('xx');
|
|
58
|
-
const { icon,
|
|
61
|
+
const { icon, place, flagSrc } = useClientCountry();
|
|
59
62
|
|
|
60
63
|
expect(icon.value).toBe('xx');
|
|
61
|
-
expect(
|
|
64
|
+
expect(place.value).toBe('');
|
|
62
65
|
expect(flagSrc.value).toBe('');
|
|
63
66
|
});
|
|
64
67
|
|
|
65
|
-
it('
|
|
68
|
+
it('обстоятельство места берёт из состояния, поставленного SSR', () => {
|
|
69
|
+
setCode('cz');
|
|
70
|
+
setPlace('в Чехии');
|
|
71
|
+
const { place } = useClientCountry();
|
|
72
|
+
|
|
73
|
+
expect(place.value).toBe('в Чехии');
|
|
74
|
+
});
|
|
75
|
+
|
|
76
|
+
it('загруженный список стран флаг не ломает', async () => {
|
|
66
77
|
setCode('ad');
|
|
67
|
-
|
|
68
|
-
setResolvedName('USA');
|
|
78
|
+
setPlace('в Андорре');
|
|
69
79
|
getCountries.mockResolvedValue({
|
|
70
80
|
countries: [
|
|
71
81
|
{ title: 'Andorra', isoCodes: ['AD', 'AND'] },
|
|
@@ -73,30 +83,20 @@ describe('useClientCountry', () => {
|
|
|
73
83
|
],
|
|
74
84
|
});
|
|
75
85
|
|
|
76
|
-
const {
|
|
86
|
+
const { flagSrc, loadCountries } = useClientCountry();
|
|
77
87
|
await loadCountries();
|
|
78
88
|
|
|
79
|
-
expect(name.value).toBe('Andorra');
|
|
80
89
|
expect(flagSrc.value).toBe('/icons/flags/ad.svg');
|
|
81
90
|
});
|
|
82
91
|
|
|
83
|
-
it('
|
|
84
|
-
setCode('ad');
|
|
85
|
-
setResolvedName('Andorra');
|
|
86
|
-
const { name } = useClientCountry();
|
|
87
|
-
|
|
88
|
-
expect(name.value).toBe('Andorra');
|
|
89
|
-
});
|
|
90
|
-
|
|
91
|
-
it('выбор страны меняет и код, и имя', () => {
|
|
92
|
+
it('выбор страны меняет код и флаг', () => {
|
|
92
93
|
setCode('ad');
|
|
93
|
-
|
|
94
|
-
const { code,
|
|
94
|
+
setPlace('в Андорре');
|
|
95
|
+
const { code, flagSrc, setCountry } = useClientCountry();
|
|
95
96
|
|
|
96
97
|
setCountry({ title: 'USA', value: 'USA', icon: 'us', key: 'us' });
|
|
97
98
|
|
|
98
99
|
expect(code.value).toBe('us');
|
|
99
|
-
expect(name.value).toBe('USA');
|
|
100
100
|
expect(flagSrc.value).toBe('/icons/flags/us.svg');
|
|
101
101
|
});
|
|
102
102
|
|