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 +18 -0
- package/assets/scss/themes/dark/_theme.scss +2 -3
- package/assets/scss/themes/light/_theme.scss +4 -5
- package/components/card/video/index.vue +1 -2
- package/components/layout/footer-language.vue +0 -3
- package/components/player/core.vue +3 -0
- package/components/ui/pagination.vue +1 -1
- package/components/ui/switcher.vue +5 -1
- package/components/view/models.vue +59 -2
- package/components/view/webcams.vue +1 -0
- package/package.json +1 -1
- package/runtime/utils/__tests__/normalize-url.test.ts +10 -0
- package/runtime/utils/cleaners/__tests__/clean-video-data.test.ts +7 -2
- package/runtime/utils/cleaners/clean-video-data.ts +1 -0
- package/runtime/utils/normalize-url.ts +9 -1
- package/types/switcher-item.d.ts +2 -0
- package/types/thumbs-urls.d.ts +2 -1
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-
|
|
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-
|
|
1045
|
-
$native-card-primary: $color-gray-
|
|
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-
|
|
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
|
|
|
@@ -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,
|
|
@@ -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
|
@@ -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
|
-
|
|
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
|
}
|
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
|
}
|
package/types/thumbs-urls.d.ts
CHANGED
|
@@ -1,6 +1,7 @@
|
|
|
1
1
|
import type { ThumbSize } from '../runtime/constants/thumb-size';
|
|
2
2
|
|
|
3
|
-
/** Очищенные
|
|
3
|
+
/** Очищенные тумбы: webp; jpeg — только у ролика целиком, фолбэк постера плеера. */
|
|
4
4
|
export interface IThumbUrls {
|
|
5
5
|
webp: Partial<Record<ThumbSize, string>>;
|
|
6
|
+
jpeg?: Partial<Record<ThumbSize, string>>;
|
|
6
7
|
}
|