itube-specs 0.0.1047 → 0.0.1050
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/components/info/model.vue +74 -25
- package/composables/page/use-channel-page.ts +6 -0
- package/composables/page/use-model-page.ts +6 -0
- package/composables/use-legacy-slug-redirect.ts +29 -0
- package/package.json +1 -1
- package/pages/tag/[tag].vue +6 -0
- package/runtime/index.ts +1 -0
- package/runtime/utils/__tests__/legacy-slug.test.ts +15 -0
- package/runtime/utils/legacy-slug.ts +14 -0
|
@@ -1,7 +1,10 @@
|
|
|
1
1
|
<!-- Инфо-блок модели: портрет 3:4, псевдонимы, соцсети, шапка с действиями, рождение, описание, сводка-чипсы; «About» и группы параметров — по «Show more», на десктопе свёрнутые доливают колонку до низа портрета -->
|
|
2
2
|
<template>
|
|
3
3
|
<section class="info-model">
|
|
4
|
-
<div
|
|
4
|
+
<div
|
|
5
|
+
ref="asideRef"
|
|
6
|
+
class="info-model__aside"
|
|
7
|
+
>
|
|
5
8
|
<UiImg
|
|
6
9
|
:src="currentImage"
|
|
7
10
|
:avif-src="currentAvifSrc"
|
|
@@ -123,7 +126,7 @@
|
|
|
123
126
|
<div
|
|
124
127
|
v-if="hasMore"
|
|
125
128
|
ref="detailsRef"
|
|
126
|
-
:class="{'--collapsed': !isExpanded}"
|
|
129
|
+
:class="{'--collapsed': !isExpanded, '--pending': !isMeasured}"
|
|
127
130
|
class="info-model__details"
|
|
128
131
|
>
|
|
129
132
|
<div
|
|
@@ -149,7 +152,7 @@
|
|
|
149
152
|
v-if="hasMore"
|
|
150
153
|
ref="moreRef"
|
|
151
154
|
:aria-expanded="isExpanded"
|
|
152
|
-
:class="{'--fit': !hasCut && !isExpanded}"
|
|
155
|
+
:class="{'--fit': !hasCut && !isExpanded, '--pending': !isMeasured}"
|
|
153
156
|
class="info-model__more"
|
|
154
157
|
type="button"
|
|
155
158
|
@click="isExpanded = !isExpanded"
|
|
@@ -251,54 +254,88 @@ const hasMore = computed(() => groupedParameters.value.length > 0 || Boolean(pro
|
|
|
251
254
|
/** Единицы, которые прячутся только целиком: заголовки, абзацы, плашки. Порядок — документный. */
|
|
252
255
|
const FIT_SELECTOR = 'h3, .info-model__about-text, .info-grid__plain-text, .info-grid__item';
|
|
253
256
|
|
|
254
|
-
/**
|
|
255
|
-
const
|
|
257
|
+
/** Меньше строк «About» не показываем: одна строка с многоточием читается как поломка. */
|
|
258
|
+
const ABOUT_MIN_LINES = 2;
|
|
256
259
|
|
|
260
|
+
const asideRef = ref<HTMLElement>();
|
|
257
261
|
const detailsRef = ref<HTMLElement>();
|
|
258
262
|
const moreRef = ref<HTMLElement>();
|
|
259
263
|
const hasCut = ref(true);
|
|
264
|
+
const isMeasured = ref(false);
|
|
265
|
+
|
|
266
|
+
/** Строка грида под кнопкой (с её отступами). Запоминается, пока кнопка видна, — спрятанную не измерить. */
|
|
267
|
+
let moreRowHeight = 0;
|
|
260
268
|
|
|
261
|
-
|
|
262
|
-
function getMoreRowHeight(box: HTMLElement) {
|
|
269
|
+
function getMoreRowHeight() {
|
|
263
270
|
const more = moreRef.value;
|
|
264
|
-
if (!more || !more.offsetParent) return 0;
|
|
265
271
|
|
|
266
|
-
|
|
267
|
-
|
|
272
|
+
if (more?.offsetParent) {
|
|
273
|
+
const { marginTop, marginBottom } = getComputedStyle(more);
|
|
274
|
+
moreRowHeight = more.offsetHeight + Number.parseFloat(marginTop) + Number.parseFloat(marginBottom);
|
|
275
|
+
}
|
|
276
|
+
|
|
277
|
+
return moreRowHeight;
|
|
278
|
+
}
|
|
279
|
+
|
|
280
|
+
function getBottoms(units: HTMLElement[], top: number) {
|
|
281
|
+
return units.map(el => el.getBoundingClientRect().bottom - top);
|
|
282
|
+
}
|
|
283
|
+
|
|
284
|
+
/** До первого замера детали и кнопка на десктопе прозрачны (`--pending`): иначе после гидрации видно, как они перестраиваются. */
|
|
285
|
+
function measureDetails() {
|
|
286
|
+
fitDetails();
|
|
287
|
+
isMeasured.value = true;
|
|
268
288
|
}
|
|
269
289
|
|
|
270
290
|
/**
|
|
271
|
-
* На десктопе
|
|
272
|
-
*
|
|
273
|
-
*
|
|
291
|
+
* На десктопе свёрнутые детали доливают колонку до низа портрета. Место — от верха блока до низа
|
|
292
|
+
* `aside` за вычетом строки кнопки; «About» обрезается по числу влезающих строк, из остального
|
|
293
|
+
* прячем всё, что не влезло целиком, и заголовок без единой видимой строки под ним.
|
|
294
|
+
* Высота блока = видимый контент, остаток уходит в пустую строку грида под кнопкой — она идёт сразу
|
|
295
|
+
* за контентом. Ниже портрета блок не вытягивается никогда: секция не растёт после гидрации.
|
|
274
296
|
*/
|
|
275
|
-
function
|
|
297
|
+
function fitDetails() {
|
|
276
298
|
const box = detailsRef.value;
|
|
277
299
|
if (!box) return;
|
|
278
300
|
|
|
279
301
|
const units = Array.from(box.querySelectorAll<HTMLElement>(FIT_SELECTOR));
|
|
280
|
-
|
|
302
|
+
const about = box.querySelector<HTMLElement>('.info-model__about-text');
|
|
303
|
+
box.style.height = '';
|
|
304
|
+
about?.style.removeProperty('--line-clamp');
|
|
281
305
|
|
|
282
|
-
if (isExpanded.value || !isChipsOverflowWidth()) {
|
|
306
|
+
if (isExpanded.value || !isChipsOverflowWidth() || !asideRef.value) {
|
|
283
307
|
units.forEach(el => el.removeAttribute('data-cut'));
|
|
284
308
|
hasCut.value = true;
|
|
285
309
|
return;
|
|
286
310
|
}
|
|
287
311
|
|
|
312
|
+
about?.style.setProperty('--line-clamp', '999');
|
|
313
|
+
|
|
288
314
|
const { top } = box.getBoundingClientRect();
|
|
289
|
-
const
|
|
290
|
-
const
|
|
315
|
+
const space = asideRef.value.getBoundingClientRect().bottom - top;
|
|
316
|
+
const moreRow = getMoreRowHeight();
|
|
317
|
+
let bottoms = getBottoms(units, top);
|
|
291
318
|
const contentHeight = Math.max(0, ...bottoms);
|
|
292
319
|
|
|
293
|
-
|
|
294
|
-
|
|
295
|
-
if (contentHeight <= fullLimit + FIT_TOLERANCE) {
|
|
296
|
-
if (contentHeight > fullLimit) box.style.minHeight = `${Math.ceil(contentHeight)}px`;
|
|
320
|
+
if (contentHeight <= space + 0.5) {
|
|
321
|
+
box.style.height = `${Math.ceil(contentHeight)}px`;
|
|
297
322
|
units.forEach(el => el.removeAttribute('data-cut'));
|
|
298
323
|
hasCut.value = false;
|
|
299
324
|
return;
|
|
300
325
|
}
|
|
301
326
|
|
|
327
|
+
const limit = space - moreRow + 0.5;
|
|
328
|
+
|
|
329
|
+
if (about) {
|
|
330
|
+
const lineHeight = Number.parseFloat(getComputedStyle(about).lineHeight);
|
|
331
|
+
const rect = about.getBoundingClientRect();
|
|
332
|
+
const fullLines = Math.round(rect.height / lineHeight);
|
|
333
|
+
const lines = Math.min(fullLines, Math.floor((limit - (rect.top - top)) / lineHeight));
|
|
334
|
+
|
|
335
|
+
about.style.setProperty('--line-clamp', String(Math.max(lines, ABOUT_MIN_LINES)));
|
|
336
|
+
bottoms = getBottoms(units, top);
|
|
337
|
+
}
|
|
338
|
+
|
|
302
339
|
const visible: boolean[] = [];
|
|
303
340
|
let isOver = false;
|
|
304
341
|
|
|
@@ -315,6 +352,9 @@ function measureDetails() {
|
|
|
315
352
|
|
|
316
353
|
units.forEach((el, index) => el.toggleAttribute('data-cut', !visible[index]));
|
|
317
354
|
hasCut.value = visible.includes(false);
|
|
355
|
+
|
|
356
|
+
const visibleHeight = Math.max(0, ...bottoms.filter((_, index) => visible[index]));
|
|
357
|
+
box.style.height = `${Math.ceil(visibleHeight)}px`;
|
|
318
358
|
}
|
|
319
359
|
|
|
320
360
|
useChipsOverflow(detailsRef, measureDetails);
|
|
@@ -384,14 +424,15 @@ const shareModelCard = computed<IModelCard>(() => ({
|
|
|
384
424
|
|
|
385
425
|
@include from-br(sm) {
|
|
386
426
|
grid-template-columns: 240px minmax(0, 1fr);
|
|
387
|
-
grid-template-rows: auto auto auto auto
|
|
427
|
+
grid-template-rows: auto auto auto auto auto auto 1fr;
|
|
388
428
|
grid-template-areas:
|
|
389
429
|
'aside header'
|
|
390
430
|
'aside born'
|
|
391
431
|
'aside description'
|
|
392
432
|
'aside summary'
|
|
393
433
|
'aside details'
|
|
394
|
-
'aside more'
|
|
434
|
+
'aside more'
|
|
435
|
+
'aside .';
|
|
395
436
|
}
|
|
396
437
|
}
|
|
397
438
|
|
|
@@ -612,6 +653,14 @@ const shareModelCard = computed<IModelCard>(() => ({
|
|
|
612
653
|
grid-area: details;
|
|
613
654
|
row-gap: 20px;
|
|
614
655
|
margin-top: 20px;
|
|
656
|
+
transition: var(--transition) opacity;
|
|
657
|
+
}
|
|
658
|
+
|
|
659
|
+
.info-model__details.--pending,
|
|
660
|
+
.info-model__more.--pending {
|
|
661
|
+
@include from-br(sm) {
|
|
662
|
+
opacity: 0;
|
|
663
|
+
}
|
|
615
664
|
}
|
|
616
665
|
|
|
617
666
|
.info-model__details.--collapsed {
|
|
@@ -664,7 +713,7 @@ const shareModelCard = computed<IModelCard>(() => ({
|
|
|
664
713
|
margin-bottom: -6px;
|
|
665
714
|
color: $info-model-more-text;
|
|
666
715
|
|
|
667
|
-
@include hover(color) {
|
|
716
|
+
@include hover(color, opacity) {
|
|
668
717
|
color: $info-model-more-text--hover;
|
|
669
718
|
}
|
|
670
719
|
}
|
|
@@ -1,5 +1,6 @@
|
|
|
1
1
|
import type { MaybeRefOrGetter, Ref } from 'vue';
|
|
2
2
|
import { convertString } from '../../runtime';
|
|
3
|
+
import type { Language } from '../../runtime';
|
|
3
4
|
import { sortItemsDefault } from '../../lib';
|
|
4
5
|
import { ChannelsApiService } from '../../services/api/channels.service';
|
|
5
6
|
import { VideosApiService } from '../../services/api/videos.service';
|
|
@@ -11,6 +12,9 @@ import type { IBreadcrumbItem, IChipsItem, IPopularTags } from '../../types';
|
|
|
11
12
|
* запрашивает после. Разметку страница держит свою, логику — `useChannelPage()`.
|
|
12
13
|
*/
|
|
13
14
|
export async function useChannelPageData() {
|
|
15
|
+
const lang = useLang() as Language;
|
|
16
|
+
const redirectLegacySlug = useLegacySlugRedirect('channel', name => ChannelsApiService.getChannelsByName(lang, name).then(Boolean));
|
|
17
|
+
|
|
14
18
|
const [
|
|
15
19
|
{ data: cardInfo, status },
|
|
16
20
|
{ data: videosData, status: videosStatus },
|
|
@@ -21,6 +25,8 @@ export async function useChannelPageData() {
|
|
|
21
25
|
useFetchPopularTagsByChannelName(VideosApiService.popularTagsByChannelName),
|
|
22
26
|
]);
|
|
23
27
|
|
|
28
|
+
if (!cardInfo.value) await redirectLegacySlug();
|
|
29
|
+
|
|
24
30
|
return { cardInfo, status, videosData, videosStatus, channelTagsData };
|
|
25
31
|
}
|
|
26
32
|
|
|
@@ -1,5 +1,6 @@
|
|
|
1
1
|
import type { MaybeRefOrGetter, Ref } from 'vue';
|
|
2
2
|
import { convertString } from '../../runtime';
|
|
3
|
+
import type { Language } from '../../runtime';
|
|
3
4
|
import { sortItemsDefault } from '../../lib';
|
|
4
5
|
import { ModelsApiService } from '../../services/api/models.service';
|
|
5
6
|
import { VideosApiService } from '../../services/api/videos.service';
|
|
@@ -10,6 +11,9 @@ import type { IBreadcrumbItem } from '../../types';
|
|
|
10
11
|
* с запросами одной страницы (похожие модели, теги, каналы). Разметку страница держит свою, логику — `useModelPage()`.
|
|
11
12
|
*/
|
|
12
13
|
export async function useModelPageData() {
|
|
14
|
+
const lang = useLang() as Language;
|
|
15
|
+
const redirectLegacySlug = useLegacySlugRedirect('slug', name => ModelsApiService.getModelsByName(lang, name).then(Boolean));
|
|
16
|
+
|
|
13
17
|
const [
|
|
14
18
|
{ data, status },
|
|
15
19
|
{ data: cardInfo },
|
|
@@ -18,6 +22,8 @@ export async function useModelPageData() {
|
|
|
18
22
|
useFetchModelByName(ModelsApiService.getModelsByName),
|
|
19
23
|
]);
|
|
20
24
|
|
|
25
|
+
if (!cardInfo.value) await redirectLegacySlug();
|
|
26
|
+
|
|
21
27
|
return { data, status, cardInfo };
|
|
22
28
|
}
|
|
23
29
|
|
|
@@ -0,0 +1,29 @@
|
|
|
1
|
+
import { legacySlugCandidate } from '../runtime';
|
|
2
|
+
|
|
3
|
+
/**
|
|
4
|
+
* Старые адреса с литеральным дефисом в имени (`/tag/anal-sex-free`, `/channels/bi-empire`): слаг редизайна читает
|
|
5
|
+
* дефис как пробел, и сущность «anal sex free» не находится. Создаётся до запросов страницы (нужен контекст Nuxt),
|
|
6
|
+
* возвращённая функция зовётся, когда основной запрос ничего не вернул: проверяет имя как в URL и, если сущность
|
|
7
|
+
* есть, уводит 301 на каноничный слаг (`anal_sex_free`).
|
|
8
|
+
*/
|
|
9
|
+
export function useLegacySlugRedirect(param: string, exists: (name: string) => Promise<boolean>) {
|
|
10
|
+
const route = useRoute();
|
|
11
|
+
const router = useRouter();
|
|
12
|
+
const nuxtApp = useNuxtApp();
|
|
13
|
+
const { generateLink } = useGenerateLink();
|
|
14
|
+
const raw = String(route.params[param] || '');
|
|
15
|
+
const slug = legacySlugCandidate(raw);
|
|
16
|
+
|
|
17
|
+
return async () => {
|
|
18
|
+
if (!slug) return;
|
|
19
|
+
|
|
20
|
+
// после await страницы контекста Nuxt нет — и запрос, и редирект запускаем в нём явно
|
|
21
|
+
const found = await nuxtApp.runWithContext(() => exists(raw)).catch(() => false);
|
|
22
|
+
if (!found) return;
|
|
23
|
+
|
|
24
|
+
// по имени роут резолвится без префикса ниши — его добавляет generateLink
|
|
25
|
+
const path = router.resolve({ name: String(route.name), params: { ...route.params, [param]: slug } }).path;
|
|
26
|
+
|
|
27
|
+
await nuxtApp.runWithContext(() => navigateTo({ path: generateLink(path), query: route.query }, { redirectCode: 301, replace: true }));
|
|
28
|
+
};
|
|
29
|
+
}
|
package/package.json
CHANGED
package/pages/tag/[tag].vue
CHANGED
|
@@ -22,6 +22,7 @@
|
|
|
22
22
|
<script setup lang="ts">
|
|
23
23
|
import type { IBreadcrumbItem } from '../../types';
|
|
24
24
|
import { convertString } from '../../runtime';
|
|
25
|
+
import type { Language } from '../../runtime';
|
|
25
26
|
import { VideosApiService } from '../../services/api/videos.service';
|
|
26
27
|
import { ModelsApiService } from '../../services/api/models.service';
|
|
27
28
|
import { sortItemsDefault } from '../../lib';
|
|
@@ -29,6 +30,9 @@ import { sortItemsDefault } from '../../lib';
|
|
|
29
30
|
const { t } = useI18n();
|
|
30
31
|
|
|
31
32
|
const pageName = useGetPureRouteName();
|
|
33
|
+
const lang = useLang() as Language;
|
|
34
|
+
const redirectLegacySlug = useLegacySlugRedirect('tag', name => VideosApiService.getListByNicheAndTag({ page: 1, 'per-page': 1 }, {}, name, lang, '-popularity')
|
|
35
|
+
.then(result => !!result?.items?.length));
|
|
32
36
|
|
|
33
37
|
const [
|
|
34
38
|
{ data, status },
|
|
@@ -40,6 +44,8 @@ const [
|
|
|
40
44
|
useRouteQueryCategories(),
|
|
41
45
|
]);
|
|
42
46
|
|
|
47
|
+
if (!data.value?.items?.length) await redirectLegacySlug();
|
|
48
|
+
|
|
43
49
|
useNoindexThinListing(() => data.value?.items?.length);
|
|
44
50
|
|
|
45
51
|
const route = useRoute();
|
package/runtime/index.ts
CHANGED
|
@@ -12,6 +12,7 @@ export * from './constants/webcams';
|
|
|
12
12
|
export * from './utils/aside-model-videos';
|
|
13
13
|
export * from './utils/build-srcset';
|
|
14
14
|
export * from './utils/pick-portrait-variant';
|
|
15
|
+
export * from './utils/legacy-slug';
|
|
15
16
|
export * from './utils/model-placeholder';
|
|
16
17
|
export * from './utils/format-date';
|
|
17
18
|
export * from './utils/format-time';
|
|
@@ -0,0 +1,15 @@
|
|
|
1
|
+
import { describe, it, expect } from 'vitest';
|
|
2
|
+
import { legacySlugCandidate } from '../legacy-slug';
|
|
3
|
+
|
|
4
|
+
describe('legacySlugCandidate', () => {
|
|
5
|
+
it('literal hyphen in the name → underscore slug', () => {
|
|
6
|
+
expect(legacySlugCandidate('anal-sex-free')).toBe('anal_sex_free');
|
|
7
|
+
expect(legacySlugCandidate('Bi-Empire')).toBe('bi_empire');
|
|
8
|
+
});
|
|
9
|
+
|
|
10
|
+
it('already canonical → null', () => {
|
|
11
|
+
expect(legacySlugCandidate('teen')).toBeNull();
|
|
12
|
+
expect(legacySlugCandidate('anal_sex_free')).toBeNull();
|
|
13
|
+
expect(legacySlugCandidate('')).toBeNull();
|
|
14
|
+
});
|
|
15
|
+
});
|
|
@@ -0,0 +1,14 @@
|
|
|
1
|
+
import { convertString } from './converters/convert-string';
|
|
2
|
+
|
|
3
|
+
/**
|
|
4
|
+
* Canonical slug for a legacy address whose name keeps a literal hyphen (`/tag/anal-sex-free`, `/channels/bi-empire`):
|
|
5
|
+
* the redesign reads `-` as a space, so the entity is looked up as "anal sex free" and missed. Returns the slug the
|
|
6
|
+
* same name gets from `toSlug` (`anal_sex_free`), or `null` when the address is already canonical.
|
|
7
|
+
*/
|
|
8
|
+
export function legacySlugCandidate(raw: string): string | null {
|
|
9
|
+
if (!raw.includes('-')) return null;
|
|
10
|
+
|
|
11
|
+
const slug = convertString().toSlug(raw);
|
|
12
|
+
|
|
13
|
+
return slug === raw ? null : slug;
|
|
14
|
+
}
|