itube-specs 0.0.1029 → 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,24 @@
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
+
24
+ ## 0.0.1030 — 2026-10-13
25
+
26
+ - **Всем сайтам с плеером:** поднять `itube-modern-player` до `^0.15.0` (`npm install itube-modern-player@^0.15.0`).
27
+ Слой теперь отдаёт постеру ролика `fallback` — jpeg из `thumbUrls.jpeg` (его сохраняет `cleanVideoData`), и плеер
28
+ 0.15 рисует webp-постер через `<picture>` с jpeg для браузеров без webp. Не поднять — ничего не сломается, поле
29
+ молча игнорируется, фолбэка просто не будет. Сайтам с форком `components/player/core.vue` (hdsex-remaster,
30
+ videosection-redesign) — добавить `fallback` в постер, образец — коммит videosection-redesign.
31
+
14
32
  ## 0.0.1029 — 2026-10-13
15
33
 
16
34
  - **Сайтам с рекламными ссылками над контентом на мобиле (videosection-redesign):** новая вторая навигация —
@@ -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};
@@ -1041,8 +1039,8 @@ $layout-second-nav-link-text: $color-white;
1041
1039
  $layout-second-nav-dot-bg: $color-green-400;
1042
1040
 
1043
1041
  // ─── Native card ───
1044
- $native-card-bg: $color-slate-800;
1045
- $native-card-primary: $color-gray-400;
1042
+ $native-card-bg: $color-white;
1043
+ $native-card-primary: $color-gray-900;
1046
1044
  $native-card-secondary: $color-gray-500;
1047
1045
 
1048
1046
  // ─── Adv: плашка «Реклама» поверх креатива ───
@@ -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 {
@@ -172,6 +172,8 @@ const currentSource = computed<VideoSource>(() => {
172
172
  const big = props.data.thumbUrls?.webp?.[ThumbSize.Big];
173
173
  const medium = props.data.thumbUrls?.webp?.[ThumbSize.Medium];
174
174
  const src = big || medium;
175
+ // jpeg — для браузеров без webp: плеер (0.15+) рисует <picture> с ним в <img>
176
+ const fallback = props.data.thumbUrls?.jpeg?.[ThumbSize.Big] || props.data.thumbUrls?.jpeg?.[ThumbSize.Medium];
175
177
 
176
178
  return {
177
179
  src: props.data.videoUrl,
@@ -181,6 +183,7 @@ const currentSource = computed<VideoSource>(() => {
181
183
  poster: src
182
184
  ? {
183
185
  src,
186
+ fallback,
184
187
  srcset: big && medium ? `${medium} 480w, ${big} 1280w` : undefined,
185
188
  sizes: big && medium ? '100vw' : undefined,
186
189
  width: 1280,
@@ -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.1029",
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');
@@ -19,7 +19,7 @@ describe('cleanVideoData', () => {
19
19
  channel: { name: 'Channel', avatar_url: 'https://img.com/ch.jpg' },
20
20
  url: '/video/1',
21
21
  preview_url: 'https://img.com/preview.mp4',
22
- thumb_urls: { webp: { '320x180': 'small' } },
22
+ thumb_urls: { webp: { '320x180': 'small' }, jpeg: { '320x180': 'small-jpeg' } },
23
23
  vtt_url: '/vtt',
24
24
  vtt_sprite_url: '/sprite',
25
25
  created: 1700000000,
@@ -32,7 +32,7 @@ describe('cleanVideoData', () => {
32
32
  const result = cleanVideoData(raw as any);
33
33
  expect(result.guid).toBe('v-1');
34
34
  expect(result.channelName).toBe('Channel');
35
- expect(result.thumbUrls).toEqual({ webp: { '320x180': 'small' } });
35
+ expect(result.thumbUrls).toEqual({ webp: { '320x180': 'small' }, jpeg: { '320x180': 'small-jpeg' } });
36
36
  expect(result.vttUrl).toBe('/vtt');
37
37
  expect(result.videoUrl).toBe('/stream');
38
38
  expect(result.isDeleted).toBe(false);
@@ -150,6 +150,11 @@ describe('cleanVideoData', () => {
150
150
  ]);
151
151
  });
152
152
 
153
+ it('тумбы без jpeg — без ключа jpeg', () => {
154
+ const result = cleanVideoData({ thumb_urls: { webp: { '320x180': 'small' } } } as any);
155
+ expect(result.thumbUrls).toEqual({ webp: { '320x180': 'small' } });
156
+ });
157
+
153
158
  it('пустые данные → дефолты', () => {
154
159
  const result = cleanVideoData({} as any);
155
160
  expect(result.guid).toBe('');
@@ -84,6 +84,7 @@ export const cleanVideoData = (data: IRawVideoData, theme?: string): IVideoData
84
84
  previewUrl: data.preview_url || '',
85
85
  thumbUrls: data.thumb_urls ? {
86
86
  webp: data.thumb_urls.webp,
87
+ ...(data.thumb_urls.jpeg ? { jpeg: data.thumb_urls.jpeg } : {}),
87
88
  } : undefined,
88
89
  vttUrl: data.vtt_url || '',
89
90
  vttSpriteUrl: data.vtt_sprite_url || '',
@@ -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
  }
@@ -1,6 +1,7 @@
1
1
  import type { ThumbSize } from '../runtime/constants/thumb-size';
2
2
 
3
- /** Очищенные тумбы — только webp */
3
+ /** Очищенные тумбы: webp; jpeg — только у ролика целиком, фолбэк постера плеера. */
4
4
  export interface IThumbUrls {
5
5
  webp: Partial<Record<ThumbSize, string>>;
6
+ jpeg?: Partial<Record<ThumbSize, string>>;
6
7
  }