itube-specs 0.0.944 → 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 +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/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/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
|
+
};
|
package/i18n/locales/de-DE.json
CHANGED
|
@@ -1,4 +1,5 @@
|
|
|
1
1
|
{
|
|
2
|
+
"top_models": "Top-Models",
|
|
2
3
|
"additional_link": {
|
|
3
4
|
"2257": "2257",
|
|
4
5
|
"report": "Fehler melden",
|
|
@@ -107,7 +108,7 @@
|
|
|
107
108
|
"get_full_video": "Vollständiges Video erhalten",
|
|
108
109
|
"filter": "Filter",
|
|
109
110
|
"filters": "Filter",
|
|
110
|
-
"show_all_models": "Alle
|
|
111
|
+
"show_all_models": "Alle anzeigen",
|
|
111
112
|
"all_categories": "Alphabetisch A-Z",
|
|
112
113
|
"related_searches": "Ähnliche Suchen",
|
|
113
114
|
"related_models": "Ähnliche Models",
|
|
@@ -380,11 +381,16 @@
|
|
|
380
381
|
"item": "{n} Element | {n} Elemente",
|
|
381
382
|
"filter": "{n} Filter | {n} Filter"
|
|
382
383
|
},
|
|
384
|
+
"video_actions": {
|
|
385
|
+
"add_favorites": "Zu Favoriten hinzufügen",
|
|
386
|
+
"remove_favorites": "Aus Favoriten entfernen",
|
|
387
|
+
"add_watch_later": "Zu „Später ansehen“ hinzufügen",
|
|
388
|
+
"remove_watch_later": "Aus „Später ansehen“ entfernen"
|
|
389
|
+
},
|
|
383
390
|
"filter_popup": {
|
|
384
391
|
"active_count": "{count} aktiv",
|
|
385
392
|
"active_title": "Aktive Filter",
|
|
386
|
-
"quick_title": "Schnellfilter
|
|
387
|
-
"fields": "{count} Felder",
|
|
393
|
+
"quick_title": "Schnellfilter",
|
|
388
394
|
"performer": {
|
|
389
395
|
"title": "Darsteller",
|
|
390
396
|
"description": "Wer zu sehen ist"
|
|
@@ -485,6 +491,7 @@
|
|
|
485
491
|
"playlists": "Playlists",
|
|
486
492
|
"network": "Netzwerk",
|
|
487
493
|
"profile": "Profil",
|
|
494
|
+
"library": "Mediathek",
|
|
488
495
|
"settings": "Einstellungen",
|
|
489
496
|
"my_playlists": "Meine Playlists",
|
|
490
497
|
"back_to_main_page": "Zur Hauptseite zurück",
|
package/i18n/locales/en-US.json
CHANGED
|
@@ -1,4 +1,5 @@
|
|
|
1
1
|
{
|
|
2
|
+
"top_models": "Top models",
|
|
2
3
|
"playlists_empty": {
|
|
3
4
|
"title": "No playlists yet",
|
|
4
5
|
"tap": "Tap",
|
|
@@ -163,7 +164,7 @@
|
|
|
163
164
|
"get_full_video": "Get full video",
|
|
164
165
|
"filter": "Filter",
|
|
165
166
|
"filters": "Filters",
|
|
166
|
-
"show_all_models": "Show all
|
|
167
|
+
"show_all_models": "Show all",
|
|
167
168
|
"all_categories": "Alphabetical A-Z",
|
|
168
169
|
"related_searches": "Related searches",
|
|
169
170
|
"related_models": "Related models",
|
|
@@ -196,7 +197,7 @@
|
|
|
196
197
|
"models_by_letter": "Models by letter",
|
|
197
198
|
"trending": "Trending",
|
|
198
199
|
"trending_models": "Trending Models",
|
|
199
|
-
"popular_searches": "Popular
|
|
200
|
+
"popular_searches": "Popular searches",
|
|
200
201
|
"category": "Category",
|
|
201
202
|
"playlists": "Playlists",
|
|
202
203
|
"channels": "Channels",
|
|
@@ -489,8 +490,7 @@
|
|
|
489
490
|
"filter_popup": {
|
|
490
491
|
"active_count": "{count} active",
|
|
491
492
|
"active_title": "Active filters",
|
|
492
|
-
"quick_title": "Quick filters
|
|
493
|
-
"fields": "{count} fields",
|
|
493
|
+
"quick_title": "Quick filters",
|
|
494
494
|
"performer": {
|
|
495
495
|
"title": "Performer",
|
|
496
496
|
"description": "Who's on screen"
|
package/i18n/locales/es-ES.json
CHANGED
|
@@ -1,4 +1,5 @@
|
|
|
1
1
|
{
|
|
2
|
+
"top_models": "Top modelos",
|
|
2
3
|
"additional_link": {
|
|
3
4
|
"2257": "2257",
|
|
4
5
|
"report": "Reportar error",
|
|
@@ -107,7 +108,7 @@
|
|
|
107
108
|
"get_full_video": "Obtener video completo",
|
|
108
109
|
"filter": "Filtro",
|
|
109
110
|
"filters": "Filtros",
|
|
110
|
-
"show_all_models": "
|
|
111
|
+
"show_all_models": "Ver todos",
|
|
111
112
|
"all_categories": "Alfabético A-Z",
|
|
112
113
|
"related_searches": "Búsquedas relacionadas",
|
|
113
114
|
"related_models": "Modelos relacionados",
|
|
@@ -380,13 +381,18 @@
|
|
|
380
381
|
"item": "{n} elemento | {n} elementos",
|
|
381
382
|
"filter": "{n} filtro | {n} filtros"
|
|
382
383
|
},
|
|
384
|
+
"video_actions": {
|
|
385
|
+
"add_favorites": "Añadir a favoritos",
|
|
386
|
+
"remove_favorites": "Quitar de favoritos",
|
|
387
|
+
"add_watch_later": "Añadir a «Ver más tarde»",
|
|
388
|
+
"remove_watch_later": "Quitar de «Ver más tarde»"
|
|
389
|
+
},
|
|
383
390
|
"filter_popup": {
|
|
384
391
|
"active_count": "{count} activos",
|
|
385
392
|
"active_title": "Filtros activos",
|
|
386
|
-
"quick_title": "Filtros rápidos
|
|
387
|
-
"fields": "{count} campos",
|
|
393
|
+
"quick_title": "Filtros rápidos",
|
|
388
394
|
"performer": {
|
|
389
|
-
"title": "
|
|
395
|
+
"title": "Actor",
|
|
390
396
|
"description": "Quién sale en pantalla"
|
|
391
397
|
},
|
|
392
398
|
"scene": {
|
|
@@ -485,6 +491,7 @@
|
|
|
485
491
|
"profile": "Perfil",
|
|
486
492
|
"settings": "Configuraciones",
|
|
487
493
|
"my_playlists": "Mis listas de reproducción",
|
|
494
|
+
"library": "Biblioteca",
|
|
488
495
|
"back_to_main_page": "Volver a la página principal",
|
|
489
496
|
"history": "Historial",
|
|
490
497
|
"live_sex": "Sexo en vivo",
|
package/i18n/locales/fr-FR.json
CHANGED
|
@@ -1,4 +1,5 @@
|
|
|
1
1
|
{
|
|
2
|
+
"top_models": "Top modèles",
|
|
2
3
|
"additional_link": {
|
|
3
4
|
"2257": "2257",
|
|
4
5
|
"report": "Signaler un bug",
|
|
@@ -107,7 +108,7 @@
|
|
|
107
108
|
"get_full_video": "Obtenir la vidéo complète",
|
|
108
109
|
"filter": "Filtre",
|
|
109
110
|
"filters": "Filtres",
|
|
110
|
-
"show_all_models": "
|
|
111
|
+
"show_all_models": "Voir tout",
|
|
111
112
|
"all_categories": "Alphabétique A-Z",
|
|
112
113
|
"related_searches": "Recherches associées",
|
|
113
114
|
"related_models": "Modèles associés",
|
|
@@ -380,13 +381,18 @@
|
|
|
380
381
|
"item": "{n} élément | {n} éléments",
|
|
381
382
|
"filter": "{n} filtre | {n} filtres"
|
|
382
383
|
},
|
|
384
|
+
"video_actions": {
|
|
385
|
+
"add_favorites": "Ajouter aux favoris",
|
|
386
|
+
"remove_favorites": "Retirer des favoris",
|
|
387
|
+
"add_watch_later": "Ajouter à « À regarder plus tard »",
|
|
388
|
+
"remove_watch_later": "Retirer de « À regarder plus tard »"
|
|
389
|
+
},
|
|
383
390
|
"filter_popup": {
|
|
384
391
|
"active_count": "{count} actifs",
|
|
385
392
|
"active_title": "Filtres actifs",
|
|
386
|
-
"quick_title": "Filtres rapides
|
|
387
|
-
"fields": "{count} champs",
|
|
393
|
+
"quick_title": "Filtres rapides",
|
|
388
394
|
"performer": {
|
|
389
|
-
"title": "Acteur
|
|
395
|
+
"title": "Acteur",
|
|
390
396
|
"description": "Qui est à l'écran"
|
|
391
397
|
},
|
|
392
398
|
"scene": {
|
|
@@ -485,6 +491,7 @@
|
|
|
485
491
|
"profile": "Profil",
|
|
486
492
|
"settings": "Paramètres",
|
|
487
493
|
"my_playlists": "Mes listes de lecture",
|
|
494
|
+
"library": "Bibliothèque",
|
|
488
495
|
"back_to_main_page": "Retour à la page principale",
|
|
489
496
|
"history": "Historique",
|
|
490
497
|
"live_sex": "Sexe en direct",
|
package/i18n/locales/it-IT.json
CHANGED
|
@@ -1,4 +1,5 @@
|
|
|
1
1
|
{
|
|
2
|
+
"top_models": "Top modelli",
|
|
2
3
|
"additional_link": {
|
|
3
4
|
"2257": "2257",
|
|
4
5
|
"report": "Segnala bug",
|
|
@@ -107,7 +108,7 @@
|
|
|
107
108
|
"get_full_video": "Ottieni video completo",
|
|
108
109
|
"filter": "Filtro",
|
|
109
110
|
"filters": "Filtri",
|
|
110
|
-
"show_all_models": "
|
|
111
|
+
"show_all_models": "Vedi tutti",
|
|
111
112
|
"all_categories": "Alfabetico A-Z",
|
|
112
113
|
"related_searches": "Ricerche correlate",
|
|
113
114
|
"related_models": "Modelli correlati",
|
|
@@ -380,13 +381,18 @@
|
|
|
380
381
|
"item": "{n} elemento | {n} elementi",
|
|
381
382
|
"filter": "{n} filtro | {n} filtri"
|
|
382
383
|
},
|
|
384
|
+
"video_actions": {
|
|
385
|
+
"add_favorites": "Aggiungi ai preferiti",
|
|
386
|
+
"remove_favorites": "Rimuovi dai preferiti",
|
|
387
|
+
"add_watch_later": "Aggiungi a «Guarda più tardi»",
|
|
388
|
+
"remove_watch_later": "Rimuovi da «Guarda più tardi»"
|
|
389
|
+
},
|
|
383
390
|
"filter_popup": {
|
|
384
391
|
"active_count": "{count} attivi",
|
|
385
392
|
"active_title": "Filtri attivi",
|
|
386
|
-
"quick_title": "Filtri rapidi
|
|
387
|
-
"fields": "{count} campi",
|
|
393
|
+
"quick_title": "Filtri rapidi",
|
|
388
394
|
"performer": {
|
|
389
|
-
"title": "Attore
|
|
395
|
+
"title": "Attore",
|
|
390
396
|
"description": "Chi c'è sullo schermo"
|
|
391
397
|
},
|
|
392
398
|
"scene": {
|
|
@@ -487,6 +493,7 @@
|
|
|
487
493
|
"my_playlists": "Le mie playlist",
|
|
488
494
|
"back_to_main_page": "Torna alla pagina principale",
|
|
489
495
|
"history": "Cronologia",
|
|
496
|
+
"library": "Raccolta",
|
|
490
497
|
"live_sex": "Sesso dal vivo",
|
|
491
498
|
"watch_later": "Guarda dopo"
|
|
492
499
|
},
|
package/i18n/locales/ja-JP.json
CHANGED
|
@@ -1,4 +1,5 @@
|
|
|
1
1
|
{
|
|
2
|
+
"top_models": "人気モデル",
|
|
2
3
|
"additional_link": {
|
|
3
4
|
"2257": "2257",
|
|
4
5
|
"report": "バグを報告",
|
|
@@ -107,7 +108,7 @@
|
|
|
107
108
|
"get_full_video": "フルビデオを取得",
|
|
108
109
|
"filter": "フィルター",
|
|
109
110
|
"filters": "フィルター",
|
|
110
|
-
"show_all_models": "
|
|
111
|
+
"show_all_models": "すべて見る",
|
|
111
112
|
"all_categories": "アルファベット A-Z",
|
|
112
113
|
"related_searches": "関連検索",
|
|
113
114
|
"related_models": "関連モデル",
|
|
@@ -140,7 +141,7 @@
|
|
|
140
141
|
"models_by_letter": "アルファベット別のモデル",
|
|
141
142
|
"trending": "トレンド",
|
|
142
143
|
"trending_models": "トレンドモデル",
|
|
143
|
-
"popular_searches": "
|
|
144
|
+
"popular_searches": "人気の検索",
|
|
144
145
|
"category": "カテゴリ",
|
|
145
146
|
"playlists": "プレイリスト",
|
|
146
147
|
"channels": "チャンネル",
|
|
@@ -378,13 +379,18 @@
|
|
|
378
379
|
"views": "{n} 視聴 | {n} 視聴回数",
|
|
379
380
|
"videos": "{n} ビデオ | {n} ビデオ",
|
|
380
381
|
"item": "{n} アイテム | {n} アイテム",
|
|
381
|
-
"filter": "{n}
|
|
382
|
+
"filter": "{n}件のフィルター | {n}件のフィルター"
|
|
383
|
+
},
|
|
384
|
+
"video_actions": {
|
|
385
|
+
"add_favorites": "お気に入りに追加",
|
|
386
|
+
"remove_favorites": "お気に入りから削除",
|
|
387
|
+
"add_watch_later": "後で見るに追加",
|
|
388
|
+
"remove_watch_later": "後で見るから削除"
|
|
382
389
|
},
|
|
383
390
|
"filter_popup": {
|
|
384
391
|
"active_count": "{count}件有効",
|
|
385
392
|
"active_title": "有効なフィルター",
|
|
386
|
-
"quick_title": "クイックフィルター
|
|
387
|
-
"fields": "{count}項目",
|
|
393
|
+
"quick_title": "クイックフィルター",
|
|
388
394
|
"performer": {
|
|
389
395
|
"title": "出演者",
|
|
390
396
|
"description": "画面に映っている人"
|
|
@@ -485,6 +491,7 @@
|
|
|
485
491
|
"profile": "プロフィール",
|
|
486
492
|
"settings": "設定",
|
|
487
493
|
"my_playlists": "マイプレイリスト",
|
|
494
|
+
"library": "ライブラリ",
|
|
488
495
|
"back_to_main_page": "メインページに戻る",
|
|
489
496
|
"history": "履歴",
|
|
490
497
|
"live_sex": "ライブ",
|
package/i18n/locales/ko-KR.json
CHANGED
|
@@ -1,4 +1,5 @@
|
|
|
1
1
|
{
|
|
2
|
+
"top_models": "인기 모델",
|
|
2
3
|
"additional_link": {
|
|
3
4
|
"2257": "2257",
|
|
4
5
|
"report": "버그 신고",
|
|
@@ -107,7 +108,7 @@
|
|
|
107
108
|
"get_full_video": "전체 비디오 받기",
|
|
108
109
|
"filter": "필터",
|
|
109
110
|
"filters": "필터",
|
|
110
|
-
"show_all_models": "
|
|
111
|
+
"show_all_models": "전체 보기",
|
|
111
112
|
"all_categories": "알파벳 A-Z",
|
|
112
113
|
"related_searches": "관련 검색",
|
|
113
114
|
"related_models": "관련 모델",
|
|
@@ -140,7 +141,7 @@
|
|
|
140
141
|
"models_by_letter": "알파벳별 모델",
|
|
141
142
|
"trending": "트렌드",
|
|
142
143
|
"trending_models": "트렌드 모델",
|
|
143
|
-
"popular_searches": "인기
|
|
144
|
+
"popular_searches": "인기 검색어",
|
|
144
145
|
"category": "카테고리",
|
|
145
146
|
"playlists": "재생목록",
|
|
146
147
|
"channels": "채널",
|
|
@@ -378,13 +379,18 @@
|
|
|
378
379
|
"views": "{n} 조회 | {n} 조회수",
|
|
379
380
|
"videos": "{n} 비디오 | {n} 비디오",
|
|
380
381
|
"item": "{n} 항목 | {n} 항목",
|
|
381
|
-
"filter": "{n}개 필터
|
|
382
|
+
"filter": "필터 {n}개 | 필터 {n}개"
|
|
383
|
+
},
|
|
384
|
+
"video_actions": {
|
|
385
|
+
"add_favorites": "즐겨찾기에 추가",
|
|
386
|
+
"remove_favorites": "즐겨찾기에서 제거",
|
|
387
|
+
"add_watch_later": "나중에 볼 동영상에 추가",
|
|
388
|
+
"remove_watch_later": "나중에 볼 동영상에서 제거"
|
|
382
389
|
},
|
|
383
390
|
"filter_popup": {
|
|
384
391
|
"active_count": "{count}개 활성",
|
|
385
392
|
"active_title": "활성 필터",
|
|
386
|
-
"quick_title": "빠른 필터
|
|
387
|
-
"fields": "{count}개 필드",
|
|
393
|
+
"quick_title": "빠른 필터",
|
|
388
394
|
"performer": {
|
|
389
395
|
"title": "배우",
|
|
390
396
|
"description": "화면 속 인물"
|
|
@@ -484,6 +490,7 @@
|
|
|
484
490
|
"network": "네트워크",
|
|
485
491
|
"profile": "프로필",
|
|
486
492
|
"settings": "설정",
|
|
493
|
+
"library": "보관함",
|
|
487
494
|
"my_playlists": "내 재생목록",
|
|
488
495
|
"back_to_main_page": "메인 페이지로 돌아가기",
|
|
489
496
|
"history": "기록",
|
package/i18n/locales/ru-RU.json
CHANGED
|
@@ -1,4 +1,5 @@
|
|
|
1
1
|
{
|
|
2
|
+
"top_models": "Популярные модели",
|
|
2
3
|
"additional_link": {
|
|
3
4
|
"2257": "2257",
|
|
4
5
|
"report": "Сообщить об ошибке",
|
|
@@ -107,7 +108,7 @@
|
|
|
107
108
|
"get_full_video": "Получить полное видео",
|
|
108
109
|
"filter": "Фильтр",
|
|
109
110
|
"filters": "Фильтры",
|
|
110
|
-
"show_all_models": "Показать
|
|
111
|
+
"show_all_models": "Показать все",
|
|
111
112
|
"all_categories": "По алфавиту А-Я",
|
|
112
113
|
"related_searches": "Похожие запросы",
|
|
113
114
|
"related_models": "Похожие модели",
|
|
@@ -378,15 +379,20 @@
|
|
|
378
379
|
"views": "{n} просмотр | {n} просмотра | {n} просмотров",
|
|
379
380
|
"videos": "{n} видео | {n} видео | {n} видео",
|
|
380
381
|
"item": "{n} элемент | {n} элемента | {n} элементов",
|
|
381
|
-
"filter": "{n} фильтр | {n} фильтра"
|
|
382
|
+
"filter": "{n} фильтр | {n} фильтра | {n} фильтров"
|
|
383
|
+
},
|
|
384
|
+
"video_actions": {
|
|
385
|
+
"add_favorites": "В избранное",
|
|
386
|
+
"remove_favorites": "Убрать из избранного",
|
|
387
|
+
"add_watch_later": "Смотреть позже",
|
|
388
|
+
"remove_watch_later": "Убрать из «Смотреть позже»"
|
|
382
389
|
},
|
|
383
390
|
"filter_popup": {
|
|
384
391
|
"active_count": "{count} активно",
|
|
385
392
|
"active_title": "Активные фильтры",
|
|
386
|
-
"quick_title": "Быстрые фильтры
|
|
387
|
-
"fields": "{count} полей",
|
|
393
|
+
"quick_title": "Быстрые фильтры",
|
|
388
394
|
"performer": {
|
|
389
|
-
"title": "Актёр
|
|
395
|
+
"title": "Актёр",
|
|
390
396
|
"description": "Кто в кадре"
|
|
391
397
|
},
|
|
392
398
|
"scene": {
|
|
@@ -485,6 +491,7 @@
|
|
|
485
491
|
"playlists": "Плейлисты",
|
|
486
492
|
"network": "Сеть",
|
|
487
493
|
"profile": "Профиль",
|
|
494
|
+
"library": "Библиотека",
|
|
488
495
|
"settings": "Настройки",
|
|
489
496
|
"my_playlists": "Мои плейлисты",
|
|
490
497
|
"back_to_main_page": "Вернуться на главную",
|
package/i18n/locales/zh-CN.json
CHANGED
|
@@ -1,4 +1,5 @@
|
|
|
1
1
|
{
|
|
2
|
+
"top_models": "热门模特",
|
|
2
3
|
"additional_link": {
|
|
3
4
|
"2257": "2257",
|
|
4
5
|
"report": "报告错误",
|
|
@@ -107,7 +108,7 @@
|
|
|
107
108
|
"get_full_video": "获取完整视频",
|
|
108
109
|
"filter": "筛选",
|
|
109
110
|
"filters": "筛选器",
|
|
110
|
-
"show_all_models": "
|
|
111
|
+
"show_all_models": "查看全部",
|
|
111
112
|
"all_categories": "字母 A-Z",
|
|
112
113
|
"related_searches": "相关搜索",
|
|
113
114
|
"related_models": "相关模特",
|
|
@@ -378,13 +379,18 @@
|
|
|
378
379
|
"views": "{n} 次观看 | {n} 次观看",
|
|
379
380
|
"videos": "{n} 个视频 | {n} 个视频",
|
|
380
381
|
"item": "{n} 个项目 | {n} 个项目",
|
|
381
|
-
"filter": "{n}
|
|
382
|
+
"filter": "{n} 个筛选项 | {n} 个筛选项"
|
|
383
|
+
},
|
|
384
|
+
"video_actions": {
|
|
385
|
+
"add_favorites": "添加到收藏",
|
|
386
|
+
"remove_favorites": "从收藏中移除",
|
|
387
|
+
"add_watch_later": "添加到稍后观看",
|
|
388
|
+
"remove_watch_later": "从稍后观看中移除"
|
|
382
389
|
},
|
|
383
390
|
"filter_popup": {
|
|
384
391
|
"active_count": "{count} 个已启用",
|
|
385
392
|
"active_title": "已启用的筛选",
|
|
386
|
-
"quick_title": "快速筛选
|
|
387
|
-
"fields": "{count} 个字段",
|
|
393
|
+
"quick_title": "快速筛选",
|
|
388
394
|
"performer": {
|
|
389
395
|
"title": "演员",
|
|
390
396
|
"description": "画面中是谁"
|
|
@@ -485,6 +491,7 @@
|
|
|
485
491
|
"profile": "个人资料",
|
|
486
492
|
"settings": "设置",
|
|
487
493
|
"my_playlists": "我的播放列表",
|
|
494
|
+
"library": "媒体库",
|
|
488
495
|
"back_to_main_page": "返回主页",
|
|
489
496
|
"history": "历史记录",
|
|
490
497
|
"live_sex": "直播",
|
package/package.json
CHANGED
|
@@ -75,7 +75,7 @@
|
|
|
75
75
|
</template>
|
|
76
76
|
|
|
77
77
|
<script setup lang="ts">
|
|
78
|
-
import { convertString,
|
|
78
|
+
import { convertString, convertModelToRoundCard, convertChannelToRoundCard } from '../../runtime';
|
|
79
79
|
import { sortItemsDefault } from '../../lib';
|
|
80
80
|
import { ChannelsApiService } from '../../services/api/channels.service';
|
|
81
81
|
import { ModelsApiService } from '../../services/api/models.service';
|
|
@@ -93,11 +93,13 @@ const [
|
|
|
93
93
|
{ data: cardInfo, status },
|
|
94
94
|
{ data: videosData, status: videosStatus },
|
|
95
95
|
{ data: channelsTag },
|
|
96
|
+
queryCategories,
|
|
96
97
|
] = await Promise.all([
|
|
97
98
|
useFetchModelsByChannel(ModelsApiService.getListByChannel, 100),
|
|
98
99
|
useFetchChannelsByName(ChannelsApiService.getChannelsByName),
|
|
99
100
|
useFetchVideosByChannel(VideosApiService.getListByNicheAndChannel),
|
|
100
101
|
useFetchPopularTagsByChannelName(VideosApiService.popularTagsByChannelName),
|
|
102
|
+
useRouteQueryCategories(),
|
|
101
103
|
]);
|
|
102
104
|
|
|
103
105
|
const { data: networkChannels } = cardInfo.value?.isNetwork
|
|
@@ -107,13 +109,7 @@ const { data: networkChannels } = cardInfo.value?.isNetwork
|
|
|
107
109
|
const title = convertString().fromSlug(String(route.params['channel']));
|
|
108
110
|
const metaTitle = computed(() => convertString().toCapitalize(title));
|
|
109
111
|
|
|
110
|
-
const routeQueryCategories = computed(() => {
|
|
111
|
-
if (route.query['categories']) {
|
|
112
|
-
const text = convertQueryCategories(route);
|
|
113
|
-
return ` ${convertString().toCapitalize(text)}`;
|
|
114
|
-
}
|
|
115
|
-
return '';
|
|
116
|
-
});
|
|
112
|
+
const routeQueryCategories = computed(() => (queryCategories.value ? ` ${queryCategories.value}` : ''));
|
|
117
113
|
|
|
118
114
|
const {
|
|
119
115
|
meta
|
package/pages/history.vue
CHANGED
|
@@ -51,7 +51,7 @@
|
|
|
51
51
|
>
|
|
52
52
|
<UiSectionTitle
|
|
53
53
|
:title="getGroupTitle(group)"
|
|
54
|
-
:count="
|
|
54
|
+
:count="group.items.length"
|
|
55
55
|
class="history__group-header"
|
|
56
56
|
icon="video-camera"
|
|
57
57
|
title-tag="h2"
|
|
@@ -130,7 +130,7 @@ import type { IBreadcrumbItem, IVideoCard } from '../types';
|
|
|
130
130
|
|
|
131
131
|
const PER_PAGE = 24;
|
|
132
132
|
|
|
133
|
-
const { t,
|
|
133
|
+
const { t, locale } = useI18n();
|
|
134
134
|
const route = useRoute();
|
|
135
135
|
|
|
136
136
|
const isFilterOpen = ref(false);
|
package/pages/index.vue
CHANGED
|
@@ -17,7 +17,7 @@
|
|
|
17
17
|
</template>
|
|
18
18
|
|
|
19
19
|
<script setup lang="ts">
|
|
20
|
-
import {
|
|
20
|
+
import { 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';
|
|
@@ -37,21 +37,17 @@ const [
|
|
|
37
37
|
{ data, status },
|
|
38
38
|
{ data: dataModels },
|
|
39
39
|
{ quickGroups },
|
|
40
|
+
queryCategories,
|
|
40
41
|
] = await Promise.all([
|
|
41
42
|
useFetchVideos(VideosApiService.getListByNiche),
|
|
42
43
|
useFetchTopModels(ModelsApiService.getTop, 24, AsyncData.TopModelsMore, String(pageName)),
|
|
43
44
|
useFetchQuickFilterGroups(CategoriesApiService.getQuickGroups, quickFilterScheme.map(item => item.name)),
|
|
45
|
+
useRouteQueryCategories(),
|
|
44
46
|
]);
|
|
45
47
|
|
|
46
48
|
const quickFilters = computed(() => toQuickFilters(quickGroups.value || [], quickFilterScheme, key => t(key)));
|
|
47
49
|
|
|
48
|
-
const routeQueryCategories = computed(() => {
|
|
49
|
-
if (route.query['categories']) {
|
|
50
|
-
const text = convertQueryCategories(route);
|
|
51
|
-
return `${convertString().toCapitalize(text)} `;
|
|
52
|
-
}
|
|
53
|
-
return '';
|
|
54
|
-
});
|
|
50
|
+
const routeQueryCategories = computed(() => (queryCategories.value ? `${queryCategories.value} ` : ''));
|
|
55
51
|
|
|
56
52
|
const clientCountry = useClientCountry();
|
|
57
53
|
|
|
@@ -24,7 +24,7 @@
|
|
|
24
24
|
</template>
|
|
25
25
|
|
|
26
26
|
<script setup lang="ts">
|
|
27
|
-
import {
|
|
27
|
+
import { convertString } from '../../../../runtime';
|
|
28
28
|
import { VideosApiService } from '../../../../services/api/videos.service';
|
|
29
29
|
import { ModelsApiService } from '../../../../services/api/models.service';
|
|
30
30
|
import { ChannelsApiService } from '../../../../services/api/channels.service';
|
|
@@ -42,6 +42,7 @@ const [
|
|
|
42
42
|
{ data: dataModels },
|
|
43
43
|
{ data: modelTags },
|
|
44
44
|
{ data: modelChannels },
|
|
45
|
+
queryCategories,
|
|
45
46
|
] = await Promise.all([
|
|
46
47
|
useFetchVideosByChannel(VideosApiService.getListByNicheAndChannel),
|
|
47
48
|
useFetchVideosByModelAndChannel(VideosApiService.getListByNicheAndModelAndChannel),
|
|
@@ -49,6 +50,7 @@ const [
|
|
|
49
50
|
useFetchRelatedModels(ModelsApiService.getRelatedModels, 25, String(pageName)),
|
|
50
51
|
useFetchPopularTagsByModelName(VideosApiService.popularTagsByModelName),
|
|
51
52
|
useFetchChannelsByModel(ChannelsApiService.getChannelsByModel),
|
|
53
|
+
useRouteQueryCategories(),
|
|
52
54
|
]);
|
|
53
55
|
|
|
54
56
|
const { t } = useI18n();
|
|
@@ -56,13 +58,7 @@ const { t } = useI18n();
|
|
|
56
58
|
const title = convertString().fromSlug(String(route.params[ 'slug' ]));
|
|
57
59
|
const metaTitle = computed(() => convertString().toCapitalize(title));
|
|
58
60
|
|
|
59
|
-
const routeQueryCategories = computed(() => {
|
|
60
|
-
if (route.query[ 'categories' ]) {
|
|
61
|
-
const text = convertQueryCategories(route);
|
|
62
|
-
return ` ${convertString().toCapitalize(text)}`;
|
|
63
|
-
}
|
|
64
|
-
return '';
|
|
65
|
-
});
|
|
61
|
+
const routeQueryCategories = computed(() => (queryCategories.value ? ` ${queryCategories.value}` : ''));
|
|
66
62
|
|
|
67
63
|
const channelRoute = String(route.params[ 'channel' ]);
|
|
68
64
|
const channelSlug = computed(() => convertString().fromSlug(channelRoute));
|
|
@@ -18,7 +18,7 @@
|
|
|
18
18
|
</template>
|
|
19
19
|
|
|
20
20
|
<script setup lang="ts">
|
|
21
|
-
import {
|
|
21
|
+
import { convertString, AsyncData } from '../../../runtime';
|
|
22
22
|
import { VideosApiService } from '../../../services/api/videos.service';
|
|
23
23
|
import { ModelsApiService } from '../../../services/api/models.service';
|
|
24
24
|
import { ChannelsApiService } from '../../../services/api/channels.service';
|
|
@@ -36,6 +36,7 @@ const [
|
|
|
36
36
|
{ data: modelTags },
|
|
37
37
|
{ data: modelChannels },
|
|
38
38
|
{ data: topModels }, // TODO placeholder
|
|
39
|
+
queryCategories,
|
|
39
40
|
] = await Promise.all([
|
|
40
41
|
useFetchVideosByModel(VideosApiService.getListByNicheAndModel),
|
|
41
42
|
useFetchModelByName(ModelsApiService.getModelsByName),
|
|
@@ -43,6 +44,7 @@ const [
|
|
|
43
44
|
useFetchPopularTagsByModelName(VideosApiService.popularTagsByModelName),
|
|
44
45
|
useFetchChannelsByModel(ChannelsApiService.getChannelsByModel),
|
|
45
46
|
useFetchTopModels(ModelsApiService.getTop, 12, AsyncData.TopModelsMore, String(pageName)),
|
|
47
|
+
useRouteQueryCategories(),
|
|
46
48
|
]);
|
|
47
49
|
|
|
48
50
|
const testModelImages = [
|
|
@@ -66,13 +68,7 @@ const { t } = useI18n();
|
|
|
66
68
|
const title = convertString().fromSlug(String(route.params[ 'slug' ]));
|
|
67
69
|
const metaTitle = computed(() => convertString().toCapitalize(title));
|
|
68
70
|
|
|
69
|
-
const routeQueryCategories = computed(() => {
|
|
70
|
-
if (route.query[ 'categories' ]) {
|
|
71
|
-
const text = convertQueryCategories(route);
|
|
72
|
-
return ` ${convertString().toCapitalize(text)}`;
|
|
73
|
-
}
|
|
74
|
-
return '';
|
|
75
|
-
});
|
|
71
|
+
const routeQueryCategories = computed(() => (queryCategories.value ? ` ${queryCategories.value}` : ''));
|
|
76
72
|
|
|
77
73
|
const { meta, h1 } = useMeta({
|
|
78
74
|
t,
|
|
@@ -24,7 +24,7 @@
|
|
|
24
24
|
</template>
|
|
25
25
|
|
|
26
26
|
<script setup lang="ts">
|
|
27
|
-
import {
|
|
27
|
+
import { convertString } from '../../../../runtime';
|
|
28
28
|
import { VideosApiService } from '../../../../services/api/videos.service';
|
|
29
29
|
import { ModelsApiService } from '../../../../services/api/models.service';
|
|
30
30
|
import { ChannelsApiService } from '../../../../services/api/channels.service';
|
|
@@ -42,6 +42,7 @@ const [
|
|
|
42
42
|
{ data: dataModels },
|
|
43
43
|
{ data: modelTags },
|
|
44
44
|
{ data: modelChannels },
|
|
45
|
+
queryCategories,
|
|
45
46
|
] = await Promise.all([
|
|
46
47
|
useFetchVideosByTag(VideosApiService.getListByNicheAndTag),
|
|
47
48
|
useFetchVideosByModelAndTag(VideosApiService.getListByNicheAndModelAndTag),
|
|
@@ -49,6 +50,7 @@ const [
|
|
|
49
50
|
useFetchRelatedModels(ModelsApiService.getRelatedModels, 25, String(pageName)),
|
|
50
51
|
useFetchPopularTagsByModelName(VideosApiService.popularTagsByModelName),
|
|
51
52
|
useFetchChannelsByModel(ChannelsApiService.getChannelsByModel),
|
|
53
|
+
useRouteQueryCategories(),
|
|
52
54
|
]);
|
|
53
55
|
|
|
54
56
|
const { t } = useI18n();
|
|
@@ -57,13 +59,7 @@ const tagRoute = String(route.params[ 'tag' ]);
|
|
|
57
59
|
const tagSlug = computed(() => convertString().fromSlug(tagRoute));
|
|
58
60
|
const title = convertString().fromSlug(String(route.params[ 'slug' ]));
|
|
59
61
|
const metaTitle = computed(() => convertString().toCapitalize(title));
|
|
60
|
-
const routeQueryCategories = computed(() => {
|
|
61
|
-
if (route.query[ 'categories' ]) {
|
|
62
|
-
const text = convertQueryCategories(route);
|
|
63
|
-
return ` ${convertString().toCapitalize(text)}`;
|
|
64
|
-
}
|
|
65
|
-
return '';
|
|
66
|
-
});
|
|
62
|
+
const routeQueryCategories = computed(() => (queryCategories.value ? ` ${queryCategories.value}` : ''));
|
|
67
63
|
|
|
68
64
|
const { meta, h1 } = useMeta({
|
|
69
65
|
t,
|
package/pages/tag/[tag].vue
CHANGED
|
@@ -21,7 +21,7 @@
|
|
|
21
21
|
|
|
22
22
|
<script setup lang="ts">
|
|
23
23
|
import type { IBreadcrumbItem } from '../../types';
|
|
24
|
-
import {
|
|
24
|
+
import { convertString } from '../../runtime';
|
|
25
25
|
import { VideosApiService } from '../../services/api/videos.service';
|
|
26
26
|
import { ModelsApiService } from '../../services/api/models.service';
|
|
27
27
|
import { sortItemsDefault } from '../../lib';
|
|
@@ -33,9 +33,11 @@ const pageName = useGetPureRouteName();
|
|
|
33
33
|
const [
|
|
34
34
|
{ data, status },
|
|
35
35
|
{ data: modelsByTag },
|
|
36
|
+
queryCategories,
|
|
36
37
|
] = await Promise.all([
|
|
37
38
|
useFetchVideosByTag(VideosApiService.getListByNicheAndTag),
|
|
38
39
|
useFetchModelsByTag(ModelsApiService.getListByTag, 100),
|
|
40
|
+
useRouteQueryCategories(),
|
|
39
41
|
]);
|
|
40
42
|
|
|
41
43
|
useNoindexThinListing(() => data.value?.items?.length);
|
|
@@ -46,13 +48,7 @@ const slug = computed(() => String(route.params[ 'tag' ]));
|
|
|
46
48
|
const title = convertString().fromSlug(slug.value);
|
|
47
49
|
const metaTitle = computed(() => convertString().toCapitalize(title));
|
|
48
50
|
|
|
49
|
-
const routeQueryCategories = computed(() => {
|
|
50
|
-
if (route.query[ 'categories' ]) {
|
|
51
|
-
const text = convertQueryCategories(route);
|
|
52
|
-
return ` ${convertString().toCapitalize(text)}`;
|
|
53
|
-
}
|
|
54
|
-
return '';
|
|
55
|
-
});
|
|
51
|
+
const routeQueryCategories = computed(() => (queryCategories.value ? ` ${queryCategories.value}` : ''));
|
|
56
52
|
|
|
57
53
|
const { meta, h1 } = useMeta({
|
|
58
54
|
t,
|
|
@@ -19,4 +19,21 @@ describe('convertQueryCategories', () => {
|
|
|
19
19
|
const result = convertQueryCategories(route as any);
|
|
20
20
|
expect(result).toBe('Teen');
|
|
21
21
|
});
|
|
22
|
+
|
|
23
|
+
it('берёт переведённый заголовок из групп с бека', () => {
|
|
24
|
+
const route = { query: { categories: 'cat_teen,age_milf' } };
|
|
25
|
+
const groups = [
|
|
26
|
+
{ name: 'cat', categories: [{ name: 'teen', title: 'подростки' }] },
|
|
27
|
+
{ name: 'Age', categories: [{ name: 'milf', title: 'мамочки' }] },
|
|
28
|
+
];
|
|
29
|
+
const result = convertQueryCategories(route as any, groups as any);
|
|
30
|
+
expect(result).toBe('Подростки, Мамочки');
|
|
31
|
+
});
|
|
32
|
+
|
|
33
|
+
it('без совпадения в группах оставляет значение из query', () => {
|
|
34
|
+
const route = { query: { categories: 'cat_teen' } };
|
|
35
|
+
const groups = [{ name: 'other', categories: [{ name: 'milf', title: 'мамочки' }] }];
|
|
36
|
+
const result = convertQueryCategories(route as any, groups as any);
|
|
37
|
+
expect(result).toBe('Teen');
|
|
38
|
+
});
|
|
22
39
|
});
|
|
@@ -1,6 +1,28 @@
|
|
|
1
1
|
import type { RouteLocationNormalizedLoaded } from 'vue-router';
|
|
2
|
-
|
|
3
|
-
|
|
2
|
+
import type { IGroupCategories } from '../../../types';
|
|
3
|
+
|
|
4
|
+
/**
|
|
5
|
+
* Конвертирует категории из query-параметра в читаемую строку с заглавными буквами (до 3 категорий).
|
|
6
|
+
* Со списком групп с бека берёт переведённый заголовок категории, без него — значение из query
|
|
7
|
+
* (английский слаг): группы грузятся лениво, до них заголовок собирать всё равно нужно.
|
|
8
|
+
*
|
|
9
|
+
* @param route - роут со query-параметром `categories` вида `<группа>_<категория>`
|
|
10
|
+
* @param groups - группы категорий с бека, заголовки в языке текущей локали
|
|
11
|
+
*/
|
|
12
|
+
export function convertQueryCategories(
|
|
13
|
+
route: RouteLocationNormalizedLoaded,
|
|
14
|
+
groups?: IGroupCategories[] | null,
|
|
15
|
+
) {
|
|
4
16
|
const MAX_CATEGORIES = 3;
|
|
5
|
-
|
|
17
|
+
|
|
18
|
+
return String(route.query[ 'categories' ])?.split(',').slice(0, MAX_CATEGORIES).map(item => {
|
|
19
|
+
const [ groupName, ...rest ] = item.split('_');
|
|
20
|
+
const name = rest.join('_');
|
|
21
|
+
const title = groups
|
|
22
|
+
?.find(group => group.name.toLowerCase() === groupName?.toLowerCase())
|
|
23
|
+
?.categories?.find(category => category.name === name)?.title;
|
|
24
|
+
|
|
25
|
+
const text = title || name;
|
|
26
|
+
return text?.charAt(0).toUpperCase() + text?.slice(1);
|
|
27
|
+
}).join(', ');
|
|
6
28
|
}
|