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.
- package/assets/scss/base/_animations.scss +11 -10
- package/assets/scss/mixins/_font-sizes.scss +5 -2
- package/assets/scss/mixins/_video-card.scss +24 -1
- package/assets/scss/themes/dark/_theme.scss +7 -4
- package/assets/scss/themes/light/_theme.scss +3 -0
- package/assets/scss/vars/_tokens.scss +1 -1
- package/assets/scss/vars/_vars.scss +1 -0
- package/components/card/collections-more.vue +1 -1
- package/components/card/dropdown/index.vue +2 -2
- package/components/card/playlist.vue +7 -8
- package/components/card/video/actions.vue +2 -2
- package/components/card/video/index.vue +24 -12
- package/components/filter/popup.vue +1 -0
- package/components/grid/videos.vue +2 -15
- package/components/layout/burger.vue +1 -0
- package/components/layout/extra-links.vue +5 -2
- package/components/layout/logo.vue +5 -2
- package/components/library/feed.vue +4 -0
- package/components/section/collections.vue +2 -2
- package/components/section/swiper-cards.vue +3 -3
- package/components/ui/chips.vue +7 -2
- package/components/ui/dropdown.vue +17 -8
- package/components/ui/expand-row.vue +0 -4
- package/components/ui/popup.vue +29 -8
- package/components/ui/section-title.vue +6 -6
- package/composables/__tests__/use-overlay-stack.test.ts +110 -0
- package/composables/__tests__/use-video-collections.test.ts +75 -0
- package/composables/use-overlay-stack.ts +58 -0
- package/composables/use-video-collections.ts +27 -5
- package/package.json +1 -1
- package/pages/following.vue +4 -4
- package/pages/history.vue +3 -13
- package/pages/library/index.vue +6 -6
- package/pages/playlists/index.vue +5 -5
|
@@ -2,22 +2,23 @@
|
|
|
2
2
|
@use '../mixins' as *;
|
|
3
3
|
|
|
4
4
|
/**
|
|
5
|
-
Анимация открытия попапов, для анимации открытия попапа обернуть
|
|
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:
|
|
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
|
}
|
|
@@ -4,11 +4,34 @@
|
|
|
4
4
|
|
|
5
5
|
@mixin video-card {
|
|
6
6
|
display: grid;
|
|
7
|
-
row-gap:
|
|
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-
|
|
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-
|
|
204
|
-
$video-card-user-bg: $color-zinc-
|
|
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:
|
|
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;
|
|
@@ -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="
|
|
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="
|
|
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-
|
|
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:
|
|
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:
|
|
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:
|
|
411
|
-
margin-top:
|
|
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:
|
|
402
|
-
grid-template-columns:
|
|
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:
|
|
408
|
-
grid-template-columns:
|
|
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:
|
|
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 +
|
|
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:
|
|
609
|
+
gap: 8px;
|
|
598
610
|
}
|
|
599
611
|
|
|
600
612
|
.card-video__user {
|
|
601
613
|
@include font-xs;
|
|
602
614
|
|
|
603
|
-
width:
|
|
604
|
-
height:
|
|
615
|
+
width: 36px;
|
|
616
|
+
height: 36px;
|
|
605
617
|
display: grid;
|
|
606
618
|
place-content: center;
|
|
607
619
|
font-weight: 700;
|
|
@@ -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
|
-
|
|
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
|
-
|
|
89
|
-
grid-template-columns: repeat(4, 1fr);
|
|
90
|
-
}
|
|
77
|
+
position: relative;
|
|
91
78
|
}
|
|
92
79
|
|
|
93
80
|
.grid-videos__idle,
|
|
@@ -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
|
>
|
|
@@ -8,7 +8,7 @@
|
|
|
8
8
|
<UiSectionTitle
|
|
9
9
|
:title="t('collections_by')"
|
|
10
10
|
title-tag="h2"
|
|
11
|
-
|
|
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:
|
|
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
|
-
:
|
|
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
|
-
/**
|
|
74
|
-
|
|
73
|
+
/** Вариант заголовка — см. `UiSectionTitle` */
|
|
74
|
+
variant?: 'page' | 'section'
|
|
75
75
|
placeholderAvatar?: string
|
|
76
76
|
/** Карточки только с аватаром и названием, без счётчика роликов */
|
|
77
77
|
withoutCount?: boolean
|
package/components/ui/chips.vue
CHANGED
|
@@ -184,11 +184,16 @@ const name = computed(() => {
|
|
|
184
184
|
@include font-xs;
|
|
185
185
|
|
|
186
186
|
padding-block: 4px;
|
|
187
|
-
padding-inline:
|
|
187
|
+
padding-inline: 10px;
|
|
188
188
|
height: var(--chips-height-micro);
|
|
189
|
-
border-radius:
|
|
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
|
}
|
package/components/ui/popup.vue
CHANGED
|
@@ -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
|
-
|
|
355
|
-
|
|
356
|
-
|
|
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
|
-
/** `
|
|
42
|
-
|
|
41
|
+
/** `page` — страничный заголовок (18px, капс, 700); `section` — заголовок секции (18px, без капса, 600) */
|
|
42
|
+
variant?: 'page' | 'section'
|
|
43
43
|
}>(), {
|
|
44
44
|
titleTag: 'h1',
|
|
45
|
-
|
|
45
|
+
variant: 'page'
|
|
46
46
|
});
|
|
47
47
|
|
|
48
48
|
const titleClass = computed(() => [
|
|
49
49
|
'ui-section-title__title',
|
|
50
|
-
props.
|
|
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.--
|
|
63
|
-
@include font-
|
|
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
|
-
|
|
12
|
-
|
|
13
|
-
|
|
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) =>
|
|
20
|
-
|
|
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
package/pages/following.vue
CHANGED
|
@@ -17,7 +17,7 @@
|
|
|
17
17
|
:header-link-text="t('following.all_models')"
|
|
18
18
|
all-link="/library/models"
|
|
19
19
|
icon="user"
|
|
20
|
-
|
|
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
|
-
|
|
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
|
-
|
|
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
|
-
|
|
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
|
-
|
|
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
|
-
|
|
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
|
-
|
|
252
|
-
grid-template-columns: repeat(4, 1fr);
|
|
253
|
-
}
|
|
243
|
+
position: relative;
|
|
254
244
|
}
|
|
255
245
|
|
|
256
246
|
.ui-pagination.history__pagination {
|
package/pages/library/index.vue
CHANGED
|
@@ -25,14 +25,14 @@
|
|
|
25
25
|
:title="t('library.continue_watching')"
|
|
26
26
|
icon="history"
|
|
27
27
|
title-tag="h2"
|
|
28
|
-
|
|
28
|
+
variant="section"
|
|
29
29
|
/>
|
|
30
30
|
|
|
31
31
|
<UiLink
|
|
32
32
|
:to="generateLink('/history')"
|
|
33
|
-
theme="
|
|
33
|
+
theme="simple"
|
|
34
34
|
>
|
|
35
|
-
{{ t('library.
|
|
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
|
-
|
|
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
|
-
|
|
89
|
+
variant="section"
|
|
90
90
|
/>
|
|
91
91
|
|
|
92
92
|
<UiLink
|
|
93
93
|
:to="generateLink('/following')"
|
|
94
|
-
theme="
|
|
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="
|
|
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
|
-
|
|
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:
|
|
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:
|
|
135
|
+
margin-bottom: 16px;
|
|
136
136
|
}
|
|
137
137
|
|
|
138
138
|
.playlists__navigation {
|