itube-specs 0.0.912 → 0.0.914

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.
Files changed (34) hide show
  1. package/assets/scss/base/_animations.scss +11 -10
  2. package/assets/scss/mixins/_font-sizes.scss +5 -2
  3. package/assets/scss/mixins/_video-card.scss +24 -1
  4. package/assets/scss/themes/dark/_theme.scss +7 -4
  5. package/assets/scss/themes/light/_theme.scss +3 -0
  6. package/assets/scss/vars/_tokens.scss +1 -1
  7. package/assets/scss/vars/_vars.scss +1 -0
  8. package/components/card/collections-more.vue +1 -1
  9. package/components/card/dropdown/index.vue +2 -2
  10. package/components/card/playlist.vue +7 -8
  11. package/components/card/video/actions.vue +2 -2
  12. package/components/card/video/index.vue +24 -12
  13. package/components/filter/popup.vue +1 -0
  14. package/components/grid/videos.vue +2 -15
  15. package/components/layout/burger.vue +1 -0
  16. package/components/layout/extra-links.vue +5 -2
  17. package/components/layout/logo.vue +5 -2
  18. package/components/library/feed.vue +4 -0
  19. package/components/section/collections.vue +2 -2
  20. package/components/section/swiper-cards.vue +3 -3
  21. package/components/ui/chips.vue +7 -2
  22. package/components/ui/dropdown.vue +17 -8
  23. package/components/ui/expand-row.vue +0 -4
  24. package/components/ui/popup.vue +29 -8
  25. package/components/ui/section-title.vue +6 -6
  26. package/composables/__tests__/use-overlay-stack.test.ts +110 -0
  27. package/composables/__tests__/use-video-collections.test.ts +75 -0
  28. package/composables/use-overlay-stack.ts +58 -0
  29. package/composables/use-video-collections.ts +27 -5
  30. package/package.json +1 -1
  31. package/pages/following.vue +4 -4
  32. package/pages/history.vue +3 -13
  33. package/pages/library/index.vue +6 -6
  34. package/pages/playlists/index.vue +5 -5
@@ -2,22 +2,23 @@
2
2
  @use '../mixins' as *;
3
3
 
4
4
  /**
5
- Анимация открытия попапов, для анимации открытия попапа обернуть SPopup в transition
5
+ Анимация открытия попапов, для анимации открытия попапа обернуть попап в transition.
6
+ Гейты `--no-enter-anim` / `--no-leave-anim` вешает useOverlayStack: появление
7
+ анимируется только у первого оверлея в стеке, закрытие — только у последнего.
8
+ Негации завёрнуты в `:where()`, чтобы не поднимать вес селектора — иначе они начинают
9
+ спорить с правилами компонентов (`.ui-snackbar.v-enter-from` и Ко).
6
10
  */
