itube-specs 0.0.915 → 0.0.917
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/icons/common/web-cam.svg +1 -0
- package/assets/scss/themes/dark/_theme.scss +12 -1
- package/assets/scss/themes/light/_theme.scss +11 -0
- package/components/card/playlist.vue +135 -59
- package/components/grid/playlists.vue +6 -0
- package/components/library/collection.vue +92 -0
- package/components/playlist/edit.vue +8 -10
- package/components/playlist/main.vue +30 -4
- package/composables/__tests__/use-playlist-delete.test.ts +38 -0
- package/composables/__tests__/use-playlist-follow.test.ts +54 -0
- package/composables/__tests__/use-snackbar.test.ts +47 -0
- package/composables/use-collection-stub.ts +16 -0
- package/composables/use-playlist-delete.ts +30 -0
- package/composables/use-playlist-follow.ts +42 -0
- package/composables/use-snackbar.ts +23 -0
- package/composables/use-user-playlists.ts +41 -0
- package/lib/index.ts +1 -0
- package/lib/scheme/navigation-items.ts +1 -1
- package/lib/scheme/sort-collections.ts +16 -0
- package/package.json +1 -1
- package/pages/following.vue +2 -2
- package/pages/library/favorites.vue +48 -0
- package/pages/library/index.vue +4 -4
- package/pages/library/my-playlists.vue +233 -0
- package/pages/library/playlists.vue +114 -0
- package/pages/library/watch-later.vue +48 -0
- package/pages/playlists/index.vue +5 -2
- package/stubs/collections.ts +81 -0
- package/stubs/library-follows.ts +8 -13
- package/composables/__tests__/use-playlist-collections.test.ts +0 -63
- package/composables/use-playlist-collections.ts +0 -39
|
@@ -0,0 +1 @@
|
|
|
1
|
+
<svg viewBox="0 0 24 24" fill="none" stroke="currentColor" stroke-width="2" stroke-linecap="round" stroke-linejoin="round"><circle cx="12" cy="9" r="6.5"></circle><circle cx="12" cy="9" r="2.5"></circle><path d="M12 15.5V19"></path><path d="M8 20h8"></path></svg>
|
|
@@ -42,7 +42,7 @@ $nav-border--active: $color-indigo-500-50;
|
|
|
42
42
|
$nav-border--hover: transparent;
|
|
43
43
|
$nav-border: $color-zinc-700-50;
|
|
44
44
|
$nav-icon: $color-zinc-400;
|
|
45
|
-
$nav-icon-accent: $color-
|
|
45
|
+
$nav-icon-accent: $color-emerald-400;
|
|
46
46
|
$nav-icon--active: $color-zinc-400;
|
|
47
47
|
$nav-container-bg: $color-black;
|
|
48
48
|
$nav-down-icon: $color-zinc-500;
|
|
@@ -867,6 +867,17 @@ $playlist-card-placeholder-text: $color-zinc-400;
|
|
|
867
867
|
$playlist-card-placeholder-bg: $color-zinc-700;
|
|
868
868
|
$playlist-card-dropdown-icon: $color-zinc-500;
|
|
869
869
|
$playlist-card-dropdown-icon--hover: $color-white;
|
|
870
|
+
$playlist-card-visibility-icon: $color-zinc-400;
|
|
871
|
+
$playlist-card-delete-text: $color-red-400;
|
|
872
|
+
|
|
873
|
+
// ─── My playlists empty state ───
|
|
874
|
+
$playlists-empty-border: $color-zinc-800;
|
|
875
|
+
$playlists-empty-title: $color-white;
|
|
876
|
+
$playlists-empty-text: $color-zinc-400;
|
|
877
|
+
$playlists-empty-chip-bg: $color-zinc-800;
|
|
878
|
+
$playlists-empty-chip-text: $color-zinc-300;
|
|
879
|
+
$playlists-empty-chip-icon: $color-zinc-400;
|
|
880
|
+
$playlists-empty-arrow: $color-zinc-600;
|
|
870
881
|
|
|
871
882
|
// ─── Collections section ───
|
|
872
883
|
$collections-badge-text: $color-indigo-300;
|
|
@@ -895,6 +895,17 @@ $playlist-card-placeholder-text: $color-gray-700;
|
|
|
895
895
|
$playlist-card-placeholder-bg: $color-gray-300;
|
|
896
896
|
$playlist-card-dropdown-icon: $color-gray-600;
|
|
897
897
|
$playlist-card-dropdown-icon--hover: $color-gray-900;
|
|
898
|
+
$playlist-card-visibility-icon: $color-gray-500;
|
|
899
|
+
$playlist-card-delete-text: $color-red-400;
|
|
900
|
+
|
|
901
|
+
// ─── My playlists empty state ───
|
|
902
|
+
$playlists-empty-border: $color-gray-300;
|
|
903
|
+
$playlists-empty-title: $color-gray-900;
|
|
904
|
+
$playlists-empty-text: $color-gray-600;
|
|
905
|
+
$playlists-empty-chip-bg: $color-gray-100;
|
|
906
|
+
$playlists-empty-chip-text: $color-gray-700;
|
|
907
|
+
$playlists-empty-chip-icon: $color-gray-500;
|
|
908
|
+
$playlists-empty-arrow: $color-gray-400;
|
|
898
909
|
|
|
899
910
|
// ─── Collections section ───
|
|
900
911
|
$collections-badge-text: $color-indigo-600;
|
|
@@ -33,19 +33,18 @@
|
|
|
33
33
|
size="16"
|
|
34
34
|
/>
|
|
35
35
|
</UiLabel>
|
|
36
|
-
|
|
37
|
-
<UiLabel
|
|
38
|
-
v-if="card.playlistType === 'private'"
|
|
39
|
-
:text="t('private')"
|
|
40
|
-
class="card-playlist__private"
|
|
41
|
-
theme="backdrop"
|
|
42
|
-
>
|
|
43
|
-
<UiIcon
|
|
44
|
-
name="lock"
|
|
45
|
-
size="16"
|
|
46
|
-
/>
|
|
47
|
-
</UiLabel>
|
|
48
36
|
</NuxtLink>
|
|
37
|
+
|
|
38
|
+
<!-- Кнопка живёт снаружи ссылки-обложки: button внутри a — невалидная разметка -->
|
|
39
|
+
<UiFollowBtn
|
|
40
|
+
v-if="showFollow"
|
|
41
|
+
:following="following"
|
|
42
|
+
:name="card.name"
|
|
43
|
+
class="card-playlist__follow"
|
|
44
|
+
size="s"
|
|
45
|
+
@toggle="toggle(card.id)"
|
|
46
|
+
/>
|
|
47
|
+
|
|
49
48
|
<div class="card-playlist__wrapper">
|
|
50
49
|
<div class="card-playlist__wrapper-header">
|
|
51
50
|
<p class="card-playlist__numbers">
|
|
@@ -75,7 +74,8 @@
|
|
|
75
74
|
name="date"
|
|
76
75
|
size="14"
|
|
77
76
|
/>
|
|
78
|
-
{{
|
|
77
|
+
<span class="card-playlist__updated">{{ t('library.updated') }}</span>
|
|
78
|
+
<span>{{ formatDate(card.created) }}</span>
|
|
79
79
|
</p>
|
|
80
80
|
</div>
|
|
81
81
|
<div class="card-playlist__content">
|
|
@@ -98,6 +98,17 @@
|
|
|
98
98
|
:to="generateLink(`/playlists/${card.id}/${card.firstVideoId}`)"
|
|
99
99
|
class="card-playlist__title"
|
|
100
100
|
>
|
|
101
|
+
<!-- У своих плейлистов приватность видна у названия: значок вместо плашки на обложке -->
|
|
102
|
+
<span
|
|
103
|
+
v-if="own"
|
|
104
|
+
:title="visibilityTitle"
|
|
105
|
+
class="card-playlist__visibility"
|
|
106
|
+
>
|
|
107
|
+
<UiIcon
|
|
108
|
+
:name="PLAYLIST_TYPE_ICON[card.playlistType]"
|
|
109
|
+
size="14"
|
|
110
|
+
/>
|
|
111
|
+
</span>
|
|
101
112
|
<span class="card-playlist__name">{{ card.name }}</span>
|
|
102
113
|
<span
|
|
103
114
|
v-if="card.newCount"
|
|
@@ -124,7 +135,7 @@
|
|
|
124
135
|
>
|
|
125
136
|
<UiIcon
|
|
126
137
|
name="dots-vertical"
|
|
127
|
-
size="
|
|
138
|
+
size="18"
|
|
128
139
|
/>
|
|
129
140
|
</button>
|
|
130
141
|
</template>
|
|
@@ -135,39 +146,65 @@
|
|
|
135
146
|
/>
|
|
136
147
|
</template>
|
|
137
148
|
<template #items="className">
|
|
138
|
-
<
|
|
139
|
-
|
|
140
|
-
|
|
141
|
-
|
|
142
|
-
|
|
143
|
-
|
|
144
|
-
|
|
145
|
-
|
|
146
|
-
|
|
147
|
-
|
|
148
|
-
|
|
149
|
-
|
|
150
|
-
|
|
151
|
-
|
|
152
|
-
|
|
153
|
-
|
|
154
|
-
|
|
155
|
-
|
|
156
|
-
|
|
157
|
-
|
|
158
|
-
|
|
159
|
-
|
|
160
|
-
|
|
161
|
-
|
|
162
|
-
|
|
163
|
-
|
|
164
|
-
|
|
165
|
-
|
|
166
|
-
|
|
167
|
-
|
|
168
|
-
|
|
169
|
-
|
|
170
|
-
|
|
149
|
+
<template v-if="own">
|
|
150
|
+
<button
|
|
151
|
+
:class="className"
|
|
152
|
+
type="button"
|
|
153
|
+
@click="openPlaylistEdit(PlaylistStep.Edit, false, card)"
|
|
154
|
+
>
|
|
155
|
+
<UiIcon
|
|
156
|
+
name="edit"
|
|
157
|
+
size="16"
|
|
158
|
+
/>
|
|
159
|
+
{{ t('playlist.edit') }}
|
|
160
|
+
</button>
|
|
161
|
+
<button
|
|
162
|
+
:class="[className, 'card-playlist__delete']"
|
|
163
|
+
type="button"
|
|
164
|
+
@click="openPlaylistEdit(PlaylistStep.Delete, false, card)"
|
|
165
|
+
>
|
|
166
|
+
<UiIcon
|
|
167
|
+
name="trash-bin"
|
|
168
|
+
size="16"
|
|
169
|
+
/>
|
|
170
|
+
{{ t('playlist.delete') }}
|
|
171
|
+
</button>
|
|
172
|
+
</template>
|
|
173
|
+
<template v-else>
|
|
174
|
+
<button
|
|
175
|
+
:class="className"
|
|
176
|
+
type="button"
|
|
177
|
+
@click="toggleFollow(card.id, card.name)"
|
|
178
|
+
>
|
|
179
|
+
<UiIcon
|
|
180
|
+
:name="shownFollowing ? 'minus' : 'plus'"
|
|
181
|
+
size="16"
|
|
182
|
+
/>
|
|
183
|
+
{{ followLabel }}
|
|
184
|
+
</button>
|
|
185
|
+
<button
|
|
186
|
+
:class="className"
|
|
187
|
+
type="button"
|
|
188
|
+
@click="emit('share', card)"
|
|
189
|
+
>
|
|
190
|
+
<UiIcon
|
|
191
|
+
name="share"
|
|
192
|
+
size="16"
|
|
193
|
+
/>
|
|
194
|
+
{{ t('share') }}
|
|
195
|
+
</button>
|
|
196
|
+
<button
|
|
197
|
+
:class="className"
|
|
198
|
+
type="button"
|
|
199
|
+
@click="onReport"
|
|
200
|
+
>
|
|
201
|
+
<UiIcon
|
|
202
|
+
name="flag"
|
|
203
|
+
size="16"
|
|
204
|
+
/>
|
|
205
|
+
{{ t('report') }}
|
|
206
|
+
</button>
|
|
207
|
+
</template>
|
|
171
208
|
</template>
|
|
172
209
|
</UiDropdown>
|
|
173
210
|
</div>
|
|
@@ -183,7 +220,7 @@
|
|
|
183
220
|
</template>
|
|
184
221
|
|
|
185
222
|
<script setup lang="ts">
|
|
186
|
-
import { formatDate, ThumbSize } from '../../runtime';
|
|
223
|
+
import { formatDate, PLAYLIST_TYPE_ICON, PlaylistStep, PlaylistType, ThumbSize } from '../../runtime';
|
|
187
224
|
import type { IPlaylistCard, IProfileData, IThumbUrls } from '../../types';
|
|
188
225
|
import { AuthorizationApiService } from '../../services/api/authorization.service';
|
|
189
226
|
import { CSS_BREAKPOINTS } from '../../config/css-breakpoints';
|
|
@@ -191,8 +228,12 @@ import { CSS_BREAKPOINTS } from '../../config/css-breakpoints';
|
|
|
191
228
|
const props = defineProps<{
|
|
192
229
|
card: IPlaylistCard
|
|
193
230
|
topPlaylists?: boolean
|
|
194
|
-
/** Без меню «три точки» — вместе с ним уходят
|
|
231
|
+
/** Без меню «три точки» — вместе с ним уходят подписка, share и report */
|
|
195
232
|
noActions?: boolean
|
|
233
|
+
/** Кнопка подписки поверх обложки — список подписок в библиотеке */
|
|
234
|
+
showFollow?: boolean
|
|
235
|
+
/** Свой плейлист: приватность значком у названия, в меню — правка и удаление вместо подписки */
|
|
236
|
+
own?: boolean
|
|
196
237
|
priority?: boolean
|
|
197
238
|
}>();
|
|
198
239
|
|
|
@@ -216,21 +257,29 @@ const userProfileData = computed(() => useState<IProfileData>('profileData').val
|
|
|
216
257
|
|
|
217
258
|
const { isAuthorized } = useUser(AuthorizationApiService);
|
|
218
259
|
|
|
219
|
-
const {
|
|
260
|
+
const { isFollowing, toggleFollow, toggle } = usePlaylistFollow();
|
|
220
261
|
|
|
221
|
-
const
|
|
262
|
+
const { openPlaylistEdit } = usePlaylistEdit();
|
|
222
263
|
|
|
223
|
-
|
|
264
|
+
const isPrivate = computed(() => props.card.playlistType === PlaylistType.Private);
|
|
265
|
+
|
|
266
|
+
const visibilityTitle = computed(() => isPrivate.value
|
|
267
|
+
? t('playlist.private_playlist')
|
|
268
|
+
: t('playlist.public_playlist'));
|
|
269
|
+
|
|
270
|
+
const following = computed(() => isFollowing(props.card.id));
|
|
271
|
+
|
|
272
|
+
// Тем же кликом, что переключает подписку, меню закрывается. Если менять надпись сразу, она
|
|
224
273
|
// дёргается прямо во время анимации закрытия, поэтому в меню живёт снапшот состояния — он
|
|
225
274
|
// догоняет реальное только к следующему открытию, когда пункт всё равно перерисовывается с нуля.
|
|
226
|
-
const
|
|
275
|
+
const shownFollowing = ref(following.value);
|
|
227
276
|
|
|
228
|
-
const
|
|
229
|
-
? t('
|
|
230
|
-
: t('
|
|
277
|
+
const followLabel = computed(() => shownFollowing.value
|
|
278
|
+
? t('unfollow')
|
|
279
|
+
: t('follow'));
|
|
231
280
|
|
|
232
281
|
watch(() => dropdownRef.value?.open, (isOpen) => {
|
|
233
|
-
if (isOpen)
|
|
282
|
+
if (isOpen) shownFollowing.value = following.value;
|
|
234
283
|
});
|
|
235
284
|
|
|
236
285
|
const avatar = computed(() => {
|
|
@@ -261,9 +310,14 @@ const avatarPlaceholder = computed(() => props.card.username.charAt(0));
|
|
|
261
310
|
</script>
|
|
262
311
|
|
|
263
312
|
<style lang="scss">
|
|
313
|
+
// Ниже этой ширины в мета-строку не влезает «Updated» рядом с лайками, просмотрами и датой
|
|
314
|
+
$updated-word-width: 240px;
|
|
315
|
+
|
|
264
316
|
.card-playlist {
|
|
317
|
+
position: relative;
|
|
265
318
|
display: grid;
|
|
266
319
|
width: 100%;
|
|
320
|
+
container-type: inline-size;
|
|
267
321
|
align-items: start;
|
|
268
322
|
align-content: start;
|
|
269
323
|
border-radius: $radius-xl;
|
|
@@ -423,10 +477,26 @@ const avatarPlaceholder = computed(() => props.card.username.charAt(0));
|
|
|
423
477
|
color: $playlist-card-empty-text;
|
|
424
478
|
}
|
|
425
479
|
|
|
426
|
-
.card-
|
|
480
|
+
.card-playlist__visibility {
|
|
481
|
+
display: grid;
|
|
482
|
+
flex-shrink: 0;
|
|
483
|
+
color: $playlist-card-visibility-icon;
|
|
484
|
+
}
|
|
485
|
+
|
|
486
|
+
/* Дропдаун красит свои пункты и иконки сам, поэтому селектор адресный — иначе «удалить» не красное */
|
|
487
|
+
.ui-dropdown__menu-items > .card-playlist__delete.ui-dropdown__item {
|
|
488
|
+
color: $playlist-card-delete-text;
|
|
489
|
+
}
|
|
490
|
+
|
|
491
|
+
.ui-dropdown__menu-items > .card-playlist__delete.ui-dropdown__item .ui-icon {
|
|
492
|
+
color: inherit;
|
|
493
|
+
}
|
|
494
|
+
|
|
495
|
+
.card-playlist__follow {
|
|
427
496
|
position: absolute;
|
|
428
|
-
right: 8px;
|
|
429
497
|
top: 8px;
|
|
498
|
+
right: 8px;
|
|
499
|
+
z-index: $z-index-plus;
|
|
430
500
|
}
|
|
431
501
|
|
|
432
502
|
.card-playlist__tags {
|
|
@@ -467,6 +537,12 @@ const avatarPlaceholder = computed(() => props.card.username.charAt(0));
|
|
|
467
537
|
color: $playlist-card-meta;
|
|
468
538
|
}
|
|
469
539
|
|
|
540
|
+
@include to-cq($updated-word-width) {
|
|
541
|
+
.card-playlist__updated {
|
|
542
|
+
display: none;
|
|
543
|
+
}
|
|
544
|
+
}
|
|
545
|
+
|
|
470
546
|
.card-playlist__avatar.--placeholder {
|
|
471
547
|
@include font-xs;
|
|
472
548
|
|
|
@@ -7,6 +7,8 @@
|
|
|
7
7
|
:card="item"
|
|
8
8
|
:top-playlists="topPlaylists"
|
|
9
9
|
:no-actions="noActions"
|
|
10
|
+
:show-follow="showFollow"
|
|
11
|
+
:own="own"
|
|
10
12
|
:priority="priority && index === 0"
|
|
11
13
|
@share="onShare"
|
|
12
14
|
/>
|
|
@@ -35,6 +37,10 @@ defineProps<{
|
|
|
35
37
|
topPlaylists?: boolean
|
|
36
38
|
/** Скрыть меню карточки (три точки: share/report) — например у редакционных подборок */
|
|
37
39
|
noActions?: boolean
|
|
40
|
+
/** Кнопка подписки поверх обложки — список подписок в библиотеке */
|
|
41
|
+
showFollow?: boolean
|
|
42
|
+
/** Свои плейлисты: приватность значком у названия, в меню — правка и удаление */
|
|
43
|
+
own?: boolean
|
|
38
44
|
priority?: boolean
|
|
39
45
|
}>();
|
|
40
46
|
|
|
@@ -0,0 +1,92 @@
|
|
|
1
|
+
<!-- Коллекция видео («Избранное», «Смотреть позже»): счётчик, сортировка, сетка карточек -->
|
|
2
|
+
<template>
|
|
3
|
+
<div class="library-collection">
|
|
4
|
+
<UiSectionTitle
|
|
5
|
+
:title="title"
|
|
6
|
+
:count="total"
|
|
7
|
+
:icon="icon"
|
|
8
|
+
class="library-collection__title"
|
|
9
|
+
/>
|
|
10
|
+
|
|
11
|
+
<SectionNavLinks
|
|
12
|
+
:items="sortCollections"
|
|
13
|
+
class="library-collection__sort"
|
|
14
|
+
theme="secondary"
|
|
15
|
+
/>
|
|
16
|
+
|
|
17
|
+
<div
|
|
18
|
+
v-if="pageItems.length > 0"
|
|
19
|
+
class="library-collection__grid"
|
|
20
|
+
>
|
|
21
|
+
<CardVideo
|
|
22
|
+
v-for="(item, index) in pageItems"
|
|
23
|
+
:key="item.guid"
|
|
24
|
+
:card="item"
|
|
25
|
+
:priority="index < 4"
|
|
26
|
+
:loading="index < 4 ? 'eager' : 'lazy'"
|
|
27
|
+
/>
|
|
28
|
+
</div>
|
|
29
|
+
|
|
30
|
+
<UiEmptyState
|
|
31
|
+
v-else
|
|
32
|
+
without-reset
|
|
33
|
+
/>
|
|
34
|
+
|
|
35
|
+
<UiPagination
|
|
36
|
+
v-if="allPages > 1"
|
|
37
|
+
:key="`pagination-${allPages}`"
|
|
38
|
+
:total="allPages"
|
|
39
|
+
class="library-collection__pagination _pagination"
|
|
40
|
+
arrow-icon
|
|
41
|
+
/>
|
|
42
|
+
</div>
|
|
43
|
+
</template>
|
|
44
|
+
|
|
45
|
+
<script setup lang="ts">
|
|
46
|
+
import { sortCollections } from '../../lib';
|
|
47
|
+
import type { IVideoCard } from '../../types';
|
|
48
|
+
|
|
49
|
+
const PER_PAGE = 24;
|
|
50
|
+
|
|
51
|
+
const props = defineProps<{
|
|
52
|
+
/** Ролики коллекции в порядке добавления, свежие первыми — «недавние» это сортировка по умолчанию */
|
|
53
|
+
items: IVideoCard[]
|
|
54
|
+
title: string
|
|
55
|
+
icon: string
|
|
56
|
+
}>();
|
|
57
|
+
|
|
58
|
+
const route = useRoute();
|
|
59
|
+
|
|
60
|
+
const sortedItems = computed(() => route.query['sort'] === 'az'
|
|
61
|
+
? [ ...props.items ].sort((a, b) => a.title.localeCompare(b.title))
|
|
62
|
+
: props.items);
|
|
63
|
+
|
|
64
|
+
const total = computed(() => sortedItems.value.length);
|
|
65
|
+
const allPages = computed(() => Math.ceil(total.value / PER_PAGE));
|
|
66
|
+
|
|
67
|
+
const pageItems = computed(() => {
|
|
68
|
+
const page = Math.min(Math.max(Number(route.query['page']) || 1, 1), allPages.value || 1);
|
|
69
|
+
return sortedItems.value.slice((page - 1) * PER_PAGE, page * PER_PAGE);
|
|
70
|
+
});
|
|
71
|
+
</script>
|
|
72
|
+
|
|
73
|
+
<style lang="scss">
|
|
74
|
+
.library-collection__title {
|
|
75
|
+
margin-bottom: 24px;
|
|
76
|
+
}
|
|
77
|
+
|
|
78
|
+
.library-collection__sort {
|
|
79
|
+
margin-bottom: 32px;
|
|
80
|
+
}
|
|
81
|
+
|
|
82
|
+
.library-collection__grid {
|
|
83
|
+
@include video-card-grid;
|
|
84
|
+
|
|
85
|
+
position: relative;
|
|
86
|
+
}
|
|
87
|
+
|
|
88
|
+
.ui-pagination.library-collection__pagination {
|
|
89
|
+
margin-top: auto;
|
|
90
|
+
margin-bottom: 40px;
|
|
91
|
+
}
|
|
92
|
+
</style>
|
|
@@ -94,7 +94,7 @@ const props = defineProps<{
|
|
|
94
94
|
}>();
|
|
95
95
|
|
|
96
96
|
const emit = defineEmits<{
|
|
97
|
-
(eventName: 'delete-playlist', eventValue: string): void
|
|
97
|
+
(eventName: 'delete-playlist', eventValue: { id: string, name?: string }): void
|
|
98
98
|
(eventName: 'save-edit', eventValue: {
|
|
99
99
|
id: string
|
|
100
100
|
playlistName: string
|
|
@@ -135,16 +135,14 @@ const id = computed(() => selectedPlaylist.value?.id ?? (route.params[ 'playlist
|
|
|
135
135
|
|
|
136
136
|
const playlistsData = useState<IPlaylistData>(`data-${AsyncData.PlaylistById}-${id.value}`);
|
|
137
137
|
|
|
138
|
-
|
|
139
|
-
|
|
140
|
-
|
|
138
|
+
// Сначала закрываем попап, потом сообщаем об удалении: снекбар с undo показывает владелец
|
|
139
|
+
// запроса (PlaylistMain), и всплыть он должен уже в макете, а не в закрывающемся диалоге.
|
|
140
|
+
function onDeleteClick() {
|
|
141
|
+
const playlistId = id.value || '';
|
|
142
|
+
const name = selectedPlaylist.value?.name;
|
|
141
143
|
|
|
142
|
-
|
|
143
|
-
|
|
144
|
-
show('deleted', { icon: 'trash-bin', highlight: name });
|
|
145
|
-
} catch {
|
|
146
|
-
console.log('error delete');
|
|
147
|
-
}
|
|
144
|
+
goBack();
|
|
145
|
+
emit('delete-playlist', { id: playlistId, name });
|
|
148
146
|
}
|
|
149
147
|
|
|
150
148
|
async function onSaveClick() {
|
|
@@ -10,7 +10,7 @@
|
|
|
10
10
|
:loading-post-playlist="loadingPostPlaylist"
|
|
11
11
|
:loading-user-playlists="loadingUserPlaylists"
|
|
12
12
|
:loading-post-video="loadingPostVideo"
|
|
13
|
-
:user-playlists="
|
|
13
|
+
:user-playlists="userPlaylists"
|
|
14
14
|
@execute-user-playlists="getUserPlaylists"
|
|
15
15
|
@save-video-to-playlist="saveVideoToPlaylist"
|
|
16
16
|
/>
|
|
@@ -35,12 +35,14 @@ import { PlaylistsApiService } from '../../services/api/playlists.service';
|
|
|
35
35
|
import type { Language, PlaylistType } from '../../runtime';
|
|
36
36
|
import { AsyncData } from '../../runtime';
|
|
37
37
|
import { useI18n } from 'vue-i18n';
|
|
38
|
-
import type { IPlaylistVideoFormType, IPlaylistShort } from '../../types';
|
|
38
|
+
import type { IPlaylistCard, IPlaylistVideoFormType, IPlaylistShort, PaginatedResponse } from '../../types';
|
|
39
39
|
|
|
40
40
|
const { locale } = useI18n();
|
|
41
41
|
|
|
42
42
|
const { isPlaylistAdd } = usePlaylistAdd();
|
|
43
43
|
const { isPlaylistEditOpen } = usePlaylistEdit();
|
|
44
|
+
const { show } = useSnackbar();
|
|
45
|
+
const { isPendingDelete, markPendingDelete, unmarkPendingDelete } = usePlaylistDelete();
|
|
44
46
|
|
|
45
47
|
const route = useRoute();
|
|
46
48
|
|
|
@@ -68,6 +70,11 @@ const {
|
|
|
68
70
|
} = useApiAction(PlaylistsApiService.deletePlaylist);
|
|
69
71
|
const { execute: executeRefresh, loading: loadingRefresh } = useApiAction(PlaylistsApiService.refreshPlaylist);
|
|
70
72
|
|
|
73
|
+
/** Плейлисты с ещё не отправленным удалением в попапе добавления не показываем. */
|
|
74
|
+
const userPlaylists = computed<PaginatedResponse<IPlaylistCard> | null>(() => data.value
|
|
75
|
+
? { ...data.value, items: data.value.items.filter(item => !isPendingDelete(item.id)) }
|
|
76
|
+
: null);
|
|
77
|
+
|
|
71
78
|
async function getUserPlaylists() {
|
|
72
79
|
await executeUserPlaylists([locale.value as Language], AsyncData.GetPlaylists);
|
|
73
80
|
}
|
|
@@ -107,9 +114,28 @@ async function deleteVideo(event: {
|
|
|
107
114
|
}, locale.value as Language], `${AsyncData.GetPlaylistsVideo}-${event.playlistId}`);
|
|
108
115
|
}
|
|
109
116
|
|
|
110
|
-
async function
|
|
111
|
-
await executeDeletePlaylist([
|
|
117
|
+
async function commitDeletePlaylist(id: string) {
|
|
118
|
+
await executeDeletePlaylist([id]);
|
|
112
119
|
await executeUserPlaylists([locale.value as Language], AsyncData.GetPlaylists);
|
|
120
|
+
// Метку снимаем только после ответа: до него плейлист ещё в списке и всплыл бы обратно.
|
|
121
|
+
unmarkPendingDelete(id);
|
|
122
|
+
}
|
|
123
|
+
|
|
124
|
+
/**
|
|
125
|
+
* Удаление оптимистичное: карточка скрывается сразу, а запрос уходит, когда закроется снекбар,
|
|
126
|
+
* то есть окно отмены истекло. Undo просто снимает метку, и карточка возвращается на место.
|
|
127
|
+
* Удаления идут независимо друг от друга: у каждого свой id, поэтому несколько подряд
|
|
128
|
+
* не путаются, а рефетч после соседнего удаления не показывает уже «удалённые» плейлисты.
|
|
129
|
+
*/
|
|
130
|
+
function deletePlaylist(event: { id: string, name?: string }) {
|
|
131
|
+
markPendingDelete(event.id);
|
|
132
|
+
|
|
133
|
+
show('deleted', {
|
|
134
|
+
icon: 'trash-bin',
|
|
135
|
+
highlight: event.name,
|
|
136
|
+
onAction: () => unmarkPendingDelete(event.id),
|
|
137
|
+
onDismiss: () => commitDeletePlaylist(event.id),
|
|
138
|
+
});
|
|
113
139
|
}
|
|
114
140
|
|
|
115
141
|
async function saveEditPlaylist(event: {
|
|
@@ -0,0 +1,38 @@
|
|
|
1
|
+
// @vitest-environment nuxt
|
|
2
|
+
import { describe, it, expect, beforeEach } from 'vitest';
|
|
3
|
+
import { usePlaylistDelete } from '../use-playlist-delete';
|
|
4
|
+
|
|
5
|
+
describe('usePlaylistDelete', () => {
|
|
6
|
+
beforeEach(() => {
|
|
7
|
+
useNuxtApp().payload.state = {};
|
|
8
|
+
});
|
|
9
|
+
|
|
10
|
+
it('метка скрывает плейлист и не дублируется', () => {
|
|
11
|
+
const { pendingIds, isPendingDelete, markPendingDelete } = usePlaylistDelete();
|
|
12
|
+
|
|
13
|
+
markPendingDelete('playlist-1');
|
|
14
|
+
markPendingDelete('playlist-1');
|
|
15
|
+
|
|
16
|
+
expect(pendingIds.value).toEqual([ 'playlist-1' ]);
|
|
17
|
+
expect(isPendingDelete('playlist-1')).toBe(true);
|
|
18
|
+
expect(isPendingDelete('playlist-2')).toBe(false);
|
|
19
|
+
});
|
|
20
|
+
|
|
21
|
+
it('снятие метки не задевает соседние удаления', () => {
|
|
22
|
+
const { pendingIds, markPendingDelete, unmarkPendingDelete } = usePlaylistDelete();
|
|
23
|
+
|
|
24
|
+
markPendingDelete('playlist-1');
|
|
25
|
+
markPendingDelete('playlist-2');
|
|
26
|
+
unmarkPendingDelete('playlist-1');
|
|
27
|
+
|
|
28
|
+
expect(pendingIds.value).toEqual([ 'playlist-2' ]);
|
|
29
|
+
});
|
|
30
|
+
|
|
31
|
+
it('снятие несуществующей метки ничего не ломает', () => {
|
|
32
|
+
const { pendingIds, unmarkPendingDelete } = usePlaylistDelete();
|
|
33
|
+
|
|
34
|
+
unmarkPendingDelete('playlist-1');
|
|
35
|
+
|
|
36
|
+
expect(pendingIds.value).toEqual([]);
|
|
37
|
+
});
|
|
38
|
+
});
|
|
@@ -0,0 +1,54 @@
|
|
|
1
|
+
// @vitest-environment nuxt
|
|
2
|
+
import { describe, it, expect, beforeEach, afterEach, vi } from 'vitest';
|
|
3
|
+
import { usePlaylistFollow } from '../use-playlist-follow';
|
|
4
|
+
import { useSnackbar } from '../use-snackbar';
|
|
5
|
+
|
|
6
|
+
describe('usePlaylistFollow', () => {
|
|
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 { follows, isFollowing, toggleFollow } = usePlaylistFollow();
|
|
19
|
+
const { snackbarText } = useSnackbar();
|
|
20
|
+
|
|
21
|
+
toggleFollow('playlist-1', 'Playlist name');
|
|
22
|
+
|
|
23
|
+
expect(follows.value).toEqual([ 'playlist-1' ]);
|
|
24
|
+
expect(isFollowing('playlist-1')).toBe(true);
|
|
25
|
+
expect(snackbarText.value).toBe('');
|
|
26
|
+
});
|
|
27
|
+
|
|
28
|
+
it('отписка показывает снекбар с кнопкой отмены', () => {
|
|
29
|
+
const { follows, isFollowing, toggleFollow } = usePlaylistFollow();
|
|
30
|
+
const { snackbarText, snackbarIcon, snackbarHighlight, snackbarActionText } = useSnackbar();
|
|
31
|
+
|
|
32
|
+
toggleFollow('playlist-1', 'Playlist name');
|
|
33
|
+
toggleFollow('playlist-1', 'Playlist name');
|
|
34
|
+
|
|
35
|
+
expect(follows.value).toEqual([]);
|
|
36
|
+
expect(isFollowing('playlist-1')).toBe(false);
|
|
37
|
+
expect(snackbarText.value).toBe('unfollowed');
|
|
38
|
+
expect(snackbarIcon.value).toBe('minus');
|
|
39
|
+
expect(snackbarHighlight.value).toBe('Playlist name');
|
|
40
|
+
expect(snackbarActionText.value).toBe('undo');
|
|
41
|
+
});
|
|
42
|
+
|
|
43
|
+
it('отмена возвращает подписку и не показывает новый снекбар', () => {
|
|
44
|
+
const { follows, toggleFollow } = usePlaylistFollow();
|
|
45
|
+
const { runSnackbarAction, snackbarText } = useSnackbar();
|
|
46
|
+
|
|
47
|
+
toggleFollow('playlist-2', 'Another name');
|
|
48
|
+
toggleFollow('playlist-2', 'Another name');
|
|
49
|
+
runSnackbarAction();
|
|
50
|
+
|
|
51
|
+
expect(follows.value).toEqual([ 'playlist-2' ]);
|
|
52
|
+
expect(snackbarText.value).toBe('');
|
|
53
|
+
});
|
|
54
|
+
});
|