itube-specs 0.0.1031 → 0.0.1036
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/DOCS/CONVENTIONS.md +7 -0
- package/SITE_UPDATES.md +24 -0
- package/assets/scss/themes/dark/_theme.scss +3 -3
- package/assets/scss/themes/light/_theme.scss +3 -3
- package/components/card/video/index.vue +1 -2
- package/components/category/list.vue +1 -0
- package/components/category/nav.vue +5 -2
- package/components/layout/footer-language.vue +0 -3
- package/components/section/recommended.vue +36 -0
- package/components/ui/pagination.vue +1 -1
- package/components/ui/switcher.vue +5 -1
- package/components/view/models.vue +57 -2
- package/components/view/webcams.vue +8 -9
- package/components/webcam/main.vue +13 -0
- package/i18n/locales/de-DE.json +17 -0
- package/i18n/locales/en-US.json +39 -0
- package/i18n/locales/es-ES.json +17 -0
- package/i18n/locales/fr-FR.json +17 -0
- package/i18n/locales/it-IT.json +17 -0
- package/i18n/locales/ja-JP.json +17 -0
- package/i18n/locales/ko-KR.json +17 -0
- package/i18n/locales/ru-RU.json +38 -0
- package/i18n/locales/zh-CN.json +17 -0
- package/package.json +1 -1
- package/pages/categories/letter/[slug].vue +6 -2
- package/runtime/utils/__tests__/normalize-url.test.ts +10 -0
- package/runtime/utils/normalize-url.ts +9 -1
- package/services/api/videos.service.ts +8 -0
- package/types/switcher-item.d.ts +2 -0
package/DOCS/CONVENTIONS.md
CHANGED
|
@@ -19,6 +19,13 @@ namespace; filename must not repeat it.
|
|
|
19
19
|
**App override:** an app defining the same name outranks the layer and wins auto-import; callers untouched.
|
|
20
20
|
Don't fork a component when a prop expresses the difference.
|
|
21
21
|
|
|
22
|
+
**Small difference → flag or prop in the layer, not an override.** One or two tweaks (hide a block, another
|
|
23
|
+
link, another spacing), and anything another site may want too: add a prop, an `appConfig` key (default =
|
|
24
|
+
current behaviour, the site sets it in its `config/site.config.ts`) or a feature flag here, plus a
|
|
25
|
+
`SITE_UPDATES.md` entry. No copied page or component in an app slot for that — a fork goes stale with every
|
|
26
|
+
layer release. Override only when the app's version genuinely differs throughout (e.g. a site's ad-aware
|
|
27
|
+
`grid/videos.vue`). Sample: `appConfig.categoriesGrouped` instead of an app copy of `category/nav.vue`.
|
|
28
|
+
|
|
22
29
|
## Markup
|
|
23
30
|
|
|
24
31
|
`<div>` is the fallback, not the default.
|
package/SITE_UPDATES.md
CHANGED
|
@@ -11,6 +11,30 @@
|
|
|
11
11
|
коммиты до бампа); `npm run patch` сам проставит ей номер версии. Формат пункта: что сделать и где,
|
|
12
12
|
кому именно (всем / сайтам с N), что будет, если не сделать.
|
|
13
13
|
|
|
14
|
+
## 0.0.1036 — 2026-10-14
|
|
15
|
+
|
|
16
|
+
- **Сайтам с `FeatureWebcamsEnabled`:** UI-ключи вебкам-блоков `webcams.*` (заголовки, счётчики онлайна, карточка,
|
|
17
|
+
фильтры, переключатель разделов) теперь в локалях слоя — перенесены из videosection-redesign. Свои `webcams.*` с
|
|
18
|
+
тем же значением удалить (иначе замораживают перевод; `i18n:check` покажет их как dead overrides), отличающиеся —
|
|
19
|
+
оставить как override. SEO раздела (`meta.webcams.*`, `webcams_footer_*`) остаётся в сайте. Не сделать — ничего
|
|
20
|
+
не сломается.
|
|
21
|
+
|
|
22
|
+
- **Сайтам без сгруппированного вида категорий** (videosection-redesign): в `app.config` (через
|
|
23
|
+
`config/site.config.ts`) поставить `categoriesGrouped: false` — `CategoryNav` перестаёт рисовать табы
|
|
24
|
+
Grouped / A-Z, «All» в алфавите ведёт на `/categories`. После этого удалить проектный оверрайд
|
|
25
|
+
`components/category/nav.vue` (образец — коммит videosection-redesign с этим изменением). Не сделать — табы
|
|
26
|
+
останутся, пока жив оверрайд; без оверрайда табы вернутся. Остальным сайтам — ничего, по умолчанию `true`.
|
|
27
|
+
|
|
28
|
+
## 0.0.1033 — 2026-10-14
|
|
29
|
+
|
|
30
|
+
- **Сайтам со своей темой (hdsex-remaster):** в `_theme.scss` добавить `$switcher-icon--live: $color-emerald-400;` и
|
|
31
|
+
поменять `$switcher-icon--active` на `$color-white-70`. Иконка «в эфире» в переключателе разделов теперь зелёная
|
|
32
|
+
всегда (опция с `live: true`), а активная иконка — белая, как в макете страницы моделей. Не сделать — сборка упадёт
|
|
33
|
+
на неизвестном токене. Образец — коммит hdsex-remaster.
|
|
34
|
+
- **Сайтам с `FeatureWebcamsEnabled`:** ничего делать не надо — на странице моделей (`ViewModels`) заголовок встаёт
|
|
35
|
+
наверх с переключателем «Видео / Вебкам», как на странице вебкамов. Сайтам с форком `pages/models/index.vue` или
|
|
36
|
+
`components/view/models.vue` — сверить с апстримом.
|
|
37
|
+
|
|
14
38
|
## 0.0.1030 — 2026-10-13
|
|
15
39
|
|
|
16
40
|
- **Всем сайтам с плеером:** поднять `itube-modern-player` до `^0.15.0` (`npm install itube-modern-player@^0.15.0`).
|
|
@@ -414,8 +414,6 @@ $niche-switch-text--checked: $color-white;
|
|
|
414
414
|
$footer-language-bg: $color-zinc-800-50;
|
|
415
415
|
$footer-language-bg--hover: $color-zinc-700;
|
|
416
416
|
$footer-language-text: $color-zinc-400;
|
|
417
|
-
$footer-language-badge-bg: $color-zinc-800;
|
|
418
|
-
$footer-language-badge-text: $color-zinc-200;
|
|
419
417
|
$footer-language-menu-bg: $color-night;
|
|
420
418
|
$footer-language-menu-border: $color-zinc-800;
|
|
421
419
|
$footer-language-menu-shadow: 0 20px 25px -5px #{$mono-black-25}, 0 8px 10px -6px #{$mono-black-25};
|
|
@@ -1143,6 +1141,7 @@ $document-link: $color-indigo-400;
|
|
|
1143
1141
|
$document-link--hover: $color-indigo-300;
|
|
1144
1142
|
|
|
1145
1143
|
// ─── Вебкам-блок ───
|
|
1144
|
+
$webcam-main-bg: transparent;
|
|
1146
1145
|
$webcam-icon-text: $color-white;
|
|
1147
1146
|
$webcam-icon-bg: $color-white-10;
|
|
1148
1147
|
$webcam-header-promo-text: $color-white;
|
|
@@ -1190,7 +1189,8 @@ $switcher-text--hover: $color-white;
|
|
|
1190
1189
|
$switcher-text--active: $color-white;
|
|
1191
1190
|
$switcher-bg--active: $color-indigo-600;
|
|
1192
1191
|
$switcher-icon: $color-zinc-500;
|
|
1193
|
-
$switcher-icon--active: $color-
|
|
1192
|
+
$switcher-icon--active: $color-white-70;
|
|
1193
|
+
$switcher-icon--live: $color-emerald-400;
|
|
1194
1194
|
$switcher-subtitle: $color-zinc-600;
|
|
1195
1195
|
$switcher-subtitle--active: $color-white-60;
|
|
1196
1196
|
|
|
@@ -470,8 +470,6 @@ $niche-switch-text--checked: $color-white;
|
|
|
470
470
|
$footer-language-bg: $color-slate-750;
|
|
471
471
|
$footer-language-bg--hover: $color-slate-700;
|
|
472
472
|
$footer-language-text: $color-white;
|
|
473
|
-
$footer-language-badge-bg: $color-slate-850;
|
|
474
|
-
$footer-language-badge-text: $color-white;
|
|
475
473
|
$footer-language-menu-bg: $color-slate-850;
|
|
476
474
|
$footer-language-menu-border: $color-white-10;
|
|
477
475
|
$footer-language-menu-shadow: 0 20px 25px -5px #{$mono-black-25}, 0 8px 10px -6px #{$mono-black-25};
|
|
@@ -1170,6 +1168,7 @@ $document-link: $color-indigo-400;
|
|
|
1170
1168
|
$document-link--hover: $color-indigo-300;
|
|
1171
1169
|
|
|
1172
1170
|
// ─── Вебкам-блок ───
|
|
1171
|
+
$webcam-main-bg: $color-gray-100;
|
|
1173
1172
|
$webcam-icon-text: $color-gray-700;
|
|
1174
1173
|
$webcam-icon-bg: $color-gray-200;
|
|
1175
1174
|
$webcam-header-promo-text: $color-white;
|
|
@@ -1217,7 +1216,8 @@ $switcher-text--hover: $color-gray-950;
|
|
|
1217
1216
|
$switcher-text--active: $color-white;
|
|
1218
1217
|
$switcher-bg--active: $color-indigo-600;
|
|
1219
1218
|
$switcher-icon: $color-gray-500;
|
|
1220
|
-
$switcher-icon--active: $color-
|
|
1219
|
+
$switcher-icon--active: $color-white-70;
|
|
1220
|
+
$switcher-icon--live: $color-emerald-400;
|
|
1221
1221
|
$switcher-subtitle: $color-gray-500;
|
|
1222
1222
|
$switcher-subtitle--active: $color-white-60;
|
|
1223
1223
|
|
|
@@ -24,8 +24,8 @@
|
|
|
24
24
|
:urls="card.thumbUrls"
|
|
25
25
|
:loading="loading"
|
|
26
26
|
:priority="priority"
|
|
27
|
-
:sizes="`(max-width: ${CSS_BREAKPOINTS.lg}px) 180px, 480px`"
|
|
28
27
|
:alt="card.title"
|
|
28
|
+
sizes="auto, (min-width: 1440px) 25vw, (min-width: 900px) 33vw, (min-width: 370px) 50vw, 100vw"
|
|
29
29
|
class="card-video__poster"
|
|
30
30
|
width="480"
|
|
31
31
|
height="270"
|
|
@@ -137,7 +137,6 @@
|
|
|
137
137
|
<script setup lang="ts">
|
|
138
138
|
import type { IVideoCard } from '../../../types';
|
|
139
139
|
import { convertString, getDuration } from '../../../runtime';
|
|
140
|
-
import { CSS_BREAKPOINTS } from '../../../config/css-breakpoints';
|
|
141
140
|
|
|
142
141
|
const featureFlags = useRuntimeConfig().public.featureFlags;
|
|
143
142
|
|
|
@@ -1,5 +1,6 @@
|
|
|
1
1
|
<template>
|
|
2
2
|
<SectionNavLinks
|
|
3
|
+
v-if="isGrouped"
|
|
3
4
|
:items="categoriesNavigation"
|
|
4
5
|
theme="secondary"
|
|
5
6
|
/>
|
|
@@ -31,18 +32,20 @@ const props = defineProps<{
|
|
|
31
32
|
|
|
32
33
|
const { t } = useI18n();
|
|
33
34
|
|
|
35
|
+
const isGrouped = useAppConfig().categoriesGrouped !== false;
|
|
36
|
+
|
|
34
37
|
const alphabetItems = computed<IChipsItem[]>(() => [
|
|
35
38
|
{
|
|
36
39
|
title: t('all'),
|
|
37
40
|
value: '',
|
|
38
|
-
prefix: 'categories/all',
|
|
41
|
+
prefix: isGrouped ? 'categories/all' : 'categories',
|
|
39
42
|
},
|
|
40
43
|
...(props.items || []),
|
|
41
44
|
]);
|
|
42
45
|
</script>
|
|
43
46
|
|
|
44
47
|
<style lang="scss">
|
|
45
|
-
.category-nav__alphabet {
|
|
48
|
+
.section-nav-links + .category-nav__alphabet {
|
|
46
49
|
margin-top: 16px;
|
|
47
50
|
}
|
|
48
51
|
</style>
|
|
@@ -0,0 +1,36 @@
|
|
|
1
|
+
<!-- Блок «Recommended to you»: заголовок и сетка роликов без вебкам-врезок; пустой список — блока нет -->
|
|
2
|
+
<template>
|
|
3
|
+
<section
|
|
4
|
+
v-if="items.length"
|
|
5
|
+
class="section-recommended"
|
|
6
|
+
>
|
|
7
|
+
<UiSectionTitle
|
|
8
|
+
:title="t('recommended_to_you')"
|
|
9
|
+
title-tag="h2"
|
|
10
|
+
variant="section"
|
|
11
|
+
/>
|
|
12
|
+
|
|
13
|
+
<GridVideos
|
|
14
|
+
:items="items"
|
|
15
|
+
:inserts="[]"
|
|
16
|
+
/>
|
|
17
|
+
</section>
|
|
18
|
+
</template>
|
|
19
|
+
|
|
20
|
+
<script setup lang="ts">
|
|
21
|
+
import type { IVideoCard } from '../../types';
|
|
22
|
+
|
|
23
|
+
defineProps<{
|
|
24
|
+
items: IVideoCard[]
|
|
25
|
+
}>();
|
|
26
|
+
|
|
27
|
+
const { t } = useI18n();
|
|
28
|
+
</script>
|
|
29
|
+
|
|
30
|
+
<style lang="scss">
|
|
31
|
+
.section-recommended {
|
|
32
|
+
display: grid;
|
|
33
|
+
row-gap: 16px;
|
|
34
|
+
margin-block: 40px;
|
|
35
|
+
}
|
|
36
|
+
</style>
|
|
@@ -13,7 +13,7 @@
|
|
|
13
13
|
v-for="item in items"
|
|
14
14
|
:is="item.to ? NuxtLink : 'button'"
|
|
15
15
|
:key="item.name"
|
|
16
|
-
:class="{'--active': item.name === modelValue}"
|
|
16
|
+
:class="{'--active': item.name === modelValue, '--live': item.live}"
|
|
17
17
|
:to="item.to"
|
|
18
18
|
:type="item.to ? undefined : 'button'"
|
|
19
19
|
class="ui-switcher__item"
|
|
@@ -104,6 +104,10 @@ const emit = defineEmits<{
|
|
|
104
104
|
color: $switcher-icon--active;
|
|
105
105
|
}
|
|
106
106
|
|
|
107
|
+
.ui-switcher__item.--live .ui-switcher__icon {
|
|
108
|
+
color: $switcher-icon--live;
|
|
109
|
+
}
|
|
110
|
+
|
|
107
111
|
.ui-switcher__subtitle {
|
|
108
112
|
color: $switcher-subtitle;
|
|
109
113
|
}
|
|
@@ -1,7 +1,25 @@
|
|
|
1
|
-
<!-- Страница списка моделей: топ-аватары, фильтры, сортировка,
|
|
1
|
+
<!-- Страница списка моделей: топ-аватары, фильтры, сортировка, пагинация.
|
|
2
|
+
С вебкамами (`FeatureWebcamsEnabled`) заголовок встаёт наверх вместе с переключателем
|
|
3
|
+
разделов «Видео / Вебкам», как на странице вебкамов. -->
|
|
2
4
|
<template>
|
|
3
5
|
<div class="view-models _page _container">
|
|
4
6
|
<header class="view-models__header">
|
|
7
|
+
<div
|
|
8
|
+
v-if="isWebcamsEnabled"
|
|
9
|
+
class="view-models__intro"
|
|
10
|
+
>
|
|
11
|
+
<UiSectionTitle
|
|
12
|
+
:title="h1"
|
|
13
|
+
:count="isFiltered || letter ? data?.total : modelsTotal || data?.total"
|
|
14
|
+
icon="user"
|
|
15
|
+
/>
|
|
16
|
+
|
|
17
|
+
<UiSwitcher
|
|
18
|
+
:items="sections"
|
|
19
|
+
:model-value="'videos'"
|
|
20
|
+
/>
|
|
21
|
+
</div>
|
|
22
|
+
|
|
5
23
|
<SectionSwiperCards
|
|
6
24
|
v-if="topCards.length >= MINIMUM_TOP_THUMBS"
|
|
7
25
|
:items="topCards"
|
|
@@ -10,6 +28,7 @@
|
|
|
10
28
|
/>
|
|
11
29
|
|
|
12
30
|
<UiSectionTitle
|
|
31
|
+
v-if="!isWebcamsEnabled"
|
|
13
32
|
:count="data?.total"
|
|
14
33
|
:title="h1"
|
|
15
34
|
class="view-models__title"
|
|
@@ -117,6 +136,7 @@ import { sortModels, alphabetItems } from '../../lib';
|
|
|
117
136
|
import type {
|
|
118
137
|
IModelCard,
|
|
119
138
|
IModelFilter,
|
|
139
|
+
ISwitcherItem,
|
|
120
140
|
PaginatedResponse,
|
|
121
141
|
} from '../../types';
|
|
122
142
|
import { convertStringArrayToChips, convertModelToRoundCard } from '../../runtime';
|
|
@@ -132,7 +152,35 @@ const props = defineProps<{
|
|
|
132
152
|
letter?: string,
|
|
133
153
|
}>();
|
|
134
154
|
|
|
135
|
-
const { t } = useI18n();
|
|
155
|
+
const { t, n } = useI18n();
|
|
156
|
+
const { generateLink } = useGenerateLink();
|
|
157
|
+
const webcamsPath = useWebcamsPath();
|
|
158
|
+
|
|
159
|
+
const isWebcamsEnabled = !!useRuntimeConfig().public.featureFlags.FeatureWebcamsEnabled;
|
|
160
|
+
|
|
161
|
+
// онлайн — тем же ключом фетча, что у блока «Live Cams» (второго запроса нет); всего моделей — той же ручкой,
|
|
162
|
+
// что на странице вебкамов, чтобы счётчик в переключателе на обеих страницах совпадал. Без флага запросов нет.
|
|
163
|
+
const [{ counts: webcamCounts }, modelsTotal] = await Promise.all([
|
|
164
|
+
useFetchWebcams(),
|
|
165
|
+
isWebcamsEnabled ? useFetchModelsCount() : computed(() => 0),
|
|
166
|
+
]);
|
|
167
|
+
|
|
168
|
+
const sections = computed<ISwitcherItem[]>(() => [
|
|
169
|
+
{
|
|
170
|
+
name: 'videos',
|
|
171
|
+
title: t('webcams.sections.videos'),
|
|
172
|
+
icon: 'user-group',
|
|
173
|
+
subtitle: modelsTotal.value ? n(modelsTotal.value) : '',
|
|
174
|
+
},
|
|
175
|
+
{
|
|
176
|
+
name: 'webcams',
|
|
177
|
+
title: t('webcams.sections.webcams'),
|
|
178
|
+
icon: 'webcam',
|
|
179
|
+
live: true,
|
|
180
|
+
subtitle: webcamCounts.value.total ? t('webcams.online_short', { count: n(webcamCounts.value.total, 'compact') }) : '',
|
|
181
|
+
to: generateLink(webcamsPath.value),
|
|
182
|
+
},
|
|
183
|
+
]);
|
|
136
184
|
|
|
137
185
|
const MINIMUM_TOP_VIDEOS_COUNT = 30;
|
|
138
186
|
const MINIMUM_TOP_THUMBS = 3;
|
|
@@ -206,6 +254,13 @@ const topCards = computed(() => {
|
|
|
206
254
|
}
|
|
207
255
|
}
|
|
208
256
|
|
|
257
|
+
.view-models__intro {
|
|
258
|
+
display: grid;
|
|
259
|
+
justify-items: start;
|
|
260
|
+
row-gap: 16px;
|
|
261
|
+
margin-bottom: 16px;
|
|
262
|
+
}
|
|
263
|
+
|
|
209
264
|
.view-models__pagination {
|
|
210
265
|
margin-top: 32px;
|
|
211
266
|
}
|
|
@@ -16,7 +16,11 @@ query-параметрах `filter_*`. Формат провайдера при
|
|
|
16
16
|
<div class="view-webcams _page _container">
|
|
17
17
|
<header class="view-webcams__header">
|
|
18
18
|
<div class="view-webcams__intro">
|
|
19
|
-
<
|
|
19
|
+
<UiSectionTitle
|
|
20
|
+
:title="h1"
|
|
21
|
+
:count="data?.total"
|
|
22
|
+
icon="webcam"
|
|
23
|
+
/>
|
|
20
24
|
|
|
21
25
|
<UiSwitcher
|
|
22
26
|
:items="sections"
|
|
@@ -235,6 +239,7 @@ const sections = computed<ISwitcherItem[]>(() => [
|
|
|
235
239
|
name: 'webcams',
|
|
236
240
|
title: t('webcams.sections.webcams'),
|
|
237
241
|
icon: 'webcam',
|
|
242
|
+
live: true,
|
|
238
243
|
subtitle: props.data?.total ? t('webcams.online_short', { count: n(props.data.total, 'compact') }) : '',
|
|
239
244
|
},
|
|
240
245
|
]);
|
|
@@ -299,17 +304,11 @@ watch(() => JSON.stringify(toWebcamFilterPayload(route.query)), () => {
|
|
|
299
304
|
}
|
|
300
305
|
|
|
301
306
|
.view-webcams__intro {
|
|
302
|
-
display:
|
|
303
|
-
|
|
304
|
-
align-items: center;
|
|
305
|
-
column-gap: 20px;
|
|
307
|
+
display: grid;
|
|
308
|
+
justify-items: start;
|
|
306
309
|
row-gap: 16px;
|
|
307
310
|
}
|
|
308
311
|
|
|
309
|
-
.view-webcams__title {
|
|
310
|
-
margin-bottom: 0;
|
|
311
|
-
}
|
|
312
|
-
|
|
313
312
|
.view-webcams__options {
|
|
314
313
|
display: flex;
|
|
315
314
|
flex-wrap: wrap;
|
|
@@ -115,4 +115,17 @@ onBeforeUnmount(() => {
|
|
|
115
115
|
row-gap: 20px;
|
|
116
116
|
margin-bottom: 40px;
|
|
117
117
|
}
|
|
118
|
+
|
|
119
|
+
// Островок — только в теме, где у блока есть подложка; без неё блок как был, без отступов
|
|
120
|
+
@if $webcam-main-bg != transparent {
|
|
121
|
+
.webcam-main {
|
|
122
|
+
padding: 12px;
|
|
123
|
+
background-color: $webcam-main-bg;
|
|
124
|
+
border-radius: $radius-2xl;
|
|
125
|
+
|
|
126
|
+
@include from-br(sm) {
|
|
127
|
+
padding: 20px;
|
|
128
|
+
}
|
|
129
|
+
}
|
|
130
|
+
}
|
|
118
131
|
</style>
|
package/i18n/locales/de-DE.json
CHANGED
|
@@ -1,4 +1,21 @@
|
|
|
1
1
|
{
|
|
2
|
+
"webcams": {
|
|
3
|
+
"live_cams": "Live Cams",
|
|
4
|
+
"online_now": "{count} online",
|
|
5
|
+
"language_online": "{lang} — {count} online",
|
|
6
|
+
"more": "Mehr",
|
|
7
|
+
"more_online": {
|
|
8
|
+
"straight": "Mehr Online-Girls",
|
|
9
|
+
"gay": "Mehr Online-Guys",
|
|
10
|
+
"shemale": "Mehr Online-Transsexuelle"
|
|
11
|
+
},
|
|
12
|
+
"new_model": "Neues Model",
|
|
13
|
+
"goal": "Ziel:",
|
|
14
|
+
"speaks": "Spricht {languages}",
|
|
15
|
+
"years_old": "{age} J.",
|
|
16
|
+
"live_cams_gay": "Gay Live Cams",
|
|
17
|
+
"live_cams_shemale": "Shemale Live Cams"
|
|
18
|
+
},
|
|
2
19
|
"top_models": "Top-Models",
|
|
3
20
|
"additional_link": {
|
|
4
21
|
"2257": "2257",
|
package/i18n/locales/en-US.json
CHANGED
|
@@ -1,4 +1,43 @@
|
|
|
1
1
|
{
|
|
2
|
+
"webcams": {
|
|
3
|
+
"live_cams": "Live Cams",
|
|
4
|
+
"live_cams_gay": "Gay Live Cams",
|
|
5
|
+
"live_cams_shemale": "Shemale Live Cams",
|
|
6
|
+
"online_now": "{count} online now",
|
|
7
|
+
"language_online": "{lang} speaking - {count} online now",
|
|
8
|
+
"more": "More",
|
|
9
|
+
"more_online": {
|
|
10
|
+
"straight": "More Online Girls",
|
|
11
|
+
"gay": "More Online Guys",
|
|
12
|
+
"shemale": "More Online T-Girls"
|
|
13
|
+
},
|
|
14
|
+
"new_model": "New model",
|
|
15
|
+
"goal": "Goal:",
|
|
16
|
+
"speaks": "Speaks {languages}",
|
|
17
|
+
"speaking": "{lang} speaking",
|
|
18
|
+
"years_old": "{age} y.o.",
|
|
19
|
+
"online_only": "Online Only",
|
|
20
|
+
"online_short": "{count} online",
|
|
21
|
+
"sections": {
|
|
22
|
+
"videos": "Video",
|
|
23
|
+
"webcams": "Webcam"
|
|
24
|
+
},
|
|
25
|
+
"filters": {
|
|
26
|
+
"age": "Age",
|
|
27
|
+
"country": "Country",
|
|
28
|
+
"language": "Language",
|
|
29
|
+
"body_type": "Body Type",
|
|
30
|
+
"ethnicity": "Ethnicity",
|
|
31
|
+
"hair_color": "Hair Color",
|
|
32
|
+
"eye_color": "Eye Color",
|
|
33
|
+
"specifics": "Specifics",
|
|
34
|
+
"subculture": "Type"
|
|
35
|
+
},
|
|
36
|
+
"filter_groups": {
|
|
37
|
+
"appearance": "Appearance"
|
|
38
|
+
}
|
|
39
|
+
},
|
|
40
|
+
"recommended_to_you": "Recommended to you",
|
|
2
41
|
"also_known_as_male": "Also known as:",
|
|
3
42
|
"also_known_as_female": "Also known as:",
|
|
4
43
|
"model_about": "About",
|
package/i18n/locales/es-ES.json
CHANGED
|
@@ -1,4 +1,21 @@
|
|
|
1
1
|
{
|
|
2
|
+
"webcams": {
|
|
3
|
+
"live_cams": "Cams en directo",
|
|
4
|
+
"online_now": "{count} en línea",
|
|
5
|
+
"language_online": "{lang} — {count} en línea",
|
|
6
|
+
"more": "Más",
|
|
7
|
+
"more_online": {
|
|
8
|
+
"straight": "Más chicas en línea",
|
|
9
|
+
"gay": "Más chicos en línea",
|
|
10
|
+
"shemale": "Más trans en línea"
|
|
11
|
+
},
|
|
12
|
+
"new_model": "Modelo nueva",
|
|
13
|
+
"goal": "Meta:",
|
|
14
|
+
"speaks": "Habla {languages}",
|
|
15
|
+
"years_old": "{age} años",
|
|
16
|
+
"live_cams_gay": "Cams gay",
|
|
17
|
+
"live_cams_shemale": "Cams trans"
|
|
18
|
+
},
|
|
2
19
|
"top_models": "Top modelos",
|
|
3
20
|
"additional_link": {
|
|
4
21
|
"2257": "2257",
|
package/i18n/locales/fr-FR.json
CHANGED
|
@@ -1,4 +1,21 @@
|
|
|
1
1
|
{
|
|
2
|
+
"webcams": {
|
|
3
|
+
"live_cams": "Cams en direct",
|
|
4
|
+
"online_now": "{count} en ligne",
|
|
5
|
+
"language_online": "{lang} — {count} en ligne",
|
|
6
|
+
"more": "Plus",
|
|
7
|
+
"more_online": {
|
|
8
|
+
"straight": "Plus de filles en ligne",
|
|
9
|
+
"gay": "Plus de mecs en ligne",
|
|
10
|
+
"shemale": "Plus de trans en ligne"
|
|
11
|
+
},
|
|
12
|
+
"new_model": "Nouveau modèle",
|
|
13
|
+
"goal": "Objectif :",
|
|
14
|
+
"speaks": "Parle {languages}",
|
|
15
|
+
"years_old": "{age} ans",
|
|
16
|
+
"live_cams_gay": "Cams gay",
|
|
17
|
+
"live_cams_shemale": "Cams trans"
|
|
18
|
+
},
|
|
2
19
|
"top_models": "Top modèles",
|
|
3
20
|
"additional_link": {
|
|
4
21
|
"2257": "2257",
|
package/i18n/locales/it-IT.json
CHANGED
|
@@ -1,4 +1,21 @@
|
|
|
1
1
|
{
|
|
2
|
+
"webcams": {
|
|
3
|
+
"live_cams": "Cam in diretta",
|
|
4
|
+
"online_now": "{count} online",
|
|
5
|
+
"language_online": "{lang} — {count} online",
|
|
6
|
+
"more": "Altro",
|
|
7
|
+
"more_online": {
|
|
8
|
+
"straight": "Altre ragazze online",
|
|
9
|
+
"gay": "Altri ragazzi online",
|
|
10
|
+
"shemale": "Altre trans online"
|
|
11
|
+
},
|
|
12
|
+
"new_model": "Nuova modella",
|
|
13
|
+
"goal": "Obiettivo:",
|
|
14
|
+
"speaks": "Parla {languages}",
|
|
15
|
+
"years_old": "{age} anni",
|
|
16
|
+
"live_cams_gay": "Cam gay",
|
|
17
|
+
"live_cams_shemale": "Cam trans"
|
|
18
|
+
},
|
|
2
19
|
"top_models": "Top modelli",
|
|
3
20
|
"additional_link": {
|
|
4
21
|
"2257": "2257",
|
package/i18n/locales/ja-JP.json
CHANGED
|
@@ -1,4 +1,21 @@
|
|
|
1
1
|
{
|
|
2
|
+
"webcams": {
|
|
3
|
+
"live_cams": "ライブチャット",
|
|
4
|
+
"online_now": "{count}人がオンライン",
|
|
5
|
+
"language_online": "{lang} — {count}人オンライン",
|
|
6
|
+
"more": "もっと見る",
|
|
7
|
+
"more_online": {
|
|
8
|
+
"straight": "オンラインの女性をもっと見る",
|
|
9
|
+
"gay": "オンラインの男性をもっと見る",
|
|
10
|
+
"shemale": "オンラインのニューハーフをもっと見る"
|
|
11
|
+
},
|
|
12
|
+
"new_model": "新人モデル",
|
|
13
|
+
"goal": "目標:",
|
|
14
|
+
"speaks": "{languages}を話します",
|
|
15
|
+
"years_old": "{age}歳",
|
|
16
|
+
"live_cams_gay": "ゲイのライブチャット",
|
|
17
|
+
"live_cams_shemale": "ニューハーフのライブチャット"
|
|
18
|
+
},
|
|
2
19
|
"top_models": "人気モデル",
|
|
3
20
|
"additional_link": {
|
|
4
21
|
"2257": "2257",
|
package/i18n/locales/ko-KR.json
CHANGED
|
@@ -1,4 +1,21 @@
|
|
|
1
1
|
{
|
|
2
|
+
"webcams": {
|
|
3
|
+
"live_cams": "라이브 캠",
|
|
4
|
+
"online_now": "{count}명 접속 중",
|
|
5
|
+
"language_online": "{lang} — {count}명 접속",
|
|
6
|
+
"more": "더 보기",
|
|
7
|
+
"more_online": {
|
|
8
|
+
"straight": "접속 중인 여성 더 보기",
|
|
9
|
+
"gay": "접속 중인 남성 더 보기",
|
|
10
|
+
"shemale": "접속 중인 트랜스 더 보기"
|
|
11
|
+
},
|
|
12
|
+
"new_model": "신규 모델",
|
|
13
|
+
"goal": "목표:",
|
|
14
|
+
"speaks": "{languages} 사용",
|
|
15
|
+
"years_old": "{age}세",
|
|
16
|
+
"live_cams_gay": "게이 라이브 캠",
|
|
17
|
+
"live_cams_shemale": "쉬메일 라이브 캠"
|
|
18
|
+
},
|
|
2
19
|
"top_models": "인기 모델",
|
|
3
20
|
"additional_link": {
|
|
4
21
|
"2257": "2257",
|
package/i18n/locales/ru-RU.json
CHANGED
|
@@ -1,4 +1,42 @@
|
|
|
1
1
|
{
|
|
2
|
+
"webcams": {
|
|
3
|
+
"live_cams": "Трансляции",
|
|
4
|
+
"online_now": "{count} онлайн",
|
|
5
|
+
"language_online": "{lang} — {count} онлайн",
|
|
6
|
+
"more": "Ещё",
|
|
7
|
+
"more_online": {
|
|
8
|
+
"straight": "Больше девушек",
|
|
9
|
+
"gay": "Больше парней",
|
|
10
|
+
"shemale": "Больше трансов"
|
|
11
|
+
},
|
|
12
|
+
"new_model": "Новая модель",
|
|
13
|
+
"goal": "Цель:",
|
|
14
|
+
"speaks": "Говорит: {languages}",
|
|
15
|
+
"speaking": "Говорит: {lang}",
|
|
16
|
+
"years_old": "{age} лет",
|
|
17
|
+
"live_cams_gay": "Гей-трансляции",
|
|
18
|
+
"live_cams_shemale": "Шимейл-трансляции",
|
|
19
|
+
"online_only": "Только в эфире",
|
|
20
|
+
"online_short": "{count} в эфире",
|
|
21
|
+
"sections": {
|
|
22
|
+
"videos": "Видео",
|
|
23
|
+
"webcams": "Вебкам"
|
|
24
|
+
},
|
|
25
|
+
"filters": {
|
|
26
|
+
"age": "Возраст",
|
|
27
|
+
"country": "Страна",
|
|
28
|
+
"language": "Язык",
|
|
29
|
+
"body_type": "Телосложение",
|
|
30
|
+
"ethnicity": "Внешний тип",
|
|
31
|
+
"hair_color": "Цвет волос",
|
|
32
|
+
"eye_color": "Цвет глаз",
|
|
33
|
+
"specifics": "Особенности",
|
|
34
|
+
"subculture": "Тип"
|
|
35
|
+
},
|
|
36
|
+
"filter_groups": {
|
|
37
|
+
"appearance": "Внешность"
|
|
38
|
+
}
|
|
39
|
+
},
|
|
2
40
|
"top_models": "Популярные модели",
|
|
3
41
|
"additional_link": {
|
|
4
42
|
"2257": "2257",
|
package/i18n/locales/zh-CN.json
CHANGED
|
@@ -1,4 +1,21 @@
|
|
|
1
1
|
{
|
|
2
|
+
"webcams": {
|
|
3
|
+
"live_cams": "真人视讯",
|
|
4
|
+
"online_now": "{count} 人在线",
|
|
5
|
+
"language_online": "{lang} — {count} 人在线",
|
|
6
|
+
"more": "更多",
|
|
7
|
+
"more_online": {
|
|
8
|
+
"straight": "更多在线女孩",
|
|
9
|
+
"gay": "更多在线男生",
|
|
10
|
+
"shemale": "更多在线人妖"
|
|
11
|
+
},
|
|
12
|
+
"new_model": "新模特",
|
|
13
|
+
"goal": "目标:",
|
|
14
|
+
"speaks": "会说 {languages}",
|
|
15
|
+
"years_old": "{age} 岁",
|
|
16
|
+
"live_cams_gay": "男同真人视讯",
|
|
17
|
+
"live_cams_shemale": "人妖真人视讯"
|
|
18
|
+
},
|
|
2
19
|
"top_models": "热门模特",
|
|
3
20
|
"additional_link": {
|
|
4
21
|
"2257": "2257",
|
package/package.json
CHANGED
|
@@ -8,11 +8,11 @@
|
|
|
8
8
|
<UiSectionTitle
|
|
9
9
|
:title="h1"
|
|
10
10
|
:count="categories?.length"
|
|
11
|
-
class="
|
|
11
|
+
class="_mb-16"
|
|
12
12
|
icon="grid"
|
|
13
13
|
/>
|
|
14
14
|
|
|
15
|
-
<div class="_nav-filter">
|
|
15
|
+
<div class="category-letter__nav _nav-filter">
|
|
16
16
|
<CategoryNav
|
|
17
17
|
v-if="categoriesGroupByLetter"
|
|
18
18
|
:items="categoriesGroupByLetter as IChipsItem[]"
|
|
@@ -92,6 +92,10 @@ const breadcrumbs = [
|
|
|
92
92
|
min-height: 30vh;
|
|
93
93
|
}
|
|
94
94
|
|
|
95
|
+
.category-letter__nav:has(.section-nav-links) {
|
|
96
|
+
margin-top: 8px;
|
|
97
|
+
}
|
|
98
|
+
|
|
95
99
|
.category-letter__title {
|
|
96
100
|
margin-bottom: 12px;
|
|
97
101
|
}
|
|
@@ -55,6 +55,16 @@ describe('normalizeUrl', () => {
|
|
|
55
55
|
expect(result.path).toBe('/big-tits-videos');
|
|
56
56
|
});
|
|
57
57
|
|
|
58
|
+
it('+ и %2B → дефисы, без двойного кодирования', () => {
|
|
59
|
+
expect(normalizeUrl({ path: '/categories/anal%2Bfisting', query: {} }).path).toBe('/categories/anal-fisting');
|
|
60
|
+
expect(normalizeUrl({ path: '/search/a%2Fb', query: {} }).path).toBe('/search/a%2Fb');
|
|
61
|
+
});
|
|
62
|
+
|
|
63
|
+
it('пагинация старого движка ?p=N → ?page=N', () => {
|
|
64
|
+
expect(normalizeUrl({ path: '/categories/anal', query: { p: '2' } }).query).toEqual({ page: '2' });
|
|
65
|
+
expect(normalizeUrl({ path: '/categories/anal', query: { p: '1' } }).query).toEqual({});
|
|
66
|
+
});
|
|
67
|
+
|
|
58
68
|
it('убирает двойные дефисы', () => {
|
|
59
69
|
const result = normalizeUrl({ path: '/big--tits', query: {} });
|
|
60
70
|
expect(result.path).toBe('/big-tits');
|
|
@@ -3,6 +3,12 @@ export function normalizeUrl(to: { path: string; query: Record<string, any> }) {
|
|
|
3
3
|
let path = to.path;
|
|
4
4
|
let query = { ...to.query };
|
|
5
5
|
|
|
6
|
+
// 0) Пагинация старого движка: `?p=N` → `?page=N` (ссылки и индекс старых сайтов)
|
|
7
|
+
if (query.p !== undefined && query.page === undefined) {
|
|
8
|
+
const { p, ...rest } = query;
|
|
9
|
+
query = { ...rest, page: p };
|
|
10
|
+
}
|
|
11
|
+
|
|
6
12
|
// 1) Удаляем page=1
|
|
7
13
|
if (query.page === '1') {
|
|
8
14
|
const { page, ...rest } = query;
|
|
@@ -48,6 +54,7 @@ export function normalizeUrl(to: { path: string; query: Record<string, any> }) {
|
|
|
48
54
|
// 3) Заменяем пробелы и %20 на дефисы
|
|
49
55
|
path = path
|
|
50
56
|
.replace(/%20/gi, '-') // encoded
|
|
57
|
+
.replace(/%2b/gi, '-') // `+` — decodeURI его не раскодирует
|
|
51
58
|
.replace(/ /g, '-') // raw
|
|
52
59
|
.replace(/\+/g, '-'); // some browsers encode spaces as +
|
|
53
60
|
|
|
@@ -62,7 +69,8 @@ export function normalizeUrl(to: { path: string; query: Record<string, any> }) {
|
|
|
62
69
|
// 6) Кодируем обратно — стабильная RFC-форма (uppercase hex), совпадающая
|
|
63
70
|
// с тем, что отдаёт vue-router в to.path, поэтому редирект не зациклится.
|
|
64
71
|
try {
|
|
65
|
-
|
|
72
|
+
// уцелевшие после decodeURI %XX (`%2F`, `%3F`…) не кодируем второй раз в `%252F`
|
|
73
|
+
path = encodeURI(path).replace(/%25([0-9a-f]{2})/gi, (_, hex) => `%${hex.toUpperCase()}`);
|
|
66
74
|
} catch {
|
|
67
75
|
// no-op
|
|
68
76
|
}
|
|
@@ -136,6 +136,7 @@ export class VideosApiService {
|
|
|
136
136
|
lang: Language,
|
|
137
137
|
id: string,
|
|
138
138
|
niche = NicheHelper.getCurrentNiche(),
|
|
139
|
+
limit = 20,
|
|
139
140
|
): Promise<string[]> {
|
|
140
141
|
return await ApiHelper.fetch(
|
|
141
142
|
`/videos/referer-phrases-by-id/${id}`,
|
|
@@ -147,6 +148,9 @@ export class VideosApiService {
|
|
|
147
148
|
'X-Cache-Time': '259200',
|
|
148
149
|
'Content-Type': 'application/json',
|
|
149
150
|
},
|
|
151
|
+
params: {
|
|
152
|
+
limit,
|
|
153
|
+
},
|
|
150
154
|
},
|
|
151
155
|
useRuntimeConfig(),
|
|
152
156
|
);
|
|
@@ -155,6 +159,7 @@ export class VideosApiService {
|
|
|
155
159
|
lang: Language,
|
|
156
160
|
id: string,
|
|
157
161
|
niche = NicheHelper.getCurrentNiche(),
|
|
162
|
+
limit = 20,
|
|
158
163
|
): Promise<string[]> {
|
|
159
164
|
return await ApiHelper.fetch(
|
|
160
165
|
`/videos/referer-phrases-by-url/${id}`,
|
|
@@ -166,6 +171,9 @@ export class VideosApiService {
|
|
|
166
171
|
'X-Cache-Time': '259200',
|
|
167
172
|
'Content-Type': 'application/json',
|
|
168
173
|
},
|
|
174
|
+
params: {
|
|
175
|
+
limit,
|
|
176
|
+
},
|
|
169
177
|
},
|
|
170
178
|
useRuntimeConfig(),
|
|
171
179
|
);
|
package/types/switcher-item.d.ts
CHANGED
|
@@ -8,6 +8,8 @@ export interface ISwitcherItem {
|
|
|
8
8
|
icon?: string;
|
|
9
9
|
/** Второстепенная подпись: количество, «30 online» и подобное. */
|
|
10
10
|
subtitle?: string;
|
|
11
|
+
/** Иконка «в эфире» (вебкамы): акцентная всегда, а не только у активной опции. */
|
|
12
|
+
live?: boolean;
|
|
11
13
|
/** Адрес перехода: с ним опция рисуется ссылкой, а не кнопкой выбора. */
|
|
12
14
|
to?: string;
|
|
13
15
|
}
|