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.
@@ -1,7 +1,10 @@
1
1
  <!-- Инфо-блок модели: портрет 3:4, псевдонимы, соцсети, шапка с действиями, рождение, описание, сводка-чипсы; «About» и группы параметров — по «Show more», на десктопе свёрнутые доливают колонку до низа портрета -->
2
2
  <template>
3
3
  <section class="info-model">
4
- <div class="info-model__aside">
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
- /** Если всё целиком выпирает ниже портрета не больше чем на столько — показываем всё без «Show more». */
255
- const FIT_TOLERANCE = 64;
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
- /** Строка грида под кнопкой (с её отступами): без «Show more» она достаётся деталям. */
262
- function getMoreRowHeight(box: HTMLElement) {
269
+ function getMoreRowHeight() {
263
270
  const more = moreRef.value;
264
- if (!more || !more.offsetParent) return 0;
265
271
 
266
- return more.getBoundingClientRect().bottom + Number.parseFloat(getComputedStyle(more).marginBottom)
267
- - box.getBoundingClientRect().bottom;
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
- * Небольшой хвост в пределах допуска не прячем, а вытягиваем блок под него через `min-height`.
291
+ * На десктопе свёрнутые детали доливают колонку до низа портрета. Место — от верха блока до низа
292
+ * `aside` за вычетом строки кнопки; «About» обрезается по числу влезающих строк, из остального
293
+ * прячем всё, что не влезло целиком, и заголовок без единой видимой строки под ним.
294
+ * Высота блока = видимый контент, остаток уходит в пустую строку грида под кнопкой — она идёт сразу
295
+ * за контентом. Ниже портрета блок не вытягивается никогда: секция не растёт после гидрации.
274
296
  */
275
- function measureDetails() {
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
- box.style.minHeight = '';
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 limit = box.clientHeight + 0.5;
290
- const bottoms = units.map(el => el.getBoundingClientRect().bottom - top);
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
- const fullLimit = limit + getMoreRowHeight(box);
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 1fr 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
@@ -1,7 +1,7 @@
1
1
  {
2
2
  "name": "itube-specs",
3
3
  "type": "module",
4
- "version": "0.0.1047",
4
+ "version": "0.0.1050",
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
+ }