itube-specs 0.0.941 → 0.0.946

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 (41) hide show
  1. package/README.md +76 -0
  2. package/assets/scss/base/_global.scss +15 -0
  3. package/components/card/channel-mini.vue +1 -1
  4. package/components/card/model-mini.vue +1 -1
  5. package/components/card/playlist-mini.vue +1 -1
  6. package/components/card/round.vue +1 -1
  7. package/components/card/video/actions.vue +2 -0
  8. package/components/filter/btn.vue +5 -4
  9. package/components/filter/popup.vue +1 -1
  10. package/components/info/main.vue +1 -1
  11. package/components/library/feed.vue +2 -2
  12. package/components/player/follow.vue +1 -1
  13. package/components/playlist/add.vue +1 -1
  14. package/components/playlist/edit.vue +1 -1
  15. package/components/search/item.vue +1 -1
  16. package/components/ui/section-title.vue +7 -1
  17. package/components/view/model-videos.vue +2 -2
  18. package/components/view/videos.vue +1 -1
  19. package/components/view/webcams.vue +1 -1
  20. package/components/webcam/counters.vue +1 -1
  21. package/composables/use-route-query-categories.ts +25 -0
  22. package/i18n/locales/de-DE.json +480 -0
  23. package/i18n/locales/en-US.json +582 -0
  24. package/i18n/locales/es-ES.json +480 -0
  25. package/i18n/locales/fr-FR.json +480 -0
  26. package/i18n/locales/it-IT.json +480 -0
  27. package/i18n/locales/ja-JP.json +480 -0
  28. package/i18n/locales/ko-KR.json +480 -0
  29. package/i18n/locales/ru-RU.json +480 -0
  30. package/i18n/locales/zh-CN.json +480 -0
  31. package/package.json +1 -1
  32. package/pages/channels/[channel].vue +4 -8
  33. package/pages/history.vue +2 -2
  34. package/pages/index.vue +4 -8
  35. package/pages/models/[slug]/channel/[channel].vue +4 -8
  36. package/pages/models/[slug]/index.vue +4 -8
  37. package/pages/models/[slug]/tag/[tag].vue +4 -8
  38. package/pages/tag/[tag].vue +4 -8
  39. package/runtime/utils/converters/__tests__/convert-query-categories.test.ts +17 -0
  40. package/runtime/utils/converters/convert-query-categories.ts +25 -3
  41. package/scripts/i18n-check.mjs +15 -2
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
  }
@@ -24,7 +24,7 @@
24
24
  name="video-camera"
25
25
  size="14"
26
26
  />
27
- {{ card.videosCount }}
27
+ {{ $n(card.videosCount, 'compact') }}
28
28
  </span>
29
29
  <span
30
30
  v-if="featureFlags.FeatureFollowEnabled"
@@ -31,7 +31,7 @@
31
31
  name="video-camera"
32
32
  size="14"
33
33
  />
34
- {{ card.videosCount }}
34
+ {{ $n(card.videosCount, 'compact') }}
35
35
  </span>
36
36
  <span
37
37
  v-if="card.age"
@@ -28,7 +28,7 @@
28
28
  name="video-camera"
29
29
  size="16"
30
30
  />
31
- {{ card.videosCount }}
31
+ {{ $n(card.videosCount, 'compact') }}
32
32
  </span>
33
33
  </div>
34
34
  </div>
@@ -34,7 +34,7 @@
34
34
  name="cam"
35
35
  size="12"
36
36
  />
37
- <span class="card-round__count-text">{{ card.videosCount }}</span>
37
+ <span class="card-round__count-text">{{ $n(card.videosCount, 'compact') }}</span>
38
38
  </p>
39
39
  </NuxtLink>
40
40
  </template>
@@ -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"
@@ -3,7 +3,7 @@
3
3
  <UiBtn
4
4
  :size="size"
5
5
  :theme="theme"
6
- :title="t('filter')"
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">{{ t('filter') }}
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('filter_popup.fields', { count: group.count ?? group.fields.length }) }}</span>
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"
@@ -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"
@@ -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', Number(card.videosCount)) }}</p>
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"
@@ -52,7 +52,7 @@
52
52
  name="video-camera"
53
53
  size="14"
54
54
  />
55
- {{ item.videosCount }}
55
+ {{ $n(item.videosCount, 'compact') }}
56
56
  </span>
57
57
  </div>
58
58
  <button
@@ -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
 
@@ -24,7 +24,7 @@
24
24
  <span
25
25
  v-if="item.videosCount"
26
26
  class="search-item__count"
27
- >{{ item.videosCount }}</span>
27
+ >{{ $n(item.videosCount, 'compact') }}</span>
28
28
  </NuxtLink>
29
29
  </template>
30
30
 
@@ -25,7 +25,7 @@
25
25
  v-if="count || icon"
26
26
  :icon="icon"
27
27
  class="ui-section-title__count"
28
- >{{ count }}</UiCount>
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 ? $n(data.total, 'compact') : 0"
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 ? $n(props.dataOther?.total, 'compact') : 0"
69
+ :count="props.dataOther?.total"
70
70
  :title="otherItemsTitle"
71
71
  class="view-model-videos__other-title"
72
72
  title-tag="h2"
@@ -4,7 +4,7 @@
4
4
  <header class="view-videos__header">
5
5
  <UiSectionTitle
6
6
  v-if="title"
7
- :count="data?.total && $n(data?.total, 'compact')"
7
+ :count="data?.total"
8
8
  :title="title"
9
9
  :img="flagImg"
10
10
  class="view-videos__title"
@@ -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
 
@@ -16,7 +16,7 @@
16
16
  class="webcam-counters__item"
17
17
  >
18
18
  <span class="webcam-counters__pulse"/>
19
- {{ t('webcams.online_now', { count: $n(total) }) }}
19
+ {{ t('webcams.online_now', { count: $n(total, 'compact') }) }}
20
20
  </span>
21
21
 
22
22
  <span
@@ -0,0 +1,25 @@
1
+ import { CategoriesApiService } from '../services/api/categories.service';
2
+ import { convertQueryCategories, convertString } from '../runtime';
3
+
4
+ /**
5
+ * Перечисление выбранных категорий для заголовка страницы — на языке текущей локали.
6
+ *
7
+ * В query лежит английский слаг категории, переводы есть только в группах с бека, поэтому
8
+ * при активном фильтре список групп догружается в SSR (тот же ленивый стейт, что у чипсов
9
+ * фильтра, `load()` идемпотентен — лишнего запроса не будет). Пустая строка, если фильтр не стоит.
10
+ */
11
+ export const useRouteQueryCategories = async () => {
12
+ const route = useRoute();
13
+ const { data: groups, load } = useFetchGroupsCategoriesLazy(CategoriesApiService.getGroupsCategories);
14
+
15
+ watch(() => route.query[ 'categories' ], value => {
16
+ if (value) load();
17
+ });
18
+
19
+ if (route.query[ 'categories' ]) await load();
20
+
21
+ return computed(() => {
22
+ if (!route.query[ 'categories' ]) return '';
23
+ return convertString().toCapitalize(convertQueryCategories(route, groups.value));
24
+ });
25
+ };