itube-specs 0.0.1046 → 0.0.1049
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 +109 -11
- 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,4 +1,4 @@
|
|
|
1
|
-
<!-- Инфо-блок модели: портрет 3:4, псевдонимы, соцсети, шапка с действиями, рождение, описание, сводка-чипсы; «About» и группы параметров — по «Show more
|
|
1
|
+
<!-- Инфо-блок модели: портрет 3:4, псевдонимы, соцсети, шапка с действиями, рождение, описание, сводка-чипсы; «About» и группы параметров — по «Show more», на десктопе свёрнутые доливают колонку до низа портрета -->
|
|
2
2
|
<template>
|
|
3
3
|
<section class="info-model">
|
|
4
4
|
<div class="info-model__aside">
|
|
@@ -122,7 +122,8 @@
|
|
|
122
122
|
|
|
123
123
|
<div
|
|
124
124
|
v-if="hasMore"
|
|
125
|
-
|
|
125
|
+
ref="detailsRef"
|
|
126
|
+
:class="{'--collapsed': !isExpanded, '--pending': !isMeasured}"
|
|
126
127
|
class="info-model__details"
|
|
127
128
|
>
|
|
128
129
|
<div
|
|
@@ -146,7 +147,9 @@
|
|
|
146
147
|
|
|
147
148
|
<button
|
|
148
149
|
v-if="hasMore"
|
|
150
|
+
ref="moreRef"
|
|
149
151
|
:aria-expanded="isExpanded"
|
|
152
|
+
:class="{'--fit': !hasCut && !isExpanded, '--pending': !isMeasured}"
|
|
150
153
|
class="info-model__more"
|
|
151
154
|
type="button"
|
|
152
155
|
@click="isExpanded = !isExpanded"
|
|
@@ -245,6 +248,86 @@ const isExpanded = ref(false);
|
|
|
245
248
|
|
|
246
249
|
const hasMore = computed(() => groupedParameters.value.length > 0 || Boolean(props.card.fullDescription));
|
|
247
250
|
|
|
251
|
+
/** Единицы, которые прячутся только целиком: заголовки, абзацы, плашки. Порядок — документный. */
|
|
252
|
+
const FIT_SELECTOR = 'h3, .info-model__about-text, .info-grid__plain-text, .info-grid__item';
|
|
253
|
+
|
|
254
|
+
/** Если всё целиком выпирает ниже портрета не больше чем на столько — показываем всё без «Show more». */
|
|
255
|
+
const FIT_TOLERANCE = 64;
|
|
256
|
+
|
|
257
|
+
const detailsRef = ref<HTMLElement>();
|
|
258
|
+
const moreRef = ref<HTMLElement>();
|
|
259
|
+
const hasCut = ref(true);
|
|
260
|
+
const isMeasured = ref(false);
|
|
261
|
+
|
|
262
|
+
/** Строка грида под кнопкой (с её отступами): без «Show more» она достаётся деталям. */
|
|
263
|
+
function getMoreRowHeight(box: HTMLElement) {
|
|
264
|
+
const more = moreRef.value;
|
|
265
|
+
if (!more || !more.offsetParent) return 0;
|
|
266
|
+
|
|
267
|
+
return more.getBoundingClientRect().bottom + Number.parseFloat(getComputedStyle(more).marginBottom)
|
|
268
|
+
- box.getBoundingClientRect().bottom;
|
|
269
|
+
}
|
|
270
|
+
|
|
271
|
+
/** До первого замера детали и кнопка на десктопе прозрачны (`--pending`): иначе после гидрации видно, как они перестраиваются. */
|
|
272
|
+
function measureDetails() {
|
|
273
|
+
fitDetails();
|
|
274
|
+
isMeasured.value = true;
|
|
275
|
+
}
|
|
276
|
+
|
|
277
|
+
/**
|
|
278
|
+
* На десктопе высоту свёрнутых деталей задаёт грид — остаток колонки до низа портрета.
|
|
279
|
+
* Всё, что не влезло целиком, прячем; заголовок без единой видимой строки под ним — тоже.
|
|
280
|
+
* Небольшой хвост в пределах допуска не прячем, а вытягиваем блок под него через `min-height`.
|
|
281
|
+
*/
|
|
282
|
+
function fitDetails() {
|
|
283
|
+
const box = detailsRef.value;
|
|
284
|
+
if (!box) return;
|
|
285
|
+
|
|
286
|
+
const units = Array.from(box.querySelectorAll<HTMLElement>(FIT_SELECTOR));
|
|
287
|
+
box.style.minHeight = '';
|
|
288
|
+
|
|
289
|
+
if (isExpanded.value || !isChipsOverflowWidth()) {
|
|
290
|
+
units.forEach(el => el.removeAttribute('data-cut'));
|
|
291
|
+
hasCut.value = true;
|
|
292
|
+
return;
|
|
293
|
+
}
|
|
294
|
+
|
|
295
|
+
const { top } = box.getBoundingClientRect();
|
|
296
|
+
const limit = box.clientHeight + 0.5;
|
|
297
|
+
const bottoms = units.map(el => el.getBoundingClientRect().bottom - top);
|
|
298
|
+
const contentHeight = Math.max(0, ...bottoms);
|
|
299
|
+
|
|
300
|
+
const fullLimit = limit + getMoreRowHeight(box);
|
|
301
|
+
|
|
302
|
+
if (contentHeight <= fullLimit + FIT_TOLERANCE) {
|
|
303
|
+
if (contentHeight > fullLimit) box.style.minHeight = `${Math.ceil(contentHeight)}px`;
|
|
304
|
+
units.forEach(el => el.removeAttribute('data-cut'));
|
|
305
|
+
hasCut.value = false;
|
|
306
|
+
return;
|
|
307
|
+
}
|
|
308
|
+
|
|
309
|
+
const visible: boolean[] = [];
|
|
310
|
+
let isOver = false;
|
|
311
|
+
|
|
312
|
+
bottoms.forEach((bottom, index) => {
|
|
313
|
+
isOver ||= bottom > limit;
|
|
314
|
+
visible[index] = !isOver;
|
|
315
|
+
});
|
|
316
|
+
|
|
317
|
+
for (let index = units.length - 1; index >= 0; index--) {
|
|
318
|
+
if (units[index].tagName === 'H3' && !(visible[index + 1] && units[index + 1].tagName !== 'H3')) {
|
|
319
|
+
visible[index] = false;
|
|
320
|
+
}
|
|
321
|
+
}
|
|
322
|
+
|
|
323
|
+
units.forEach((el, index) => el.toggleAttribute('data-cut', !visible[index]));
|
|
324
|
+
hasCut.value = visible.includes(false);
|
|
325
|
+
}
|
|
326
|
+
|
|
327
|
+
useChipsOverflow(detailsRef, measureDetails);
|
|
328
|
+
|
|
329
|
+
watch([isExpanded, () => props.card], () => nextTick(measureDetails));
|
|
330
|
+
|
|
248
331
|
const isShareOpen = ref(false);
|
|
249
332
|
|
|
250
333
|
const { isAuthorized } = useUser(AuthorizationApiService);
|
|
@@ -308,15 +391,14 @@ const shareModelCard = computed<IModelCard>(() => ({
|
|
|
308
391
|
|
|
309
392
|
@include from-br(sm) {
|
|
310
393
|
grid-template-columns: 240px minmax(0, 1fr);
|
|
311
|
-
grid-template-rows: auto auto auto auto
|
|
394
|
+
grid-template-rows: auto auto auto auto 1fr auto;
|
|
312
395
|
grid-template-areas:
|
|
313
396
|
'aside header'
|
|
314
397
|
'aside born'
|
|
315
398
|
'aside description'
|
|
316
399
|
'aside summary'
|
|
317
400
|
'aside details'
|
|
318
|
-
'aside more'
|
|
319
|
-
'aside .';
|
|
401
|
+
'aside more';
|
|
320
402
|
}
|
|
321
403
|
}
|
|
322
404
|
|
|
@@ -537,20 +619,30 @@ const shareModelCard = computed<IModelCard>(() => ({
|
|
|
537
619
|
grid-area: details;
|
|
538
620
|
row-gap: 20px;
|
|
539
621
|
margin-top: 20px;
|
|
622
|
+
transition: var(--transition) opacity;
|
|
623
|
+
}
|
|
624
|
+
|
|
625
|
+
.info-model__details.--pending,
|
|
626
|
+
.info-model__more.--pending {
|
|
627
|
+
@include from-br(sm) {
|
|
628
|
+
opacity: 0;
|
|
629
|
+
}
|
|
540
630
|
}
|
|
541
631
|
|
|
542
632
|
.info-model__details.--collapsed {
|
|
543
633
|
@include to-br(sm) {
|
|
544
634
|
display: none;
|
|
545
635
|
}
|
|
546
|
-
}
|
|
547
636
|
|
|
548
|
-
|
|
549
|
-
|
|
637
|
+
@include from-br(sm) {
|
|
638
|
+
align-content: start;
|
|
639
|
+
overflow: clip;
|
|
640
|
+
contain: size;
|
|
641
|
+
}
|
|
550
642
|
}
|
|
551
643
|
|
|
552
|
-
.info-model__details
|
|
553
|
-
|
|
644
|
+
.info-model__details [data-cut] {
|
|
645
|
+
visibility: hidden;
|
|
554
646
|
}
|
|
555
647
|
|
|
556
648
|
.info-model__about-title {
|
|
@@ -587,11 +679,17 @@ const shareModelCard = computed<IModelCard>(() => ({
|
|
|
587
679
|
margin-bottom: -6px;
|
|
588
680
|
color: $info-model-more-text;
|
|
589
681
|
|
|
590
|
-
@include hover(color) {
|
|
682
|
+
@include hover(color, opacity) {
|
|
591
683
|
color: $info-model-more-text--hover;
|
|
592
684
|
}
|
|
593
685
|
}
|
|
594
686
|
|
|
687
|
+
.info-model__more.--fit {
|
|
688
|
+
@include from-br(sm) {
|
|
689
|
+
display: none;
|
|
690
|
+
}
|
|
691
|
+
}
|
|
692
|
+
|
|
595
693
|
.info-model__more-icon {
|
|
596
694
|
transition: var(--transition) transform;
|
|
597
695
|
}
|
|
@@ -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
|
+
}
|