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.
Files changed (41) hide show
  1. package/assets/icons/common/web-cam.svg +1 -0
  2. package/assets/scss/themes/dark/_theme.scss +12 -3
  3. package/assets/scss/themes/light/_theme.scss +11 -2
  4. package/components/card/playlist.vue +144 -40
  5. package/components/card/round.vue +5 -4
  6. package/components/grid/playlists.vue +6 -0
  7. package/components/library/collection.vue +92 -0
  8. package/components/playlist/edit.vue +8 -10
  9. package/components/playlist/main.vue +30 -4
  10. package/components/ui/label.vue +1 -10
  11. package/components/view/video.vue +0 -12
  12. package/composables/__tests__/use-playlist-follow.test.ts +54 -0
  13. package/composables/__tests__/use-snackbar.test.ts +47 -0
  14. package/composables/use-collection-stub.ts +16 -0
  15. package/composables/use-playlist-delete.ts +30 -0
  16. package/composables/use-playlist-follow.ts +42 -0
  17. package/composables/use-snackbar.ts +23 -0
  18. package/composables/use-user-playlists.ts +41 -0
  19. package/lib/index.ts +1 -0
  20. package/lib/scheme/navigation-items.ts +1 -1
  21. package/lib/scheme/sort-collections.ts +16 -0
  22. package/package.json +1 -1
  23. package/pages/following.vue +2 -2
  24. package/pages/library/favorites.vue +48 -0
  25. package/pages/library/index.vue +4 -4
  26. package/pages/library/my-playlists.vue +233 -0
  27. package/pages/library/playlists.vue +114 -0
  28. package/pages/library/watch-later.vue +48 -0
  29. package/pages/playlists/index.vue +5 -2
  30. package/runtime/utils/cleaners/__tests__/clean-video-card.test.ts +0 -3
  31. package/runtime/utils/cleaners/__tests__/clean-video-data.test.ts +0 -1
  32. package/runtime/utils/cleaners/clean-video-card.ts +0 -1
  33. package/runtime/utils/cleaners/clean-video-data.ts +0 -1
  34. package/stubs/collections.ts +81 -0
  35. package/stubs/history.ts +0 -1
  36. package/stubs/library-follows.ts +8 -13
  37. package/stubs/videos.ts +2 -3
  38. package/types/raw/raw-video-card.d.ts +1 -1
  39. package/types/raw/raw-video-data.d.ts +1 -1
  40. package/types/video-card.d.ts +0 -1
  41. 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-red-400;
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
- {{ formatDate(card.created) }}
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="16"
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
- <button
139
- :class="className"
140
- type="button"
141
- @click="emit('share', card)"
142
- >
143
- <UiIcon
144
- name="share"
145
- size="16"
146
- />
147
- {{ t('share') }}
148
- </button>
149
- <button
150
- :class="className"
151
- type="button"
152
- @click="onReport"
153
- >
154
- <UiIcon
155
- name="flag"
156
- size="16"
157
- />
158
- {{ t('report') }}
159
- </button>
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-playlist__private {
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
- /* Заход бейджа внутрь круга. На мобиле меньше: аватар там 80px против 96px, а сам
74
- бейдж не уменьшается — при одинаковом отступе он садится заметно глубже. */
75
- $badge-inset: 8px;
76
- $badge-inset--mobile: 4px;
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
- async function onDeleteClick() {
139
- try {
140
- const name = selectedPlaylist.value?.name;
138
+ // Сначала закрываем попап, потом сообщаем об удалении: снекбар с undo показывает владелец
139
+ // запроса (PlaylistMain), и всплыть он должен уже в макете, а не в закрывающемся диалоге.
140
+ function onDeleteClick() {
141
+ const playlistId = id.value || '';
142
+ const name = selectedPlaylist.value?.name;
141
143
 
142
- emit('delete-playlist', id.value || '');
143
- goBack();
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="data"
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 deletePlaylist(event: string) {
111
- await executeDeletePlaylist([event]);
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: {
@@ -11,7 +11,7 @@
11
11
  <script setup lang="ts">
12
12
  withDefaults(
13
13
  defineProps<{
14
- theme?: 'default' | 'primary' | 'primary-gradient' | 'yellow' | 'green' | 'white' | 'black' | 'backdrop' | 'secondary' | 'success' | 'blur' | 'grey'
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
+ });