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.
- package/README.md +76 -0
- package/assets/scss/base/_global.scss +15 -0
- package/components/card/channel-mini.vue +1 -1
- package/components/card/model-mini.vue +1 -1
- 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/filter/btn.vue +5 -4
- package/components/filter/popup.vue +1 -1
- package/components/info/main.vue +1 -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/use-route-query-categories.ts +25 -0
- package/i18n/locales/de-DE.json +480 -0
- package/i18n/locales/en-US.json +582 -0
- package/i18n/locales/es-ES.json +480 -0
- package/i18n/locales/fr-FR.json +480 -0
- package/i18n/locales/it-IT.json +480 -0
- package/i18n/locales/ja-JP.json +480 -0
- package/i18n/locales/ko-KR.json +480 -0
- package/i18n/locales/ru-RU.json +480 -0
- package/i18n/locales/zh-CN.json +480 -0
- package/package.json +1 -1
- package/pages/channels/[channel].vue +4 -8
- package/pages/history.vue +2 -2
- package/pages/index.vue +4 -8
- 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/runtime/utils/converters/__tests__/convert-query-categories.test.ts +17 -0
- package/runtime/utils/converters/convert-query-categories.ts +25 -3
- 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
|
}
|
|
@@ -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="
|
|
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/main.vue
CHANGED
|
@@ -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
|
|
|
@@ -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
|
+
};
|