itube-specs 0.0.914 → 0.0.916
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 -3
- package/assets/scss/themes/light/_theme.scss +11 -2
- package/components/card/playlist.vue +144 -40
- package/components/card/round.vue +5 -4
- 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/components/ui/label.vue +1 -10
- package/components/view/video.vue +0 -12
- 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/runtime/utils/cleaners/__tests__/clean-video-card.test.ts +0 -3
- package/runtime/utils/cleaners/__tests__/clean-video-data.test.ts +0 -1
- package/runtime/utils/cleaners/clean-video-card.ts +0 -1
- package/runtime/utils/cleaners/clean-video-data.ts +0 -1
- package/stubs/collections.ts +81 -0
- package/stubs/history.ts +0 -1
- package/stubs/library-follows.ts +8 -13
- package/stubs/videos.ts +2 -3
- package/types/raw/raw-video-card.d.ts +1 -1
- package/types/raw/raw-video-data.d.ts +1 -1
- package/types/video-card.d.ts +0 -1
- package/types/video-data.d.ts +0 -1
|
@@ -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;
|
|
@@ -592,8 +592,6 @@ $label-primary-bg: $color-indigo-600;
|
|
|
592
592
|
$label-primary-text: $color-white;
|
|
593
593
|
$label-gradient-bg: $color-indigo-600;
|
|
594
594
|
$label-gradient-text: $color-white;
|
|
595
|
-
$label-yellow-bg: $color-amber-500;
|
|
596
|
-
$label-yellow-text: $mono-black-100;
|
|
597
595
|
$label-green-bg: $color-green-500;
|
|
598
596
|
$label-green-text: $color-white;
|
|
599
597
|
$label-white-bg: $mono-white-75;
|
|
@@ -869,6 +867,17 @@ $playlist-card-placeholder-text: $color-zinc-400;
|
|
|
869
867
|
$playlist-card-placeholder-bg: $color-zinc-700;
|
|
870
868
|
$playlist-card-dropdown-icon: $color-zinc-500;
|
|
871
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;
|
|
872
881
|
|
|
873
882
|
// ─── Collections section ───
|
|
874
883
|
$collections-badge-text: $color-indigo-300;
|
|
@@ -643,8 +643,6 @@ $label-primary-bg: $color-indigo-600;
|
|
|
643
643
|
$label-primary-text: $color-white;
|
|
644
644
|
$label-gradient-bg: $color-indigo-600;
|
|
645
645
|
$label-gradient-text: $color-white;
|
|
646
|
-
$label-yellow-bg: $color-amber-500;
|
|
647
|
-
$label-yellow-text: $mono-black-100;
|
|
648
646
|
$label-green-bg: $color-green-500;
|
|
649
647
|
$label-green-text: $color-white;
|
|
650
648
|
$label-white-bg: $mono-white-75;
|
|
@@ -897,6 +895,17 @@ $playlist-card-placeholder-text: $color-gray-700;
|
|
|
897
895
|
$playlist-card-placeholder-bg: $color-gray-300;
|
|
898
896
|
$playlist-card-dropdown-icon: $color-gray-600;
|
|
899
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;
|
|
900
909
|
|
|
901
910
|
// ─── Collections section ───
|
|
902
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,28 +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
|
-
|
|
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>
|
|
160
208
|
</template>
|
|
161
209
|
</UiDropdown>
|
|
162
210
|
</div>
|
|
@@ -172,7 +220,7 @@
|
|
|
172
220
|
</template>
|
|
173
221
|
|
|
174
222
|
<script setup lang="ts">
|
|
175
|
-
import { formatDate, ThumbSize } from '../../runtime';
|
|
223
|
+
import { formatDate, PLAYLIST_TYPE_ICON, PlaylistStep, PlaylistType, ThumbSize } from '../../runtime';
|
|
176
224
|
import type { IPlaylistCard, IProfileData, IThumbUrls } from '../../types';
|
|
177
225
|
import { AuthorizationApiService } from '../../services/api/authorization.service';
|
|
178
226
|
import { CSS_BREAKPOINTS } from '../../config/css-breakpoints';
|
|
@@ -180,8 +228,12 @@ import { CSS_BREAKPOINTS } from '../../config/css-breakpoints';
|
|
|
180
228
|
const props = defineProps<{
|
|
181
229
|
card: IPlaylistCard
|
|
182
230
|
topPlaylists?: boolean
|
|
183
|
-
/** Без меню «три точки» — вместе с ним уходят share и report */
|
|
231
|
+
/** Без меню «три точки» — вместе с ним уходят подписка, share и report */
|
|
184
232
|
noActions?: boolean
|
|
233
|
+
/** Кнопка подписки поверх обложки — список подписок в библиотеке */
|
|
234
|
+
showFollow?: boolean
|
|
235
|
+
/** Свой плейлист: приватность значком у названия, в меню — правка и удаление вместо подписки */
|
|
236
|
+
own?: boolean
|
|
185
237
|
priority?: boolean
|
|
186
238
|
}>();
|
|
187
239
|
|
|
@@ -205,6 +257,31 @@ const userProfileData = computed(() => useState<IProfileData>('profileData').val
|
|
|
205
257
|
|
|
206
258
|
const { isAuthorized } = useUser(AuthorizationApiService);
|
|
207
259
|
|
|
260
|
+
const { isFollowing, toggleFollow, toggle } = usePlaylistFollow();
|
|
261
|
+
|
|
262
|
+
const { openPlaylistEdit } = usePlaylistEdit();
|
|
263
|
+
|
|
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
|
+
// Тем же кликом, что переключает подписку, меню закрывается. Если менять надпись сразу, она
|
|
273
|
+
// дёргается прямо во время анимации закрытия, поэтому в меню живёт снапшот состояния — он
|
|
274
|
+
// догоняет реальное только к следующему открытию, когда пункт всё равно перерисовывается с нуля.
|
|
275
|
+
const shownFollowing = ref(following.value);
|
|
276
|
+
|
|
277
|
+
const followLabel = computed(() => shownFollowing.value
|
|
278
|
+
? t('unfollow')
|
|
279
|
+
: t('follow'));
|
|
280
|
+
|
|
281
|
+
watch(() => dropdownRef.value?.open, (isOpen) => {
|
|
282
|
+
if (isOpen) shownFollowing.value = following.value;
|
|
283
|
+
});
|
|
284
|
+
|
|
208
285
|
const avatar = computed(() => {
|
|
209
286
|
if (isAuthorized.value && props.topPlaylists && userProfileData.value?.avatar) {
|
|
210
287
|
return userProfileData.value.avatar;
|
|
@@ -233,9 +310,14 @@ const avatarPlaceholder = computed(() => props.card.username.charAt(0));
|
|
|
233
310
|
</script>
|
|
234
311
|
|
|
235
312
|
<style lang="scss">
|
|
313
|
+
// Ниже этой ширины в мета-строку не влезает «Updated» рядом с лайками, просмотрами и датой
|
|
314
|
+
$updated-word-width: 240px;
|
|
315
|
+
|
|
236
316
|
.card-playlist {
|
|
317
|
+
position: relative;
|
|
237
318
|
display: grid;
|
|
238
319
|
width: 100%;
|
|
320
|
+
container-type: inline-size;
|
|
239
321
|
align-items: start;
|
|
240
322
|
align-content: start;
|
|
241
323
|
border-radius: $radius-xl;
|
|
@@ -395,10 +477,26 @@ const avatarPlaceholder = computed(() => props.card.username.charAt(0));
|
|
|
395
477
|
color: $playlist-card-empty-text;
|
|
396
478
|
}
|
|
397
479
|
|
|
398
|
-
.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 {
|
|
399
496
|
position: absolute;
|
|
400
|
-
right: 8px;
|
|
401
497
|
top: 8px;
|
|
498
|
+
right: 8px;
|
|
499
|
+
z-index: $z-index-plus;
|
|
402
500
|
}
|
|
403
501
|
|
|
404
502
|
.card-playlist__tags {
|
|
@@ -439,6 +537,12 @@ const avatarPlaceholder = computed(() => props.card.username.charAt(0));
|
|
|
439
537
|
color: $playlist-card-meta;
|
|
440
538
|
}
|
|
441
539
|
|
|
540
|
+
@include to-cq($updated-word-width) {
|
|
541
|
+
.card-playlist__updated {
|
|
542
|
+
display: none;
|
|
543
|
+
}
|
|
544
|
+
}
|
|
545
|
+
|
|
442
546
|
.card-playlist__avatar.--placeholder {
|
|
443
547
|
@include font-xs;
|
|
444
548
|
|
|
@@ -70,10 +70,11 @@ function onImageError() {
|
|
|
70
70
|
</script>
|
|
71
71
|
|
|
72
72
|
<style lang="scss">
|
|
73
|
-
/* Заход бейджа внутрь
|
|
74
|
-
|
|
75
|
-
|
|
76
|
-
$badge-inset
|
|
73
|
+
/* Заход бейджа внутрь круга: отступ от угла карточки подобран так, чтобы центр
|
|
74
|
+
бейджа сел на границу окружности по диагонали — примерно половина снаружи.
|
|
75
|
+
На мобиле аватар 80px против 96px, а бейдж не уменьшается, поэтому отступ меньше. */
|
|
76
|
+
$badge-inset: 3px;
|
|
77
|
+
$badge-inset--mobile: 1px;
|
|
77
78
|
|
|
78
79
|
.card-round {
|
|
79
80
|
position: relative;
|
|
@@ -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: {
|
package/components/ui/label.vue
CHANGED
|
@@ -11,7 +11,7 @@
|
|
|
11
11
|
<script setup lang="ts">
|
|
12
12
|
withDefaults(
|
|
13
13
|
defineProps<{
|
|
14
|
-
theme?: 'default' | 'primary' | 'primary-gradient' | '
|
|
14
|
+
theme?: 'default' | 'primary' | 'primary-gradient' | 'green' | 'white' | 'black' | 'backdrop' | 'secondary' | 'success' | 'blur' | 'grey'
|
|
15
15
|
text: string | number
|
|
16
16
|
}>(), {
|
|
17
17
|
theme: 'default'
|
|
@@ -47,15 +47,6 @@ withDefaults(
|
|
|
47
47
|
color: $label-gradient-text;
|
|
48
48
|
}
|
|
49
49
|
|
|
50
|
-
.ui-label.--yellow {
|
|
51
|
-
@include font-3xs;
|
|
52
|
-
|
|
53
|
-
background: $label-yellow-bg;
|
|
54
|
-
color: $label-yellow-text;
|
|
55
|
-
height: 15px;
|
|
56
|
-
display: inline-block;
|
|
57
|
-
}
|
|
58
|
-
|
|
59
50
|
.ui-label.--green {
|
|
60
51
|
background-color: $label-green-bg;
|
|
61
52
|
color: $label-green-text;
|
|
@@ -4,12 +4,6 @@
|
|
|
4
4
|
<header class="view-video__header">
|
|
5
5
|
<h1 class="view-video__title">
|
|
6
6
|
<span class="_truncate">{{ data.title }}</span>
|
|
7
|
-
<UiLabel
|
|
8
|
-
v-if="data.quality"
|
|
9
|
-
:text="data.quality"
|
|
10
|
-
class="view-video__title-label"
|
|
11
|
-
theme="yellow"
|
|
12
|
-
/>
|
|
13
7
|
</h1>
|
|
14
8
|
<div class="view-video__header-wrapper">
|
|
15
9
|
<UiExpandRow
|
|
@@ -248,12 +242,6 @@ function getModelChips(model: IVideoData['models'][number], index: number): IChi
|
|
|
248
242
|
letter-spacing: var(--tracking-wide);
|
|
249
243
|
}
|
|
250
244
|
|
|
251
|
-
.view-video__title-label {
|
|
252
|
-
display: inline;
|
|
253
|
-
vertical-align: middle;
|
|
254
|
-
margin-left: 12px;
|
|
255
|
-
}
|
|
256
|
-
|
|
257
245
|
.view-video__header-wrapper {
|
|
258
246
|
margin-bottom: 16px;
|
|
259
247
|
}
|
|
@@ -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
|
+
});
|