itube-specs 0.0.914 → 0.0.915

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.
@@ -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;
@@ -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;
@@ -135,6 +135,17 @@
135
135
  />
136
136
  </template>
137
137
  <template #items="className">
138
+ <button
139
+ :class="className"
140
+ type="button"
141
+ @click="toggleFavorite(card.id, card.name)"
142
+ >
143
+ <UiIcon
144
+ name="heart-stroke"
145
+ size="16"
146
+ />
147
+ {{ favoritesLabel }}
148
+ </button>
138
149
  <button
139
150
  :class="className"
140
151
  type="button"
@@ -180,7 +191,7 @@ import { CSS_BREAKPOINTS } from '../../config/css-breakpoints';
180
191
  const props = defineProps<{
181
192
  card: IPlaylistCard
182
193
  topPlaylists?: boolean
183
- /** Без меню «три точки» — вместе с ним уходят share и report */
194
+ /** Без меню «три точки» — вместе с ним уходят избранное, share и report */
184
195
  noActions?: boolean
185
196
  priority?: boolean
186
197
  }>();
@@ -205,6 +216,23 @@ const userProfileData = computed(() => useState<IProfileData>('profileData').val
205
216
 
206
217
  const { isAuthorized } = useUser(AuthorizationApiService);
207
218
 
219
+ const { isFavorite, toggleFavorite } = usePlaylistCollections();
220
+
221
+ const favorite = computed(() => isFavorite(props.card.id));
222
+
223
+ // Тем же кликом, что переключает коллекцию, меню закрывается. Если менять надпись сразу, она
224
+ // дёргается прямо во время анимации закрытия, поэтому в меню живёт снапшот состояния — он
225
+ // догоняет реальное только к следующему открытию, когда пункт всё равно перерисовывается с нуля.
226
+ const shownFavorite = ref(favorite.value);
227
+
228
+ const favoritesLabel = computed(() => shownFavorite.value
229
+ ? t('video_actions.remove_favorites')
230
+ : t('video_actions.add_favorites'));
231
+
232
+ watch(() => dropdownRef.value?.open, (isOpen) => {
233
+ if (isOpen) shownFavorite.value = favorite.value;
234
+ });
235
+
208
236
  const avatar = computed(() => {
209
237
  if (isAuthorized.value && props.topPlaylists && userProfileData.value?.avatar) {
210
238
  return userProfileData.value.avatar;
@@ -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;
@@ -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,63 @@
1
+ // @vitest-environment nuxt
2
+ import { describe, it, expect, beforeEach, afterEach, vi } from 'vitest';
3
+ import { usePlaylistCollections } from '../use-playlist-collections';
4
+ import { useVideoCollections } from '../use-video-collections';
5
+ import { useSnackbar } from '../use-snackbar';
6
+
7
+ describe('usePlaylistCollections', () => {
8
+ beforeEach(() => {
9
+ useNuxtApp().payload.state = {};
10
+ vi.useFakeTimers();
11
+ useSnackbar().resetSnackbar();
12
+ });
13
+
14
+ afterEach(() => {
15
+ vi.useRealTimers();
16
+ });
17
+
18
+ it('добавляет в избранное и показывает снекбар с кнопкой отмены', () => {
19
+ const { favorites, isFavorite, toggleFavorite } = usePlaylistCollections();
20
+ const { snackbarText, snackbarIcon, snackbarHighlight, snackbarActionText } = useSnackbar();
21
+
22
+ toggleFavorite('playlist-1', 'Playlist name');
23
+
24
+ expect(favorites.value).toEqual([ 'playlist-1' ]);
25
+ expect(isFavorite('playlist-1')).toBe(true);
26
+ expect(snackbarText.value).toBe('added');
27
+ expect(snackbarIcon.value).toBe('heart');
28
+ expect(snackbarHighlight.value).toBe('Playlist name');
29
+ expect(snackbarActionText.value).toBe('undo');
30
+ });
31
+
32
+ it('удаление показывает снекбар «removed»', () => {
33
+ const { favorites, toggleFavorite } = usePlaylistCollections();
34
+ const { snackbarText } = useSnackbar();
35
+
36
+ toggleFavorite('playlist-1', 'Playlist name');
37
+ toggleFavorite('playlist-1', 'Playlist name');
38
+
39
+ expect(favorites.value).toEqual([]);
40
+ expect(snackbarText.value).toBe('removed');
41
+ });
42
+
43
+ it('отмена возвращает список и не показывает новый снекбар', () => {
44
+ const { favorites, toggleFavorite } = usePlaylistCollections();
45
+ const { runSnackbarAction, snackbarText } = useSnackbar();
46
+
47
+ toggleFavorite('playlist-2', 'Another name');
48
+ runSnackbarAction();
49
+
50
+ expect(favorites.value).toEqual([]);
51
+ expect(snackbarText.value).toBe('');
52
+ });
53
+
54
+ it('избранные плейлисты не смешиваются с избранными видео', () => {
55
+ const { favorites: playlistFavorites, toggleFavorite: togglePlaylist } = usePlaylistCollections();
56
+ const { favorites: videoFavorites } = useVideoCollections();
57
+
58
+ togglePlaylist('playlist-3', 'Third name');
59
+
60
+ expect(playlistFavorites.value).toEqual([ 'playlist-3' ]);
61
+ expect(videoFavorites.value).toEqual([]);
62
+ });
63
+ });
@@ -0,0 +1,39 @@
1
+ /**
2
+ * Заглушка избранных плейлистов — механика та же, что у видео (см. `useVideoCollections`),
3
+ * но список свой: плейлист не ролик, и на странице избранного они не должны смешиваться.
4
+ * Ручек в API пока нет, поэтому состояние живёт только на клиенте и сбрасывается при перезагрузке.
5
+ */
6
+ export const usePlaylistCollections = () => {
7
+ const favorites = useState<string[]>('playlist-collections-favorites', () => []);
8
+
9
+ const { show } = useSnackbar();
10
+
11
+ /** Возвращает true, если плейлист добавлен, и false — если убран. */
12
+ function toggle(id: string) {
13
+ const added = !favorites.value.includes(id);
14
+
15
+ favorites.value = added
16
+ ? [ ...favorites.value, id ]
17
+ : favorites.value.filter(item => item !== id);
18
+
19
+ return added;
20
+ }
21
+
22
+ const isFavorite = (id: string) => favorites.value.includes(id);
23
+
24
+ // Снекбар показывается только на исходное действие: откат возвращает список как был,
25
+ // повторно сообщать об этом нечего — иначе отмена сама выглядит как новое изменение.
26
+ const toggleFavorite = (id: string, name = '') => {
27
+ show(toggle(id) ? 'added' : 'removed', {
28
+ icon: 'heart',
29
+ highlight: name,
30
+ onAction: () => toggle(id),
31
+ });
32
+ };
33
+
34
+ return {
35
+ favorites,
36
+ isFavorite,
37
+ toggleFavorite,
38
+ };
39
+ };
package/package.json CHANGED
@@ -1,7 +1,7 @@
1
1
  {
2
2
  "name": "itube-specs",
3
3
  "type": "module",
4
- "version": "0.0.914",
4
+ "version": "0.0.915",
5
5
  "main": "./nuxt.config.ts",
6
6
  "types": "./types/index.d.ts",
7
7
  "scripts": {
@@ -5,7 +5,6 @@ describe('cleanVideoCard', () => {
5
5
  it('полные данные', () => {
6
6
  const raw = {
7
7
  guid: 'v-1',
8
- quality: '1080p',
9
8
  duration: 600,
10
9
  title: 'Video Title',
11
10
  views: 1000,
@@ -27,7 +26,6 @@ describe('cleanVideoCard', () => {
27
26
  };
28
27
  const result = cleanVideoCard(raw as any);
29
28
  expect(result.guid).toBe('v-1');
30
- expect(result.quality).toBe('1080p');
31
29
  expect(result.isNew).toBe(true);
32
30
  expect(result.channelName).toBe('Channel');
33
31
  expect(result.channelAvatar).toBe('https://img.com/ch.jpg');
@@ -44,7 +42,6 @@ describe('cleanVideoCard', () => {
44
42
  it('пустые данные → дефолты', () => {
45
43
  const result = cleanVideoCard({} as any);
46
44
  expect(result.guid).toBe('');
47
- expect(result.quality).toBe('');
48
45
  expect(result.duration).toBe(0);
49
46
  expect(result.views).toBe(0);
50
47
  expect(result.isNew).toBe(false);
@@ -6,7 +6,6 @@ describe('cleanVideoData', () => {
6
6
  const raw = {
7
7
  guid: 'v-1',
8
8
  md5: 'abc',
9
- quality: '1080p',
10
9
  duration: 600,
11
10
  title: 'Title',
12
11
  header: 'Header',
@@ -4,7 +4,6 @@ import { ThumbSize } from '../../constants/thumb-size';
4
4
  /** Нормализует сырую карточку видео к типу IVideoCard. */
5
5
  export const cleanVideoCard = (card: IRawVideoCard): IVideoCard => ({
6
6
  guid: card.guid || '',
7
- quality: card.quality || '',
8
7
  duration: card.duration || 0,
9
8
  title: card.title || '',
10
9
  views: card.views || 0,
@@ -39,7 +39,6 @@ const cleanHotspots = (raw: string[] | null): number[] | null => {
39
39
  export const cleanVideoData = (data: IRawVideoData): IVideoData => ({
40
40
  guid: data.guid || '',
41
41
  md5: data.md5 || '',
42
- quality: data.quality || '',
43
42
  duration: data.duration || 0,
44
43
  title: data.title || '',
45
44
  header: data.header || '',
package/stubs/history.ts CHANGED
@@ -11,7 +11,6 @@ export type HistoryStubCard = {
11
11
  duration: number;
12
12
  thumbUrls: IThumbUrls;
13
13
  previewUrl: string;
14
- quality?: string;
15
14
  channelName?: string;
16
15
  channelAvatar?: string;
17
16
  /** Доля просмотренного, 0–100 */
package/stubs/videos.ts CHANGED
@@ -5,7 +5,6 @@ export type StubVideo = {
5
5
  id: string;
6
6
  title: string;
7
7
  duration: number;
8
- quality?: string;
9
8
  thumbUrls: IThumbUrls;
10
9
  previewUrl: string;
11
10
  channelName?: string;
@@ -15,7 +14,7 @@ export type StubVideo = {
15
14
  /** Общий пул роликов для всех заглушек — порядок фиксирован, на него завязаны наборы-надстройки. */
16
15
  export const VIDEO_POOL: StubVideo[] = [
17
16
  { id: '445839845', title: 'Drew Sebastian Barebacks Nick Fitt: Muscle Daddy Tops Athletic Bottom on the Couch', duration: 486, thumbUrls: { webp: { '320x180': 'https://nlt01.gay.cool/a/8/d/a8dc253cad705d688508de096435a989/thumbs/320x180/3.webp', '480x270': 'https://nlt01.gay.cool/a/8/d/a8dc253cad705d688508de096435a989/thumbs/480x270/3.webp' } }, channelName: 'Icon Male', channelAvatar: 'https://nli01.joystorage.com/c/0/2/c02a3f8ef14943b5f3da537aaed46fcc.jpeg', previewUrl: 'https://nlp02.gay.cool/a/8/d/a8dc253cad705d688508de096435a989/preview/video.mp4' },
18
- { id: '171708405', title: 'Bareback bathroom fisting with black latex gloves, rimming, piss play and anal gape', duration: 358, quality: '1080', thumbUrls: { webp: { '320x180': 'https://nlt03.gay.cool/b/7/5/b75c1bf33571126845502e2b9bb5d687/thumbs/320x180/33.webp', '480x270': 'https://nlt03.gay.cool/b/7/5/b75c1bf33571126845502e2b9bb5d687/thumbs/480x270/33.webp' } }, previewUrl: 'https://nlp02.gay.cool/b/7/5/b75c1bf33571126845502e2b9bb5d687/preview/video.mp4' },
17
+ { id: '171708405', title: 'Bareback bathroom fisting with black latex gloves, rimming, piss play and anal gape', duration: 358, thumbUrls: { webp: { '320x180': 'https://nlt03.gay.cool/b/7/5/b75c1bf33571126845502e2b9bb5d687/thumbs/320x180/33.webp', '480x270': 'https://nlt03.gay.cool/b/7/5/b75c1bf33571126845502e2b9bb5d687/thumbs/480x270/33.webp' } }, previewUrl: 'https://nlp02.gay.cool/b/7/5/b75c1bf33571126845502e2b9bb5d687/preview/video.mp4' },
19
18
  { id: '374534215', title: 'Jake Davis Takes Bareback Anal in Blue Harness from Athletic Partner', duration: 487, thumbUrls: { webp: { '320x180': 'https://nlt02.gay.cool/2/4/6/246f577796c7c955b8df94f6454de2a5/thumbs/320x180/12.webp', '480x270': 'https://nlt02.gay.cool/2/4/6/246f577796c7c955b8df94f6454de2a5/thumbs/480x270/12.webp' } }, channelName: 'Icon Male', channelAvatar: 'https://nli01.joystorage.com/c/0/2/c02a3f8ef14943b5f3da537aaed46fcc.jpeg', previewUrl: 'https://nlp05.gay.cool/2/4/6/246f577796c7c955b8df94f6454de2a5/preview/video.mp4' },
20
19
  { id: '3377550', title: 'Rego Bello and Devin Adams bareback bedroom flip: reciprocal oral then Devin tops Rego', duration: 426, thumbUrls: { webp: { '320x180': 'https://nlt01.gay.cool/3/e/b/3eb0f19f1e600a98a7f5a5e51edc4b46/thumbs/320x180/33.webp', '480x270': 'https://nlt01.gay.cool/3/e/b/3eb0f19f1e600a98a7f5a5e51edc4b46/thumbs/480x270/33.webp' } }, previewUrl: 'https://nlp05.gay.cool/3/e/b/3eb0f19f1e600a98a7f5a5e51edc4b46/preview/video.mp4' },
21
20
  { id: '443170695', title: 'Johnny Rapid and Malakai White lead four-man hotel bareback with Max Lorde and Kyler Drayke', duration: 487, thumbUrls: { webp: { '320x180': 'https://nlt04.gay.cool/9/f/f/9ff067b9ea8b157ff2478667423ff9bb/thumbs/320x180/29.webp', '480x270': 'https://nlt04.gay.cool/9/f/f/9ff067b9ea8b157ff2478667423ff9bb/thumbs/480x270/29.webp' } }, channelName: 'Johnny Rapid', channelAvatar: 'https://nli01.joystorage.com/e/6/d/e6d0abd734a1a2eaef0baf7ceccfdb5c.jpeg', previewUrl: 'https://nlp01.gay.cool/9/f/f/9ff067b9ea8b157ff2478667423ff9bb/preview/video.mp4' },
@@ -25,5 +24,5 @@ export const VIDEO_POOL: StubVideo[] = [
25
24
  { id: '444068845', title: 'Dean Young Bareback Flip Fuck: Versatile Anal, Deepthroat and Rimming in the Bedroom', duration: 604, thumbUrls: { webp: { '320x180': 'https://nlt03.gay.cool/7/d/a/7daacbe876b51c7cdc50101b0d504be8/thumbs/320x180/16.webp', '480x270': 'https://nlt03.gay.cool/7/d/a/7daacbe876b51c7cdc50101b0d504be8/thumbs/480x270/16.webp' } }, previewUrl: 'https://nlp03.gay.cool/7/d/a/7daacbe876b51c7cdc50101b0d504be8/preview/video.mp4' },
26
25
  { id: '374968825', title: 'Brody Fox sucks and rides daddy Cain Marko bareback', duration: 497, thumbUrls: { webp: { '320x180': 'https://nlt01.gay.cool/9/e/2/9e23f59e882e860da3ab2400d93ac73e/thumbs/320x180/10.webp', '480x270': 'https://nlt01.gay.cool/9/e/2/9e23f59e882e860da3ab2400d93ac73e/thumbs/480x270/10.webp' } }, previewUrl: 'https://nlp03.gay.cool/9/e/2/9e23f59e882e860da3ab2400d93ac73e/preview/video.mp4' },
27
26
  { id: '377844995', title: 'Rick Lovatelli: blond twink dominated, deepthroats and gets barebacked by a suited bearded top', duration: 1627, thumbUrls: { webp: { '320x180': 'https://nlt05.gay.cool/f/1/b/f1b09d77a2e23f4b4696d97169614099/thumbs/320x180/25.webp', '480x270': 'https://nlt05.gay.cool/f/1/b/f1b09d77a2e23f4b4696d97169614099/thumbs/480x270/25.webp' } }, previewUrl: 'https://nlp01.gay.cool/f/1/b/f1b09d77a2e23f4b4696d97169614099/preview/video.mp4' },
28
- { id: '375132285', title: 'Viktor Rom and Jordan Fox bareback blue-haired bottom in bedroom threesome', duration: 800, quality: '1080', thumbUrls: { webp: { '320x180': 'https://nlt03.gay.cool/5/f/b/5fbcdd007476cd0a1e9c85abc9fc0042/thumbs/320x180/1.webp', '480x270': 'https://nlt03.gay.cool/5/f/b/5fbcdd007476cd0a1e9c85abc9fc0042/thumbs/480x270/1.webp' } }, previewUrl: 'https://nlp04.gay.cool/5/f/b/5fbcdd007476cd0a1e9c85abc9fc0042/preview/video.mp4' },
27
+ { id: '375132285', title: 'Viktor Rom and Jordan Fox bareback blue-haired bottom in bedroom threesome', duration: 800, thumbUrls: { webp: { '320x180': 'https://nlt03.gay.cool/5/f/b/5fbcdd007476cd0a1e9c85abc9fc0042/thumbs/320x180/1.webp', '480x270': 'https://nlt03.gay.cool/5/f/b/5fbcdd007476cd0a1e9c85abc9fc0042/thumbs/480x270/1.webp' } }, previewUrl: 'https://nlp04.gay.cool/5/f/b/5fbcdd007476cd0a1e9c85abc9fc0042/preview/video.mp4' },
29
28
  ];
@@ -37,7 +37,7 @@ interface IChannelVideo {
37
37
  export interface IRawVideoCard {
38
38
  guid: string;
39
39
  md5: string; // unused
40
- quality: string;
40
+ quality: string; // unused
41
41
  orientation: string; // unused
42
42
  duration: number;
43
43
  created: number; // unused
@@ -4,7 +4,7 @@ import type { ITimelineGroup } from '../video-data';
4
4
  export interface IRawVideoData {
5
5
  guid: string;
6
6
  md5: string;
7
- quality: string;
7
+ quality: string; // unused
8
8
  orientation: string; // unused
9
9
  duration: number;
10
10
  created: number; // unused
@@ -2,7 +2,6 @@ import type { IThumbUrls } from '~/types/thumbs-urls';
2
2
 
3
3
  export interface IVideoCard {
4
4
  guid: string;
5
- quality?: string;
6
5
  duration: number;
7
6
  title: string;
8
7
  views?: number;
@@ -32,7 +32,6 @@ export interface ISceneGroup {
32
32
  export interface IVideoData {
33
33
  guid: string;
34
34
  md5: string;
35
- quality: string;
36
35
  duration: number;
37
36
  title: string;
38
37
  header: string;