itube-specs 0.0.1047 → 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 +19 -4
- 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
|
@@ -123,7 +123,7 @@
|
|
|
123
123
|
<div
|
|
124
124
|
v-if="hasMore"
|
|
125
125
|
ref="detailsRef"
|
|
126
|
-
:class="{'--collapsed': !isExpanded}"
|
|
126
|
+
:class="{'--collapsed': !isExpanded, '--pending': !isMeasured}"
|
|
127
127
|
class="info-model__details"
|
|
128
128
|
>
|
|
129
129
|
<div
|
|
@@ -149,7 +149,7 @@
|
|
|
149
149
|
v-if="hasMore"
|
|
150
150
|
ref="moreRef"
|
|
151
151
|
:aria-expanded="isExpanded"
|
|
152
|
-
:class="{'--fit': !hasCut && !isExpanded}"
|
|
152
|
+
:class="{'--fit': !hasCut && !isExpanded, '--pending': !isMeasured}"
|
|
153
153
|
class="info-model__more"
|
|
154
154
|
type="button"
|
|
155
155
|
@click="isExpanded = !isExpanded"
|
|
@@ -257,6 +257,7 @@ const FIT_TOLERANCE = 64;
|
|
|
257
257
|
const detailsRef = ref<HTMLElement>();
|
|
258
258
|
const moreRef = ref<HTMLElement>();
|
|
259
259
|
const hasCut = ref(true);
|
|
260
|
+
const isMeasured = ref(false);
|
|
260
261
|
|
|
261
262
|
/** Строка грида под кнопкой (с её отступами): без «Show more» она достаётся деталям. */
|
|
262
263
|
function getMoreRowHeight(box: HTMLElement) {
|
|
@@ -267,12 +268,18 @@ function getMoreRowHeight(box: HTMLElement) {
|
|
|
267
268
|
- box.getBoundingClientRect().bottom;
|
|
268
269
|
}
|
|
269
270
|
|
|
271
|
+
/** До первого замера детали и кнопка на десктопе прозрачны (`--pending`): иначе после гидрации видно, как они перестраиваются. */
|
|
272
|
+
function measureDetails() {
|
|
273
|
+
fitDetails();
|
|
274
|
+
isMeasured.value = true;
|
|
275
|
+
}
|
|
276
|
+
|
|
270
277
|
/**
|
|
271
278
|
* На десктопе высоту свёрнутых деталей задаёт грид — остаток колонки до низа портрета.
|
|
272
279
|
* Всё, что не влезло целиком, прячем; заголовок без единой видимой строки под ним — тоже.
|
|
273
280
|
* Небольшой хвост в пределах допуска не прячем, а вытягиваем блок под него через `min-height`.
|
|
274
281
|
*/
|
|
275
|
-
function
|
|
282
|
+
function fitDetails() {
|
|
276
283
|
const box = detailsRef.value;
|
|
277
284
|
if (!box) return;
|
|
278
285
|
|
|
@@ -612,6 +619,14 @@ const shareModelCard = computed<IModelCard>(() => ({
|
|
|
612
619
|
grid-area: details;
|
|
613
620
|
row-gap: 20px;
|
|
614
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
|
+
}
|
|
615
630
|
}
|
|
616
631
|
|
|
617
632
|
.info-model__details.--collapsed {
|
|
@@ -664,7 +679,7 @@ const shareModelCard = computed<IModelCard>(() => ({
|
|
|
664
679
|
margin-bottom: -6px;
|
|
665
680
|
color: $info-model-more-text;
|
|
666
681
|
|
|
667
|
-
@include hover(color) {
|
|
682
|
+
@include hover(color, opacity) {
|
|
668
683
|
color: $info-model-more-text--hover;
|
|
669
684
|
}
|
|
670
685
|
}
|
|
@@ -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
|
+
}
|