itube-specs 0.0.911 → 0.0.913
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/themes/dark/_theme.scss +22 -18
- package/assets/scss/themes/light/_theme.scss +17 -13
- package/assets/scss/vars/_tokens.scss +1 -1
- package/assets/scss/vars/_vars.scss +1 -0
- package/components/auth/register.vue +0 -4
- 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 +9 -9
- package/components/filter/popup.vue +1 -0
- package/components/library/feed.vue +1 -14
- package/components/playlist/add.vue +123 -113
- package/components/playlist/edit.vue +133 -199
- package/components/playlist/input.vue +23 -133
- package/components/playlist/main.vue +21 -40
- package/components/playlist/visibility.vue +149 -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/confirm.vue +17 -5
- package/components/ui/dropdown.vue +17 -8
- package/components/ui/expand-row.vue +0 -4
- package/components/ui/popup.vue +33 -15
- package/components/ui/section-title.vue +6 -6
- package/components/ui/tooltip.vue +35 -14
- 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-playlist-edit.ts +13 -3
- package/composables/use-snackbar.ts +15 -1
- package/composables/use-user.ts +0 -4
- package/composables/use-video-collections.ts +27 -5
- package/package.json +1 -1
- package/pages/following.vue +4 -4
- package/pages/history.vue +1 -1
- package/pages/library/index.vue +4 -4
- package/pages/playlists/index.vue +7 -76
- package/runtime/constants/playlist-type.ts +5 -0
- package/components/playlist/private-toggle.vue +0 -20
- package/composables/use-favorites.ts +0 -20
- package/pages/favorites.vue +0 -31
|
@@ -17,7 +17,7 @@
|
|
|
17
17
|
<transition name="fade">
|
|
18
18
|
<div
|
|
19
19
|
v-if="isOpen"
|
|
20
|
-
:
|
|
20
|
+
:style="contentStyle"
|
|
21
21
|
class="ui-tooltip__content"
|
|
22
22
|
>
|
|
23
23
|
<slot/>
|
|
@@ -31,12 +31,15 @@ import { checkDeviceWidth } from '../../runtime';
|
|
|
31
31
|
import { onClickOutside } from '@vueuse/core';
|
|
32
32
|
import { CSS_BREAKPOINTS } from '../../config/css-breakpoints';
|
|
33
33
|
|
|
34
|
-
defineProps<{
|
|
34
|
+
const props = defineProps<{
|
|
35
35
|
right?: boolean
|
|
36
36
|
}>();
|
|
37
37
|
|
|
38
|
+
const OFFSET = 8;
|
|
39
|
+
|
|
38
40
|
const tooltipRef = ref<HTMLElement>();
|
|
39
41
|
const isOpen = ref(false);
|
|
42
|
+
const contentStyle = ref<Record<string, string>>({});
|
|
40
43
|
|
|
41
44
|
const breakpoints = CSS_BREAKPOINTS;
|
|
42
45
|
const { isMobile } = checkDeviceWidth(breakpoints);
|
|
@@ -47,9 +50,26 @@ onClickOutside(tooltipRef, () => {
|
|
|
47
50
|
}
|
|
48
51
|
});
|
|
49
52
|
|
|
53
|
+
/** Контент в position: fixed — иначе его режет overflow скроллящихся родителей (список в попапе). */
|
|
54
|
+
function open() {
|
|
55
|
+
const rect = tooltipRef.value?.getBoundingClientRect();
|
|
56
|
+
if (!rect) return;
|
|
57
|
+
|
|
58
|
+
// На мобиле привязка к триггеру уводит широкий тултип за правый край — прибиваем к вьюпорту.
|
|
59
|
+
if (isMobile.value) {
|
|
60
|
+
contentStyle.value = { top: `${rect.top - OFFSET}px`, left: `${OFFSET}px` };
|
|
61
|
+
} else {
|
|
62
|
+
contentStyle.value = props.right
|
|
63
|
+
? { top: `${rect.top - OFFSET}px`, left: `${rect.left}px` }
|
|
64
|
+
: { top: `${rect.top - OFFSET}px`, right: `${window.innerWidth - rect.right}px` };
|
|
65
|
+
}
|
|
66
|
+
|
|
67
|
+
isOpen.value = true;
|
|
68
|
+
}
|
|
69
|
+
|
|
50
70
|
function onEnter() {
|
|
51
71
|
if (!isMobile.value) {
|
|
52
|
-
|
|
72
|
+
open();
|
|
53
73
|
}
|
|
54
74
|
}
|
|
55
75
|
|
|
@@ -60,13 +80,20 @@ function onLeave() {
|
|
|
60
80
|
}
|
|
61
81
|
|
|
62
82
|
function onClick() {
|
|
63
|
-
if (isMobile.value)
|
|
64
|
-
|
|
83
|
+
if (!isMobile.value) return;
|
|
84
|
+
|
|
85
|
+
if (isOpen.value) {
|
|
86
|
+
isOpen.value = false;
|
|
87
|
+
} else {
|
|
88
|
+
open();
|
|
65
89
|
}
|
|
66
90
|
}
|
|
67
91
|
</script>
|
|
68
92
|
|
|
69
93
|
<style lang="scss">
|
|
94
|
+
/* Держим в паре с OFFSET в скрипте: отступ тултипа от края вьюпорта. */
|
|
95
|
+
$tooltip-viewport-gap: 8px;
|
|
96
|
+
|
|
70
97
|
.ui-tooltip {
|
|
71
98
|
position: relative;
|
|
72
99
|
display: inline-flex;
|
|
@@ -80,26 +107,20 @@ function onClick() {
|
|
|
80
107
|
}
|
|
81
108
|
|
|
82
109
|
.ui-tooltip__content {
|
|
83
|
-
position:
|
|
84
|
-
|
|
85
|
-
right: 0;
|
|
110
|
+
position: fixed;
|
|
111
|
+
transform: translateY(-100%);
|
|
86
112
|
z-index: $z-index-tooltip;
|
|
87
113
|
display: grid;
|
|
88
|
-
margin-bottom: 8px;
|
|
89
114
|
padding: 12px;
|
|
90
115
|
background-color: $tooltip-bg;
|
|
91
116
|
border-radius: $radius-lg;
|
|
92
117
|
color: $tooltip-text;
|
|
93
118
|
width: max-content;
|
|
119
|
+
max-width: calc(100vw - #{$tooltip-viewport-gap * 2});
|
|
94
120
|
pointer-events: none;
|
|
95
121
|
box-shadow: $tooltip-shadow;
|
|
96
122
|
}
|
|
97
123
|
|
|
98
|
-
.ui-tooltip__content.--right {
|
|
99
|
-
right: auto;
|
|
100
|
-
left: 0;
|
|
101
|
-
}
|
|
102
|
-
|
|
103
124
|
.fade-enter-active,
|
|
104
125
|
.fade-leave-active {
|
|
105
126
|
transition: opacity 100ms ease;
|
|
@@ -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
|
+
};
|
|
@@ -8,6 +8,8 @@ export const usePlaylistEdit = () => {
|
|
|
8
8
|
const isPlaylistEditOpen = useState('playlist-edit-open', () => false);
|
|
9
9
|
const isBackToEdit = useState('playlist-edit-back', () => false);
|
|
10
10
|
const currentStep = useState<PlaylistStep>('playlist-edit-step', () => PlaylistStep.Edit);
|
|
11
|
+
/** Возврат к попапу, из которого открыли редактирование. */
|
|
12
|
+
const onBack = useState<(() => void) | null>('playlist-edit-on-back', () => null);
|
|
11
13
|
|
|
12
14
|
const openPlaylistEdit = (step: PlaylistStep, back: boolean = false, playlist?: IPlaylistCard, videoCard?: IVideoCard) => {
|
|
13
15
|
selectedPlaylist.value = playlist;
|
|
@@ -17,13 +19,19 @@ export const usePlaylistEdit = () => {
|
|
|
17
19
|
deletedVideo.value = videoCard;
|
|
18
20
|
};
|
|
19
21
|
|
|
22
|
+
/** Закрывает попап целиком, минуя возврат на шаг редактирования. */
|
|
23
|
+
const closePlaylistEditAll = () => {
|
|
24
|
+
isPlaylistEditOpen.value = false;
|
|
25
|
+
isBackToEdit.value = false;
|
|
26
|
+
selectedPlaylist.value = undefined;
|
|
27
|
+
onBack.value = null;
|
|
28
|
+
};
|
|
29
|
+
|
|
20
30
|
const closePlaylistEdit = () => {
|
|
21
31
|
if (isBackToEdit.value) {
|
|
22
32
|
openPlaylistEdit(PlaylistStep.Edit, false, selectedPlaylist.value);
|
|
23
33
|
} else {
|
|
24
|
-
|
|
25
|
-
isBackToEdit.value = false;
|
|
26
|
-
selectedPlaylist.value = undefined;
|
|
34
|
+
closePlaylistEditAll();
|
|
27
35
|
}
|
|
28
36
|
};
|
|
29
37
|
|
|
@@ -32,7 +40,9 @@ export const usePlaylistEdit = () => {
|
|
|
32
40
|
currentStep,
|
|
33
41
|
openPlaylistEdit,
|
|
34
42
|
closePlaylistEdit,
|
|
43
|
+
closePlaylistEditAll,
|
|
35
44
|
selectedPlaylist,
|
|
45
|
+
onBack,
|
|
36
46
|
deletedVideo,
|
|
37
47
|
};
|
|
38
48
|
};
|
|
@@ -36,7 +36,19 @@ export const useSnackbar = () => {
|
|
|
36
36
|
const snackbarText = useState('snackbar-text', () => '');
|
|
37
37
|
const snackbarHighlight = useState('snackbar-highlight', () => '');
|
|
38
38
|
const snackbarActionText = useState('snackbar-action-text', () => '');
|
|
39
|
-
|
|
39
|
+
// Снекбар физически переезжает между открытым попапом и макетом: диалог лежит в top
|
|
40
|
+
// layer, поэтому снекбар из макета оказался бы под ним и под блюром. Счётчик, а не флаг —
|
|
41
|
+
// при закрытии вложенного попапа снекбар должен остаться в том, что ещё открыт.
|
|
42
|
+
const popupDepth = useState('snackbar-popup-depth', () => 0);
|
|
43
|
+
const isSnackBarInPopup = computed(() => popupDepth.value > 0);
|
|
44
|
+
|
|
45
|
+
function enterPopup() {
|
|
46
|
+
popupDepth.value++;
|
|
47
|
+
}
|
|
48
|
+
|
|
49
|
+
function leavePopup() {
|
|
50
|
+
popupDepth.value = Math.max(0, popupDepth.value - 1);
|
|
51
|
+
}
|
|
40
52
|
|
|
41
53
|
function clearTimer() {
|
|
42
54
|
if (dismissTimer) {
|
|
@@ -115,6 +127,8 @@ export const useSnackbar = () => {
|
|
|
115
127
|
snackbarHighlight,
|
|
116
128
|
snackbarActionText,
|
|
117
129
|
isSnackBarInPopup,
|
|
130
|
+
enterPopup,
|
|
131
|
+
leavePopup,
|
|
118
132
|
show,
|
|
119
133
|
showError,
|
|
120
134
|
setErrorState,
|
package/composables/use-user.ts
CHANGED
|
@@ -119,10 +119,6 @@ export const useUser = (apiService) => {
|
|
|
119
119
|
const profile = useState<IProfileData | null>('profileData');
|
|
120
120
|
profile.value = null;
|
|
121
121
|
|
|
122
|
-
const { favoritesId, favoritesFirstVideoId } = useFavorites();
|
|
123
|
-
favoritesId.value = null;
|
|
124
|
-
favoritesFirstVideoId.value = null;
|
|
125
|
-
|
|
126
122
|
const router = useRouter();
|
|
127
123
|
router.push(generateLink('/'));
|
|
128
124
|
};
|
|
@@ -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
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
33
|
theme="secondary"
|
|
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,7 +86,7 @@
|
|
|
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
|