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.
@@ -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
- :class="{'--collapsed': !isExpanded, '--about': card.fullDescription}"
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 auto auto 1fr;
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
- .info-model__details.--collapsed:not(.--about) {
549
- display: none;
637
+ @include from-br(sm) {
638
+ align-content: start;
639
+ overflow: clip;
640
+ contain: size;
641
+ }
550
642
  }
551
643
 
552
- .info-model__details.--collapsed .info-model__parameters {
553
- display: none;
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
@@ -1,7 +1,7 @@
1
1
  {
2
2
  "name": "itube-specs",
3
3
  "type": "module",
4
- "version": "0.0.1046",
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
+ }