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.
@@ -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-emerald-400;
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-emerald-400;
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
 
@@ -87,6 +87,7 @@ const { t } = useI18n();
87
87
  color: $categories-link-text;
88
88
  margin-bottom: 6px;
89
89
  padding-block: 4px;
90
+ width: max-content;
90
91
  }
91
92
 
92
93
  @include to-br(sm) {
@@ -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>
@@ -89,9 +89,6 @@ function handler() {
89
89
 
90
90
  .layout-footer-language__badge {
91
91
  @include locale-badge;
92
-
93
- background-color: $footer-language-badge-bg;
94
- color: $footer-language-badge-text;
95
92
  }
96
93
 
97
94
  .layout-footer-language__angle {
@@ -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>
@@ -197,7 +197,7 @@ useSetSeoOverride(() => ({ alonePage: props.total === 1, totalPages: length.valu
197
197
  <style lang="scss">
198
198
  .ui-pagination {
199
199
  display: grid;
200
- margin: auto auto 0;
200
+ margin-inline: auto;
201
201
  gap: 12px;
202
202
  width: 100%;
203
203
  align-items: center;
@@ -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
- <h1 class="view-webcams__title _title">{{ h1 }}</h1>
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: flex;
303
- flex-wrap: wrap;
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>
@@ -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",
@@ -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",
@@ -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",
@@ -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",
@@ -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",
@@ -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",
@@ -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",
@@ -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",
@@ -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
@@ -1,7 +1,7 @@
1
1
  {
2
2
  "name": "itube-specs",
3
3
  "type": "module",
4
- "version": "0.0.1031",
4
+ "version": "0.0.1036",
5
5
  "main": "./nuxt.config.ts",
6
6
  "types": "./types/index.d.ts",
7
7
  "scripts": {
@@ -8,11 +8,11 @@
8
8
  <UiSectionTitle
9
9
  :title="h1"
10
10
  :count="categories?.length"
11
- class="_title"
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
- path = encodeURI(path);
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
  );
@@ -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
  }