7
- .v-enter-active,
8
- .v-leave-active {
9
- transition: 100ms ease opacity;
11
+ .v-enter-active:where(:not(.--no-enter-anim)),
12
+ .v-leave-active:where(:not(.--no-leave-anim)) {
13
+ transition: 200ms linear opacity, 200ms linear transform;
10
14
 
11
15
  @include from-br(sm) {
12
16
  transition: 300ms linear opacity, 200ms linear transform;
13
17
  }
14
18
  }
15
19
 
16
- .v-enter-from,
17
- .v-leave-to {
20
+ .v-enter-from:where(:not(.--no-enter-anim)),
21
+ .v-leave-to:where(:not(.--no-leave-anim)) {
18
22
  opacity: 0;
19
-
20
- @include from-br(sm) {
21
- transform: scale(0.99) translateY(8px);
22
- }
23
+ transform: scale(0.99) translateY(8px);
23
24
  }
@@ -48,9 +48,12 @@
48
48
  line-height: 1.333;
49
49
  }
50
50
 
51
+ @mixin font-3xl {
52
+ font-size: 30px;
53
+ line-height: 1.2;
54
+ }
55
+
51
56
  @mixin font-4xl {
52
57
  font-size: 36px;
53
58
  line-height: 1.111;
54
59
  }
55
-
56
-
@@ -4,11 +4,34 @@
4
4
 
5
5
  @mixin video-card {
6
6
  display: grid;
7
- row-gap: 12px;
7
+ row-gap: 16px;
8
8
  width: 100%;
9
9
  align-content: start;
10
10
  }
11
11
 
12
+ /**
13
+ Сетка карточек видео: 2 колонки, 3 с xs, 4 с md.
14
+ minmax(0, 1fr) обязателен, не `1fr`: мета-строка карточки истории нешринкуема и задаёт
15
+ колонке min-content, из-за которого грид вылезает за контейнер на телефоне.
16
+ */
17
+ @mixin video-card-grid {
18
+ display: grid;
19
+ gap: 12px;
20
+ grid-template-columns: repeat(2, minmax(0, 1fr));
21
+
22
+ @include from-br(xs) {
23
+ grid-template-columns: repeat(3, minmax(0, 1fr));
24
+ }
25
+
26
+ @include from-br(sm) {
27
+ gap: 24px;
28
+ }
29
+
30
+ @include from-br(md) {
31
+ grid-template-columns: repeat(4, minmax(0, 1fr));
32
+ }
33
+ }
34
+
12
35
  @mixin video-card-poster {
13
36
  position: relative;
14
37
  aspect-ratio: 16/9;
@@ -59,8 +59,10 @@ $chips-bg--active: $color-zinc-700;
59
59
  $chips-text: $color-zinc-400;
60
60
  $chips-text--hover: $color-white;
61
61
  $chips-text--active: $color-white;
62
- $chips-micro-bg: $color-zinc-800;
63
- $chips-micro-text: $color-zinc-400;
62
+ $chips-micro-bg: $color-zinc-800-80;
63
+ $chips-micro-bg--hover: $color-zinc-700;
64
+ $chips-micro-text: $color-zinc-300;
65
+ $chips-micro-text--hover: $color-white;
64
66
  $chips-small-bg: $color-zinc-800-50;
65
67
  $chips-small-bg--hover: $color-zinc-700;
66
68
  $chips-small-text: $color-zinc-400;
@@ -200,8 +202,8 @@ $video-card-dropdown-icon--hover: $color-white;
200
202
  $video-card-secondary: $color-zinc-500;
201
203
  $video-card-mini-title: $color-zinc-300;
202
204
  $video-card-mini-weight: 400;
203
- $video-card-user-text: $color-zinc-400;
204
- $video-card-user-bg: $color-zinc-700;
205
+ $video-card-user-text: $color-zinc-500;
206
+ $video-card-user-bg: $color-zinc-800;
205
207
  $video-card-progress-bg: $mono-white-50;
206
208
  $video-card-progress-fill: $color-indigo-500;
207
209
  $video-card-remaining: $color-indigo-400;
@@ -698,6 +700,7 @@ $filter-popup--active: $color-zinc-400;
698
700
  $filter-popup-active-bg: $color-zinc-800;
699
701
  $filter-popup-quick-title: $color-zinc-400;
700
702
  $filter-popup-group-border: $color-zinc-800;
703
+ $filter-popup-summary-arrow: $color-zinc-400;
701
704
  $filter-popup-summary-description: $color-zinc-500;
702
705
  $filter-popup-active-chip-bg: $color-indigo-600;
703
706
  $filter-popup-active-chip-bg--hover: $color-indigo-500;
@@ -72,7 +72,9 @@ $chips-text: $color-gray-700;
72
72
  $chips-text--hover: $color-gray-900;
73
73
  $chips-text--active: $color-white;
74
74
  $chips-micro-bg: $color-gray-300;
75
+ $chips-micro-bg--hover: $color-gray-400;
75
76
  $chips-micro-text: $color-gray-700;
77
+ $chips-micro-text--hover: $color-gray-900;
76
78
  $chips-small-bg: $color-gray-300;
77
79
  $chips-small-bg--hover: $color-gray-400;
78
80
  $chips-small-text: $color-gray-700;
@@ -749,6 +751,7 @@ $filter-popup--active: $color-gray-400;
749
751
  $filter-popup-active-bg: transparent;
750
752
  $filter-popup-quick-title: $color-gray-400;
751
753
  $filter-popup-group-border: transparent;
754
+ $filter-popup-summary-arrow: $color-gray-400;
752
755
  $filter-popup-summary-description: $color-gray-500;
753
756
  $filter-popup-active-chip-bg: $color-indigo-600;
754
757
  $filter-popup-active-chip-bg--hover: $color-indigo-500;
@@ -21,7 +21,7 @@
21
21
  // ─── Project-specific ───
22
22
  --chips-height: 32px;
23
23
  --chips-height-small: 24px;
24
- --chips-height-micro: 20px;
24
+ --chips-height-micro: 24px;
25
25
  --transition: var(--default-transition-duration) var(--default-transition-timing-function);
26
26
  --header-height: 64px;
27
27
  --control-min-width: 126px;
@@ -3,6 +3,7 @@ $spacing: 4px;
3
3
  // ─── Border radius ───
4
4
  $radius: 10px;
5
5
  $radius-small: 4px;
6
+ $radius-6: 6px;
6
7
  $radius-md: 8px;
7
8
  $radius-lg: 10px;
8
9
  $radius-12: 12px;
@@ -118,7 +118,7 @@ const images = computed(() => {
118
118
  }
119
119
 
120
120
  .card-collections-more__count {
121
- @include font-2xl;
121
+ @include font-3xl;
122
122
 
123
123
  font-weight: 700;
124
124
  color: $collections-more-count;
@@ -31,7 +31,7 @@
31
31
  v-if="showWatchLater"
32
32
  :class="className"
33
33
  type="button"
34
- @click="toggleWatchLater(card.guid)"
34
+ @click="toggleWatchLater(card.guid, card.title)"
35
35
  >
36
36
  <UiIcon
37
37
  name="clock"
@@ -43,7 +43,7 @@
43
43
  v-if="showFavorites"
44
44
  :class="className"
45
45
  type="button"
46
- @click="toggleFavorite(card.guid)"
46
+ @click="toggleFavorite(card.guid, card.title)"
47
47
  >
48
48
  <UiIcon
49
49
  name="heart-stroke"
@@ -55,7 +55,7 @@
55
55
  >
56
56
  <UiIcon
57
57
  name="thumbs-up"
58
- size="12"
58
+ size="14"
59
59
  />
60
60
  {{ $n(card.likes, 'compact') }}
61
61
  </span>
@@ -73,7 +73,7 @@
73
73
  <p class="card-playlist__created">
74
74
  <UiIcon
75
75
  name="date"
76
- size="12"
76
+ size="14"
77
77
  />
78
78
  {{ formatDate(card.created) }}
79
79
  </p>
@@ -83,7 +83,7 @@
83
83
  v-if="avatar"
84
84
  :src="avatar"
85
85
  :alt="card.username"
86
- class="card-playlist__avatar _from-xs"
86
+ class="card-collections-more__count _from-xs"
87
87
  width="36"
88
88
  height="36"
89
89
  sizes="36px"
@@ -292,7 +292,7 @@ const avatarPlaceholder = computed(() => props.card.username.charAt(0));
292
292
  .card-playlist__wrapper {
293
293
  display: grid;
294
294
  align-items: center;
295
- padding: 16px;
295
+ padding: 12px;
296
296
  }
297
297
 
298
298
  .card-playlist__wrapper-header {
@@ -300,7 +300,7 @@ const avatarPlaceholder = computed(() => props.card.username.charAt(0));
300
300
  align-items: center;
301
301
  justify-content: space-between;
302
302
  column-gap: 24px;
303
- margin-bottom: 12px;
303
+ margin-bottom: 8px;
304
304
  }
305
305
 
306
306
  .card-playlist__wrapper.--top {
@@ -407,8 +407,8 @@ const avatarPlaceholder = computed(() => props.card.username.charAt(0));
407
407
  --line-clamp: 1;
408
408
 
409
409
  color: $playlist-card-tags;
410
- padding-top: 12px;
411
- margin-top: 12px;
410
+ padding-top: 8px;
411
+ margin-top: 8px;
412
412
  border-top: 1px solid $playlist-card-tags-border;
413
413
  }
414
414
 
@@ -456,7 +456,6 @@ const avatarPlaceholder = computed(() => props.card.username.charAt(0));
456
456
  grid-template-columns: auto 1fr;
457
457
  column-gap: 12px;
458
458
  align-items: start;
459
- margin-bottom: 12px;
460
459
  }
461
460
 
462
461
  .card-playlist__content-wrapper {
@@ -7,7 +7,7 @@
7
7
  class="card-video-actions__btn --active"
8
8
  type="button"
9
9
  aria-pressed="true"
10
- @click="toggleFavorite(card.guid)"
10
+ @click="toggleFavorite(card.guid, card.title)"
11
11
  >
12
12
  <UiIcon
13
13
  name="heart"
@@ -22,7 +22,7 @@
22
22
  :aria-pressed="later"
23
23
  :class="['card-video-actions__btn', '--watch-later', { '--active': later }]"
24
24
  type="button"
25
- @click="toggleWatchLater(card.guid)"
25
+ @click="toggleWatchLater(card.guid, card.title)"
26
26
  >
27
27
  <UiIcon
28
28
  :name="later ? 'clock-filled' : 'clock'"
@@ -388,7 +388,7 @@ $dropdown-column: 18px;
388
388
 
389
389
  .card-video__wrapper {
390
390
  display: grid;
391
- grid-template-columns: 1fr $dropdown-column;
391
+ grid-template-columns: minmax(0, 1fr) $dropdown-column;
392
392
  align-items: start;
393
393
  align-content: center;
394
394
  color: $video-card-secondary;
@@ -398,14 +398,14 @@ $dropdown-column: 18px;
398
398
  }
399
399
 
400
400
  @include from-cq($layout-breakpoint) {
401
- column-gap: 8px;
402
- grid-template-columns: 32px 1fr $dropdown-column;
401
+ column-gap: 12px;
402
+ grid-template-columns: 36px minmax(0, 1fr) $dropdown-column;
403
403
  }
404
404
  }
405
405
 
406
406
  .--avatar .card-video__wrapper {
407
- column-gap: 8px;
408
- grid-template-columns: 32px 1fr $dropdown-column;
407
+ column-gap: 12px;
408
+ grid-template-columns: 36px minmax(0, 1fr) $dropdown-column;
409
409
  }
410
410
 
411
411
  .--avatar .card-video__title {
@@ -434,7 +434,7 @@ $dropdown-column: 18px;
434
434
  }
435
435
 
436
436
  .--no-channel .card-video__wrapper {
437
- grid-template-columns: 1fr $dropdown-column;
437
+ grid-template-columns: minmax(0, 1fr) $dropdown-column;
438
438
  }
439
439
 
440
440
  .--no-channel .card-video__title {
@@ -452,7 +452,7 @@ $dropdown-column: 18px;
452
452
  }
453
453
 
454
454
  .--mini .card-video__wrapper {
455
- grid-template-columns: 1fr $dropdown-column;
455
+ grid-template-columns: minmax(0, 1fr) $dropdown-column;
456
456
  }
457
457
 
458
458
  /* Без строки канала и тегов вторая строка грида не нужна — иначе аватар и «троеточие» растягиваются. */
@@ -511,6 +511,7 @@ $dropdown-column: 18px;
511
511
  display: flex;
512
512
  align-items: center;
513
513
  grid-row: 2;
514
+ min-width: 0;
514
515
  margin: 6px 0 0;
515
516
  font-weight: 400;
516
517
  color: $video-card-secondary;
@@ -520,6 +521,14 @@ $dropdown-column: 18px;
520
521
  }
521
522
  }
522
523
 
524
+ /* На узкой карточке (две колонки на телефоне) «канал · время» в строку не встают, а нешрикуемое
525
+ время задаёт колонке min-content и разносит грид — оставляем только время просмотра */
526
+ @include to-cq($layout-breakpoint) {
527
+ .card-video__meta .card-video__channel-title {
528
+ display: none;
529
+ }
530
+ }
531
+
523
532
  .card-video__meta .card-video__channel-title + .card-video__watched::before {
524
533
  content: '·';
525
534
  margin-inline: 6px;
@@ -530,6 +539,9 @@ $dropdown-column: 18px;
530
539
  align-items: center;
531
540
  column-gap: 4px;
532
541
  flex-shrink: 0;
542
+ min-width: 0;
543
+ white-space: nowrap;
544
+ overflow: hidden;
533
545
  }
534
546
 
535
547
  .card-video__watched.--remaining {
@@ -585,23 +597,23 @@ $dropdown-column: 18px;
585
597
 
586
598
  @include from-cq($layout-breakpoint) {
587
599
  grid-column: 2;
588
- margin-top: 8px;
600
+ margin-top: 10px;
589
601
  }
590
602
  }
591
603
 
592
604
  .card-video__tags-wrapper {
593
- max-height: calc( var(--chips-height-micro) * 2 + 2px);
605
+ max-height: calc( var(--chips-height-micro) * 2 + 8px);
594
606
  overflow: hidden;
595
607
  display: flex;
596
608
  flex-wrap: wrap;
597
- gap: 2px;
609
+ gap: 8px;
598
610
  }
599
611
 
600
612
  .card-video__user {
601
613
  @include font-xs;
602
614
 
603
- width: 32px;
604
- height: 32px;
615
+ width: 36px;
616
+ height: 36px;
605
617
  display: grid;
606
618
  place-content: center;
607
619
  font-weight: 700;
@@ -288,6 +288,7 @@ const { t } = useI18n();
288
288
 
289
289
  .filter-popup__summary-arrow {
290
290
  flex-shrink: 0;
291
+ color: $filter-popup-summary-arrow;
291
292
  transition: transform var(--transition);
292
293
  }
293
294
 
@@ -72,22 +72,9 @@ const lazyItems = computed(() => props.items.slice(eagerCount.value + IDLE_COUNT
72
72
 
73
73
  <style lang="scss">
74
74
  .grid-videos {
75
- position: relative;
76
- display: grid;
77
- gap: 12px;
78
- grid-template-columns: repeat(2, 1fr);
79
-
80
- @include from-br(xs) {
81
- grid-template-columns: repeat(3, 1fr);
82
- }
83
-
84
- @include from-br(sm) {
85
- gap: 24px;
86
- }
75
+ @include video-card-grid;
87
76
 
88
- @include from-br(md) {
89
- grid-template-columns: repeat(4, 1fr);
90
- }
77
+ position: relative;
91
78
  }
92
79
 
93
80
  .grid-videos__idle,
@@ -260,6 +260,7 @@ router.afterEach(() => {
260
260
 
261
261
  .layout-burger__menu:last-child {
262
262
  padding-bottom: 12px;
263
+ margin-bottom: 4px;
263
264
  }
264
265
 
265
266
  .layout-burger__niche {
@@ -1,11 +1,14 @@
1
1
  <template>
2
2
  <nav class="layout-extra-links">
3
+ <!-- active-class указывает на базовый класс, а не на '': пустую строку NuxtLink считает
4
+ falsy и подставляет дефолты router-link-active, а они зависят от текущего роута и
5
+ ломают гидрацию лениво гидрируемого футера. Дубль класса Vue схлопывает. -->
3
6
  <NuxtLink
4
7
  v-for="(item, index) in additionalLinksItems"
5
8
  :key="`s-additional-link-${index}`"
6
9
  :to="generateLink(item.name)"
7
- active-class=""
8
- exact-active-class=""
10
+ active-class="layout-extra-links__link"
11
+ exact-active-class="layout-extra-links__link"
9
12
  class="layout-extra-links__link"
10
13
  @click="emit('close')"
11
14
  >{{ $t(`additional_link.${item.title}`) }}</NuxtLink>
@@ -1,9 +1,12 @@
1
1
  <template>
2
+ <!-- active-class указывает на базовый класс, а не на '': пустую строку NuxtLink считает
3
+ falsy и подставляет дефолты router-link-active, а они зависят от текущего роута и
4
+ ломают гидрацию лениво гидрируемого футера. Дубль класса Vue схлопывает. -->
2
5
  <NuxtLink
3
6
  :class="{'layout-logo--glow': glow}"
4
7
  :to="generateLink('/')"
5
- active-class=""
6
- exact-active-class=""
8
+ active-class="layout-logo"
9
+ exact-active-class="layout-logo"
7
10
  class="layout-logo"
8
11
  @click="onLogoClick"
9
12
  >
@@ -201,6 +201,10 @@ const ageText = computed(() => {
201
201
  row-gap: 12px;
202
202
  align-items: center;
203
203
  margin-bottom: 20px;
204
+
205
+ @include from-br(sm) {
206
+ align-items: start;
207
+ }
204
208
  }
205
209
 
206
210
  .library-feed__header.--without-avatar {
@@ -8,7 +8,7 @@
8
8
  <UiSectionTitle
9
9
  :title="t('collections_by')"
10
10
  title-tag="h2"
11
- size="xl"
11
+ variant="section"
12
12
  >
13
13
  <span class="section-collections__badge">{{ t('editorial') }}</span>
14
14
  </UiSectionTitle>
@@ -86,7 +86,7 @@ const restThumbs = computed(() => {
86
86
  align-items: center;
87
87
  justify-content: space-between;
88
88
  column-gap: 16px;
89
- margin-bottom: 24px;
89
+ margin-bottom: 16px;
90
90
  }
91
91
 
92
92
  .section-collections__badge {
@@ -6,7 +6,7 @@
6
6
  :title="title || t('top_models')"
7
7
  :count="count ?? items.length"
8
8
  :icon="icon"
9
- :size="size"
9
+ :variant="variant"
10
10
  class="section-swiper-cards__title"
11
11
  title-tag="h2"
12
12
  />
@@ -70,8 +70,8 @@ withDefaults(defineProps<{
70
70
  /** Счётчик в шапке; по умолчанию — число карточек */
71
71
  count?: string | number
72
72
  icon?: string
73
- /** Размер заголовка секции — см. `UiSectionTitle` */
74
- size?: 'lg' | 'xl'
73
+ /** Вариант заголовка — см. `UiSectionTitle` */
74
+ variant?: 'page' | 'section'
75
75
  placeholderAvatar?: string
76
76
  /** Карточки только с аватаром и названием, без счётчика роликов */
77
77
  withoutCount?: boolean
@@ -184,11 +184,16 @@ const name = computed(() => {
184
184
  @include font-xs;
185
185
 
186
186
  padding-block: 4px;
187
- padding-inline: 8px;
187
+ padding-inline: 10px;
188
188
  height: var(--chips-height-micro);
189
- border-radius: 4px;
189
+ border-radius: $radius-6;
190
190
  background-color: $chips-micro-bg;
191
191
  color: $chips-micro-text;
192
+
193
+ @include hover(background-color, color) {
194
+ background-color: $chips-micro-bg--hover;
195
+ color: $chips-micro-text--hover;
196
+ }
192
197
  }
193
198
 
194
199
  .ui-chips.--not-capitalize {
@@ -89,6 +89,8 @@ onClickOutside(dropdownRef, () => {
89
89
 
90
90
  const { scrollLock, scrollUnlock } = useGlobalScrollLock();
91
91
 
92
+ const { registerOverlay, releaseOverlay } = useOverlayStack();
93
+
92
94
  const breakpoints = CSS_BREAKPOINTS;
93
95
  const { isTablet } = checkDeviceWidth(breakpoints);
94
96
 
@@ -126,17 +128,22 @@ function close() {
126
128
  }
127
129
 
128
130
  watch(open, (val) => {
129
- if (!isTablet.value) return;
130
131
  if (val) {
132
+ if (!isTablet.value) return;
131
133
  scrollLock();
134
+ // На мобиле меню — модальный шит с анимацией, поэтому он тоже участник стека
135
+ // оверлеев: попап, открытый из него, не должен появляться с анимацией.
136
+ nextTick(() => registerOverlay(menuRef.value));
132
137
  } else {
133
- scrollUnlock();
138
+ if (isTablet.value) scrollUnlock();
139
+ releaseOverlay(menuRef.value);
134
140
  }
135
141
  });
136
142
 
137
143
  onBeforeUnmount(() => {
138
144
  if (open.value && isTablet.value) {
139
145
  scrollUnlock();
146
+ releaseOverlay(menuRef.value);
140
147
  }
141
148
  });
142
149
 
@@ -373,25 +380,27 @@ defineExpose({ openDropdown, open });
373
380
  }
374
381
  }
375
382
 
383
+ /* Гейты `--no-*-anim` вешает useOverlayStack: шит не анимируется, если он открывается
384
+ поверх попапа или закрывается, отдавая место другому оверлею. */
376
385
  @include to-br(md) {
377
- .ui-dropdown-enter-active,
378
- .ui-dropdown-leave-active {
386
+ .ui-dropdown__menu:where(:not(.--no-enter-anim)).ui-dropdown-enter-active,
387
+ .ui-dropdown__menu:where(:not(.--no-leave-anim)).ui-dropdown-leave-active {
379
388
  transition: transform 300ms ease;
380
389
  }
381
390
 
382
- .ui-dropdown-enter-from {
391
+ .ui-dropdown__menu:where(:not(.--no-enter-anim)).ui-dropdown-enter-from {
383
392
  transform: translateY(100%);
384
393
  }
385
394
 
386
- .ui-dropdown-enter-to {
395
+ .ui-dropdown__menu:where(:not(.--no-enter-anim)).ui-dropdown-enter-to {
387
396
  transform: translateY(0);
388
397
  }
389
398
 
390
- .ui-dropdown-leave-from {
399
+ .ui-dropdown__menu:where(:not(.--no-leave-anim)).ui-dropdown-leave-from {
391
400
  transform: translateY(0);
392
401
  }
393
402
 
394
- .ui-dropdown-leave-to {
403
+ .ui-dropdown__menu:where(:not(.--no-leave-anim)).ui-dropdown-leave-to {
395
404
  transform: translateY(100%);
396
405
  }
397
406
  }
@@ -219,10 +219,6 @@ onBeforeUnmount(() => {
219
219
  height: var(--chips-height-micro);
220
220
  }
221
221
 
222
- .ui-expand-row.--micro .ui-expand-row__wrapper {
223
- gap: 4px;
224
- }
225
-
226
222
  .ui-expand-row__button.--more {
227
223
  text-transform: capitalize;
228
224
  }
@@ -75,6 +75,8 @@ const { scrollLock, scrollUnlock } = useGlobalScrollLock();
75
75
 
76
76
  const { snackbarText, isSnackBarInPopup, enterPopup, leavePopup } = useSnackbar();
77
77
 
78
+ const { registerOverlay, releaseOverlay } = useOverlayStack();
79
+
78
80
  const popupRef = ref();
79
81
  const emit = defineEmits<{
80
82
  (eventName: 'update:modelValue', value: boolean): void
@@ -95,6 +97,7 @@ const props = defineProps<{
95
97
  onMounted(() => {
96
98
  enterPopup();
97
99
  scrollLock();
100
+ registerOverlay(popupRef.value);
98
101
  if (props.notModal) {
99
102
  popupRef.value.show();
100
103
  } else {
@@ -106,6 +109,7 @@ onMounted(() => {
106
109
  onBeforeUnmount(() => {
107
110
  leavePopup();
108
111
  scrollUnlock();
112
+ releaseOverlay(popupRef.value);
109
113
  });
110
114
 
111
115
  function close() {
@@ -351,14 +355,14 @@ function closeByBackdropClick(event: Event) {
351
355
  padding: 20px;
352
356
  }
353
357
 
354
- .ui-popup.--drawer {
355
- &.v-enter-active,
356
- &.v-leave-active {
358
+ /* Появление анимируется только у первого оверлея в стеке, закрытие — только у последнего
359
+ (см. useOverlayStack): иначе на переходе попап→попап диалоги едут навстречу друг другу. */
360
+ .ui-popup.--drawer:where(:not(.--no-enter-anim)) {
361
+ &.v-enter-active {
357
362
  transition: transform 300ms ease;
358
363
  }
359
364
 
360
- &.v-enter-active::backdrop,
361
- &.v-leave-active::backdrop {
365
+ &.v-enter-active::backdrop {
362
366
  transition: opacity 300ms ease;
363
367
  }
364
368
 
@@ -373,6 +377,16 @@ function closeByBackdropClick(event: Event) {
373
377
  &.v-enter-to {
374
378
  transform: translateX(0);
375
379
  }
380
+ }
381
+
382
+ .ui-popup.--drawer:where(:not(.--no-leave-anim)) {
383
+ &.v-leave-active {
384
+ transition: transform 300ms ease;
385
+ }
386
+
387
+ &.v-leave-active::backdrop {
388
+ transition: opacity 300ms ease;
389
+ }
376
390
 
377
391
  &.v-leave-from {
378
392
  transform: translateX(0);
@@ -387,10 +401,9 @@ function closeByBackdropClick(event: Event) {
387
401
  }
388
402
  }
389
403
 
390
- .ui-popup.--sheet {
404
+ .ui-popup.--sheet:where(:not(.--no-enter-anim)) {
391
405
  @include to-br(sm) {
392
- &.v-enter-active,
393
- &.v-leave-active {
406
+ &.v-enter-active {
394
407
  transition: transform 300ms ease;
395
408
  }
396
409
 
@@ -401,6 +414,14 @@ function closeByBackdropClick(event: Event) {
401
414
  &.v-enter-to {
402
415
  transform: translateY(0);
403
416
  }
417
+ }
418
+ }
419
+
420
+ .ui-popup.--sheet:where(:not(.--no-leave-anim)) {
421
+ @include to-br(sm) {
422
+ &.v-leave-active {
423
+ transition: transform 300ms ease;
424
+ }
404
425
 
405
426
  &.v-leave-from {
406
427
  transform: translateY(0);
@@ -38,16 +38,16 @@ const props = withDefaults(defineProps<{
38
38
  title: string
39
39
  icon?: string
40
40
  img?: string
41
- /** `lg` — страничный заголовок (18px, капс); `xl` — заголовок секции (20px, без капса) */
42
- size?: 'lg' | 'xl'
41
+ /** `page` — страничный заголовок (18px, капс, 700); `section` — заголовок секции (18px, без капса, 600) */
42
+ variant?: 'page' | 'section'
43
43
  }>(), {
44
44
  titleTag: 'h1',
45
- size: 'lg'
45
+ variant: 'page'
46
46
  });
47
47
 
48
48
  const titleClass = computed(() => [
49
49
  'ui-section-title__title',
50
- props.size === 'xl' ? '--xl' : '_title'
50
+ props.variant === 'section' ? '--section' : '_title'
51
51
  ]);
52
52
  </script>
53
53
 
@@ -59,8 +59,8 @@ const titleClass = computed(() => [
59
59
  vertical-align: middle;
60
60
  }
61
61
 
62
- .ui-section-title__title.--xl {
63
- @include font-xl;
62
+ .ui-section-title__title.--section {
63
+ @include font-lg;
64
64
 
65
65
  font-weight: 600;
66
66
  }
@@ -0,0 +1,110 @@
1
+ // @vitest-environment nuxt
2
+ import { describe, it, expect, beforeEach, afterEach, vi } from 'vitest';
3
+ import { useOverlayStack } from '../use-overlay-stack';
4
+
5
+ const NO_ENTER = '--no-enter-anim';
6
+ const NO_LEAVE = '--no-leave-anim';
7
+
8
+ let frames: Array<() => void> = [];
9
+
10
+ function flushFrames() {
11
+ const queued = frames;
12
+ frames = [];
13
+ queued.forEach((cb) => cb());
14
+ }
15
+
16
+ function createOverlay() {
17
+ const el = document.createElement('dialog');
18
+ document.body.appendChild(el);
19
+
20
+ return el;
21
+ }
22
+
23
+ describe('useOverlayStack', () => {
24
+ beforeEach(() => {
25
+ frames = [];
26
+ vi.stubGlobal('requestAnimationFrame', (cb: () => void) => {
27
+ frames.push(cb);
28
+
29
+ return 0;
30
+ });
31
+ });
32
+
33
+ afterEach(() => {
34
+ flushFrames();
35
+ document.body.innerHTML = '';
36
+ vi.unstubAllGlobals();
37
+ });
38
+
39
+ it('первый оверлей анимируется в обе стороны', () => {
40
+ const { registerOverlay } = useOverlayStack();
41
+ const el = createOverlay();
42
+
43
+ registerOverlay(el);
44
+
45
+ expect(el.classList.contains(NO_ENTER)).toBe(false);
46
+ expect(el.classList.contains(NO_LEAVE)).toBe(false);
47
+ });
48
+
49
+ it('второй оверлей открывается без анимации появления', () => {
50
+ const { registerOverlay } = useOverlayStack();
51
+ const first = createOverlay();
52
+ const second = createOverlay();
53
+
54
+ registerOverlay(first);
55
+ registerOverlay(second);
56
+
57
+ expect(second.classList.contains(NO_ENTER)).toBe(true);
58
+ });
59
+
60
+ it('пока оверлеев больше одного, закрытие не анимируется ни у одного', () => {
61
+ const { registerOverlay } = useOverlayStack();
62
+ const first = createOverlay();
63
+ const second = createOverlay();
64
+
65
+ registerOverlay(first);
66
+ registerOverlay(second);
67
+
68
+ expect(first.classList.contains(NO_LEAVE)).toBe(true);
69
+ expect(second.classList.contains(NO_LEAVE)).toBe(true);
70
+ });
71
+
72
+ it('уходящий оверлей остаётся в стеке до конца кадра — сменщик успевает открыться без анимации', () => {
73
+ const { registerOverlay, releaseOverlay } = useOverlayStack();
74
+ const closing = createOverlay();
75
+ const opening = createOverlay();
76
+
77
+ registerOverlay(closing);
78
+ releaseOverlay(closing);
79
+ registerOverlay(opening);
80
+
81
+ expect(opening.classList.contains(NO_ENTER)).toBe(true);
82
+ expect(closing.classList.contains(NO_LEAVE)).toBe(true);
83
+ });
84
+
85
+ it('оставшийся последним снова закрывается с анимацией', () => {
86
+ const { registerOverlay, releaseOverlay } = useOverlayStack();
87
+ const closing = createOverlay();
88
+ const opening = createOverlay();
89
+
90
+ registerOverlay(closing);
91
+ releaseOverlay(closing);
92
+ registerOverlay(opening);
93
+ flushFrames();
94
+
95
+ expect(opening.classList.contains(NO_LEAVE)).toBe(false);
96
+ });
97
+
98
+ it('оверлей, удалённый из DOM без релиза, не блокирует анимации следующему', () => {
99
+ const { registerOverlay } = useOverlayStack();
100
+ const leaked = createOverlay();
101
+ const next = createOverlay();
102
+
103
+ registerOverlay(leaked);
104
+ leaked.remove();
105
+ registerOverlay(next);
106
+
107
+ expect(next.classList.contains(NO_ENTER)).toBe(false);
108
+ expect(next.classList.contains(NO_LEAVE)).toBe(false);
109
+ });
110
+ });
@@ -0,0 +1,75 @@
1
+ // @vitest-environment nuxt
2
+ import { describe, it, expect, beforeEach, afterEach, vi } from 'vitest';
3
+ import { useVideoCollections } from '../use-video-collections';
4
+ import { useSnackbar } from '../use-snackbar';
5
+
6
+ describe('useVideoCollections', () => {
7
+ beforeEach(() => {
8
+ useNuxtApp().payload.state = {};
9
+ vi.useFakeTimers();
10
+ useSnackbar().resetSnackbar();
11
+ });
12
+
13
+ afterEach(() => {
14
+ vi.useRealTimers();
15
+ });
16
+
17
+ it('добавляет в «смотреть позже» и показывает снекбар с кнопкой отмены', () => {
18
+ const { watchLater, isWatchLater, toggleWatchLater } = useVideoCollections();
19
+ const { snackbarText, snackbarIcon, snackbarHighlight, snackbarActionText } = useSnackbar();
20
+
21
+ toggleWatchLater('guid-1', 'Video title');
22
+
23
+ expect(watchLater.value).toEqual([ 'guid-1' ]);
24
+ expect(isWatchLater('guid-1')).toBe(true);
25
+ expect(snackbarText.value).toBe('added');
26
+ expect(snackbarIcon.value).toBe('clock');
27
+ expect(snackbarHighlight.value).toBe('Video title');
28
+ expect(snackbarActionText.value).toBe('undo');
29
+ });
30
+
31
+ it('удаление показывает снекбар «removed»', () => {
32
+ const { watchLater, toggleWatchLater } = useVideoCollections();
33
+ const { snackbarText } = useSnackbar();
34
+
35
+ toggleWatchLater('guid-1', 'Video title');
36
+ toggleWatchLater('guid-1', 'Video title');
37
+
38
+ expect(watchLater.value).toEqual([]);
39
+ expect(snackbarText.value).toBe('removed');
40
+ });
41
+
42
+ it('избранное показывает снекбар с иконкой сердца', () => {
43
+ const { favorites, isFavorite, toggleFavorite } = useVideoCollections();
44
+ const { snackbarIcon, snackbarText } = useSnackbar();
45
+
46
+ toggleFavorite('guid-2', 'Another title');
47
+
48
+ expect(favorites.value).toEqual([ 'guid-2' ]);
49
+ expect(isFavorite('guid-2')).toBe(true);
50
+ expect(snackbarIcon.value).toBe('heart');
51
+ expect(snackbarText.value).toBe('added');
52
+ });
53
+
54
+ it('отмена возвращает список и не показывает новый снекбар', () => {
55
+ const { favorites, toggleFavorite } = useVideoCollections();
56
+ const { runSnackbarAction, snackbarText } = useSnackbar();
57
+
58
+ toggleFavorite('guid-2', 'Another title');
59
+ runSnackbarAction();
60
+
61
+ expect(favorites.value).toEqual([]);
62
+ expect(snackbarText.value).toBe('');
63
+ });
64
+
65
+ it('отмена удаления возвращает ролик обратно', () => {
66
+ const { watchLater, toggleWatchLater } = useVideoCollections();
67
+ const { runSnackbarAction } = useSnackbar();
68
+
69
+ toggleWatchLater('guid-3', 'Third title');
70
+ toggleWatchLater('guid-3', 'Third title');
71
+ runSnackbarAction();
72
+
73
+ expect(watchLater.value).toEqual([ 'guid-3' ]);
74
+ });
75
+ });
@@ -0,0 +1,58 @@
1
+ const NO_ENTER_ANIM_CLASS = '--no-enter-anim';
2
+ const NO_LEAVE_ANIM_CLASS = '--no-leave-anim';
3
+
4
+ // Элементы, а не счётчик: класс вешается прямо на DOM. Уходящий компонент управлять
5
+ // своим `:class` уже не может — Vue гасит его реактивный эффект в unmount, до конца
6
+ // leave-транзишена, поэтому ре-рендер по реактивному флагу не случится.
7
+ const overlays = new Set<HTMLElement>();
8
+
9
+ function syncLeaveAnimation() {
10
+ const chained = overlays.size > 1;
11
+ overlays.forEach((el) => el.classList.toggle(NO_LEAVE_ANIM_CLASS, chained));
12
+ }
13
+
14
+ /**
15
+ * Стек открытых оверлеев (попапы и мобильные шиты дропдауна). Нужен, чтобы анимация
16
+ * появления играла только у первого оверлея, а анимация закрытия — только у последнего:
17
+ * при переходе попап→попап уходящий и приходящий диалоги ехали бы навстречу друг другу
18
+ * и картинка скакала бы.
19
+ */
20
+ export const useOverlayStack = () => {
21
+ /**
22
+ * Решение по анимации появления принимается один раз и дальше не меняется: состав
23
+ * стека успеет измениться до того, как браузер прочитает transition уходящего.
24
+ */
25
+ function registerOverlay(el?: HTMLElement | null) {
26
+ if (!import.meta.client || !el) return;
27
+
28
+ overlays.forEach((item) => {
29
+ if (!item.isConnected) overlays.delete(item);
30
+ });
31
+
32
+ el.classList.toggle(NO_ENTER_ANIM_CLASS, overlays.size > 0);
33
+ overlays.add(el);
34
+ syncLeaveAnimation();
35
+ }
36
+
37
+ /**
38
+ * Выпуск отложен на кадр: следующий оверлей монтируется позже unmount-хука уходящего
39
+ * (а ленивый — ещё и через микротаск). Убрать элемент из стека сразу — уходящий не
40
+ * успеет узнать, что ему на смену открывают другой попап, и проиграет закрытие.
41
+ */
42
+ function releaseOverlay(el?: HTMLElement | null) {
43
+ if (!import.meta.client || !el) return;
44
+
45
+ const release = () => {
46
+ overlays.delete(el);
47
+ syncLeaveAnimation();
48
+ };
49
+
50
+ if (typeof requestAnimationFrame === 'function') {
51
+ requestAnimationFrame(release);
52
+ } else {
53
+ release();
54
+ }
55
+ }
56
+
57
+ return { registerOverlay, releaseOverlay };
58
+ };
@@ -7,17 +7,39 @@ export const useVideoCollections = () => {
7
7
  const favorites = useState<string[]>('video-collections-favorites', () => []);
8
8
  const watchLater = useState<string[]>('video-collections-watch-later', () => []);
9
9
 
10
+ const { show } = useSnackbar();
11
+
12
+ /** Возвращает true, если ролик добавлен, и false — если убран. */
10
13
  function toggle(list: Ref<string[]>, guid: string) {
11
- list.value = list.value.includes(guid)
12
- ? list.value.filter(item => item !== guid)
13
- : [ ...list.value, guid ];
14
+ const added = !list.value.includes(guid);
15
+
16
+ list.value = added
17
+ ? [ ...list.value, guid ]
18
+ : list.value.filter(item => item !== guid);
19
+
20
+ return added;
21
+ }
22
+
23
+ // Снекбар показывается только на исходное действие: откат возвращает список как был,
24
+ // повторно сообщать об этом нечего — иначе отмена сама выглядит как новое изменение.
25
+ function notify(added: boolean, title: string, icon: string, undo: () => void) {
26
+ show(added ? 'added' : 'removed', {
27
+ icon,
28
+ highlight: title,
29
+ onAction: undo,
30
+ });
14
31
  }
15
32
 
16
33
  const isFavorite = (guid: string) => favorites.value.includes(guid);
17
34
  const isWatchLater = (guid: string) => watchLater.value.includes(guid);
18
35
 
19
- const toggleFavorite = (guid: string) => toggle(favorites, guid);
20
- const toggleWatchLater = (guid: string) => toggle(watchLater, guid);
36
+ const toggleFavorite = (guid: string, title = '') => {
37
+ notify(toggle(favorites, guid), title, 'heart', () => toggle(favorites, guid));
38
+ };
39
+
40
+ const toggleWatchLater = (guid: string, title = '') => {
41
+ notify(toggle(watchLater, guid), title, 'clock', () => toggle(watchLater, guid));
42
+ };
21
43
 
22
44
  return {
23
45
  favorites,
package/package.json CHANGED
@@ -1,7 +1,7 @@
1
1
  {
2
2
  "name": "itube-specs",
3
3
  "type": "module",
4
- "version": "0.0.912",
4
+ "version": "0.0.914",
5
5
  "main": "./nuxt.config.ts",
6
6
  "types": "./types/index.d.ts",
7
7
  "scripts": {
@@ -17,7 +17,7 @@
17
17
  :header-link-text="t('following.all_models')"
18
18
  all-link="/library/models"
19
19
  icon="user"
20
- size="xl"
20
+ variant="section"
21
21
  placeholder-avatar="/img/model-placeholder.webp"
22
22
  class="following__section"
23
23
  without-count
@@ -32,7 +32,7 @@
32
32
  :header-link-text="t('following.all_channels')"
33
33
  all-link="/library/channels"
34
34
  icon="play-circle"
35
- size="xl"
35
+ variant="section"
36
36
  placeholder-avatar="/img/logo-placeholder.svg"
37
37
  class="following__section"
38
38
  without-count
@@ -45,7 +45,7 @@
45
45
  :count="t('following.updates', { count: categoriesUpdates })"
46
46
  icon="grid"
47
47
  title-tag="h2"
48
- size="xl"
48
+ variant="section"
49
49
  />
50
50
 
51
51
  <!-- На мобиле плитки «все» в гриде нет, поэтому ссылка вынесена в шапку -->
@@ -79,7 +79,7 @@
79
79
  :count="t('following.updates', { count: playlistsUpdates })"
80
80
  icon="playlist"
81
81
  title-tag="h2"
82
- size="xl"
82
+ variant="section"
83
83
  />
84
84
 
85
85
  <UiLink
package/pages/history.vue CHANGED
@@ -55,7 +55,7 @@
55
55
  class="history__group-header"
56
56
  icon="video-camera"
57
57
  title-tag="h2"
58
- size="xl"
58
+ variant="section"
59
59
  />
60
60
 
61
61
  <div class="history__grid">
@@ -238,19 +238,9 @@ const breadcrumbs = [
238
238
  }
239
239
 
240
240
  .history__grid {
241
- position: relative;
242
- display: grid;
243
- gap: 12px;
244
- grid-template-columns: repeat(2, 1fr);
245
-
246
- @include from-br(sm) {
247
- gap: 24px;
248
- grid-template-columns: repeat(3, 1fr);
249
- }
241
+ @include video-card-grid;
250
242
 
251
- @include from-br(md) {
252
- grid-template-columns: repeat(4, 1fr);
253
- }
243
+ position: relative;
254
244
  }
255
245
 
256
246
  .ui-pagination.history__pagination {
@@ -25,14 +25,14 @@
25
25
  :title="t('library.continue_watching')"
26
26
  icon="history"
27
27
  title-tag="h2"
28
- size="xl"
28
+ variant="section"
29
29
  />
30
30
 
31
31
  <UiLink
32
32
  :to="generateLink('/history')"
33
- theme="secondary"
33
+ theme="simple"
34
34
  >
35
- {{ t('library.view_all_history') }}
35
+ {{ t('library.history') }}
36
36
  <UiIcon
37
37
  name="arrow-right"
38
38
  size="16"
@@ -61,7 +61,7 @@
61
61
  :title="t('library.collections')"
62
62
  icon="layers"
63
63
  title-tag="h2"
64
- size="xl"
64
+ variant="section"
65
65
  />
66
66
  </header>
67
67
 
@@ -86,12 +86,12 @@
86
86
  :title="t('library.following')"
87
87
  icon="plus"
88
88
  title-tag="h2"
89
- size="xl"
89
+ variant="section"
90
90
  />
91
91
 
92
92
  <UiLink
93
93
  :to="generateLink('/following')"
94
- theme="secondary"
94
+ theme="simple"
95
95
  >
96
96
  {{ t('library.whats_new', { count: newTotal }) }}
97
97
  <UiIcon
@@ -13,7 +13,7 @@
13
13
  v-if="h1"
14
14
  :title="h1"
15
15
  :count="publicData.items.length"
16
- icon="list"
16
+ icon="playlist"
17
17
  />
18
18
 
19
19
  <UiLink
@@ -25,7 +25,7 @@
25
25
  name="playlist"
26
26
  size="16"
27
27
  />
28
- {{ $t('my_playlists') }}
28
+ {{ $t('my_playlists') }} (3)
29
29
  <UiIcon
30
30
  name="arrow-right"
31
31
  size="16"
@@ -39,7 +39,7 @@
39
39
  :title="$t('user_playlists')"
40
40
  class="playlists__title"
41
41
  title-tag="h2"
42
- size="xl"
42
+ variant="section"
43
43
  />
44
44
 
45
45
  <SectionNavLinks
@@ -117,7 +117,7 @@ const breadcrumbs = [
117
117
  flex-direction: column;
118
118
  align-items: flex-start;
119
119
  row-gap: 12px;
120
- margin-bottom: 24px;
120
+ margin-bottom: 32px;
121
121
 
122
122
  @include from-br(xm) {
123
123
  flex-direction: row;
@@ -132,7 +132,7 @@ const breadcrumbs = [
132
132
  }
133
133
 
134
134
  .playlists__title {
135
- margin-bottom: 24px;
135
+ margin-bottom: 16px;
136
136
  }
137
137
 
138
138
  .playlists__navigation {