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.
@@ -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 measureDetails() {
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
@@ -1,7 +1,7 @@
1
1
  {
2
2
  "name": "itube-specs",
3
3
  "type": "module",
4
- "version": "0.0.1047",
4
+ "version": "0.0.1049",
5
5
  "main": "./nuxt.config.ts",
6
6
  "types": "./types/index.d.ts",
7
7
  "scripts": {
@@ -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
+ }