itube-specs 0.0.1031 → 0.0.1034

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/SITE_UPDATES.md CHANGED
@@ -11,6 +11,16 @@
11
11
  коммиты до бампа); `npm run patch` сам проставит ей номер версии. Формат пункта: что сделать и где,
12
12
  кому именно (всем / сайтам с N), что будет, если не сделать.
13
13
 
14
+ ## 0.0.1033 — 2026-10-14
15
+
16
+ - **Сайтам со своей темой (hdsex-remaster):** в `_theme.scss` добавить `$switcher-icon--live: $color-emerald-400;` и
17
+ поменять `$switcher-icon--active` на `$color-white-70`. Иконка «в эфире» в переключателе разделов теперь зелёная
18
+ всегда (опция с `live: true`), а активная иконка — белая, как в макете страницы моделей. Не сделать — сборка упадёт
19
+ на неизвестном токене. Образец — коммит hdsex-remaster.
20
+ - **Сайтам с `FeatureWebcamsEnabled`:** ничего делать не надо — на странице моделей (`ViewModels`) заголовок встаёт
21
+ наверх с переключателем «Видео / Вебкам», как на странице вебкамов. Сайтам с форком `pages/models/index.vue` или
22
+ `components/view/models.vue` — сверить с апстримом.
23
+
14
24
  ## 0.0.1030 — 2026-10-13
15
25
 
16
26
  - **Всем сайтам с плеером:** поднять `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};
@@ -1190,7 +1188,8 @@ $switcher-text--hover: $color-white;
1190
1188
  $switcher-text--active: $color-white;
1191
1189
  $switcher-bg--active: $color-indigo-600;
1192
1190
  $switcher-icon: $color-zinc-500;
1193
- $switcher-icon--active: $color-emerald-400;
1191
+ $switcher-icon--active: $color-white-70;
1192
+ $switcher-icon--live: $color-emerald-400;
1194
1193
  $switcher-subtitle: $color-zinc-600;
1195
1194
  $switcher-subtitle--active: $color-white-60;
1196
1195
 
@@ -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};
@@ -1217,7 +1215,8 @@ $switcher-text--hover: $color-gray-950;
1217
1215
  $switcher-text--active: $color-white;
1218
1216
  $switcher-bg--active: $color-indigo-600;
1219
1217
  $switcher-icon: $color-gray-500;
1220
- $switcher-icon--active: $color-emerald-400;
1218
+ $switcher-icon--active: $color-white-70;
1219
+ $switcher-icon--live: $color-emerald-400;
1221
1220
  $switcher-subtitle: $color-gray-500;
1222
1221
  $switcher-subtitle--active: $color-white-60;
1223
1222
 
@@ -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
 
@@ -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 {
@@ -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,21 @@
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
+ <h1 class="view-models__heading _title">{{ h1 }}</h1>
12
+
13
+ <UiSwitcher
14
+ :items="sections"
15
+ :model-value="'videos'"
16
+ />
17
+ </div>
18
+
5
19
  <SectionSwiperCards
6
20
  v-if="topCards.length >= MINIMUM_TOP_THUMBS"
7
21
  :items="topCards"
@@ -10,6 +24,7 @@
10
24
  />
11
25
 
12
26
  <UiSectionTitle
27
+ v-if="!isWebcamsEnabled"
13
28
  :count="data?.total"
14
29
  :title="h1"
15
30
  class="view-models__title"
@@ -117,6 +132,7 @@ import { sortModels, alphabetItems } from '../../lib';
117
132
  import type {
118
133
  IModelCard,
119
134
  IModelFilter,
135
+ ISwitcherItem,
120
136
  PaginatedResponse,
121
137
  } from '../../types';
122
138
  import { convertStringArrayToChips, convertModelToRoundCard } from '../../runtime';
@@ -132,7 +148,35 @@ const props = defineProps<{
132
148
  letter?: string,
133
149
  }>();
134
150
 
135
- const { t } = useI18n();
151
+ const { t, n } = useI18n();
152
+ const { generateLink } = useGenerateLink();
153
+ const webcamsPath = useWebcamsPath();
154
+
155
+ const isWebcamsEnabled = !!useRuntimeConfig().public.featureFlags.FeatureWebcamsEnabled;
156
+
157
+ // онлайн — тем же ключом фетча, что у блока «Live Cams» (второго запроса нет); всего моделей — той же ручкой,
158
+ // что на странице вебкамов, чтобы счётчик в переключателе на обеих страницах совпадал. Без флага запросов нет.
159
+ const [{ counts: webcamCounts }, modelsTotal] = await Promise.all([
160
+ useFetchWebcams(),
161
+ isWebcamsEnabled ? useFetchModelsCount() : computed(() => 0),
162
+ ]);
163
+
164
+ const sections = computed<ISwitcherItem[]>(() => [
165
+ {
166
+ name: 'videos',
167
+ title: t('webcams.sections.videos'),
168
+ icon: 'user-group',
169
+ subtitle: modelsTotal.value ? n(modelsTotal.value) : '',
170
+ },
171
+ {
172
+ name: 'webcams',
173
+ title: t('webcams.sections.webcams'),
174
+ icon: 'webcam',
175
+ live: true,
176
+ subtitle: webcamCounts.value.total ? t('webcams.online_short', { count: n(webcamCounts.value.total, 'compact') }) : '',
177
+ to: generateLink(webcamsPath.value),
178
+ },
179
+ ]);
136
180
 
137
181
  const MINIMUM_TOP_VIDEOS_COUNT = 30;
138
182
  const MINIMUM_TOP_THUMBS = 3;
@@ -206,6 +250,19 @@ const topCards = computed(() => {
206
250
  }
207
251
  }
208
252
 
253
+ .view-models__intro {
254
+ display: flex;
255
+ flex-wrap: wrap;
256
+ align-items: center;
257
+ column-gap: 20px;
258
+ row-gap: 16px;
259
+ margin-bottom: 16px;
260
+ }
261
+
262
+ .view-models__heading {
263
+ margin-bottom: 0;
264
+ }
265
+
209
266
  .view-models__pagination {
210
267
  margin-top: 32px;
211
268
  }
@@ -235,6 +235,7 @@ const sections = computed<ISwitcherItem[]>(() => [
235
235
  name: 'webcams',
236
236
  title: t('webcams.sections.webcams'),
237
237
  icon: 'webcam',
238
+ live: true,
238
239
  subtitle: props.data?.total ? t('webcams.online_short', { count: n(props.data.total, 'compact') }) : '',
239
240
  },
240
241
  ]);
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.1034",
5
5
  "main": "./nuxt.config.ts",
6
6
  "types": "./types/index.d.ts",
7
7
  "scripts": {
@@ -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
  }
@@ -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
  }