itube-specs 0.0.902 → 0.0.910

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 (42) hide show
  1. package/assets/icons/common/flag.svg +1 -1
  2. package/assets/icons/common/heart-stroke.svg +1 -1
  3. package/assets/icons/common/heart.svg +1 -1
  4. package/assets/icons/common/share.svg +1 -1
  5. package/assets/icons/common/thumbs-down-filled.svg +1 -0
  6. package/assets/icons/common/thumbs-down.svg +1 -1
  7. package/assets/icons/common/thumbs-up-filled.svg +1 -0
  8. package/assets/icons/common/thumbs-up.svg +1 -1
  9. package/assets/scss/themes/dark/_theme.scss +49 -19
  10. package/assets/scss/themes/light/_theme.scss +46 -16
  11. package/assets/scss/vars/_palette.scss +5 -0
  12. package/assets/scss/vars/_vars.scss +1 -0
  13. package/components/auth/login.vue +2 -2
  14. package/components/auth/recovery.vue +2 -2
  15. package/components/auth/register.vue +2 -2
  16. package/components/card/collections-more.vue +133 -0
  17. package/components/card/playlist.vue +6 -1
  18. package/components/grid/playlists.vue +3 -0
  19. package/components/info/main.vue +18 -14
  20. package/components/library/feed.vue +20 -39
  21. package/components/library/follows.vue +19 -4
  22. package/components/player/interface.vue +132 -58
  23. package/components/player/like.vue +82 -60
  24. package/components/playlist/add.vue +2 -2
  25. package/components/playlist/delete-video.vue +2 -2
  26. package/components/playlist/edit.vue +3 -3
  27. package/components/profile/delete.vue +2 -2
  28. package/components/profile/setup.vue +2 -2
  29. package/components/report/main.vue +9 -4
  30. package/components/search/input.vue +9 -9
  31. package/components/section/collections.vue +110 -0
  32. package/components/ui/follow-btn.vue +162 -0
  33. package/components/ui/section-title.vue +2 -0
  34. package/components/ui/snackbar.vue +93 -44
  35. package/composables/__tests__/use-snackbar.test.ts +126 -20
  36. package/composables/use-snackbar.ts +75 -20
  37. package/composables/use-user.ts +4 -4
  38. package/package.json +1 -1
  39. package/pages/contacts.vue +2 -2
  40. package/pages/library/index.vue +1 -1
  41. package/pages/playlists/index.vue +55 -4
  42. package/assets/icons/common/crest-in-circle.svg +0 -1
@@ -15,43 +15,40 @@ describe('useSnackbar', () => {
15
15
  });
16
16
 
17
17
  it('дефолтное состояние', () => {
18
- const { snackbarTheme, snackbarText, snackbarIcon } = useSnackbar();
19
- expect(snackbarTheme.value).toBe('success');
18
+ const { snackbarText, snackbarIcon, snackbarHighlight, snackbarActionText } = useSnackbar();
20
19
  expect(snackbarText.value).toBe('');
21
20
  expect(snackbarIcon.value).toBe('check');
21
+ expect(snackbarHighlight.value).toBe('');
22
+ expect(snackbarActionText.value).toBe('');
22
23
  });
23
24
 
24
- it('showSuccess показывает и авто-сбрасывает через 2000мс', () => {
25
- const { showSuccess, snackbarText, snackbarTheme, snackbarIcon } = useSnackbar();
26
- showSuccess('Done');
25
+ it('show показывает и авто-сбрасывает через 4000мс', () => {
26
+ const { show, snackbarText, snackbarIcon } = useSnackbar();
27
+ show('Done');
27
28
  expect(snackbarText.value).toBe('Done');
28
- expect(snackbarTheme.value).toBe('success');
29
29
  expect(snackbarIcon.value).toBe('check');
30
30
 
31
- vi.advanceTimersByTime(1999);
31
+ vi.advanceTimersByTime(3999);
32
32
  expect(snackbarText.value).toBe('Done');
33
33
  vi.advanceTimersByTime(1);
34
34
  expect(snackbarText.value).toBe('');
35
35
  });
36
36
 
37
37
  it('showError показывает и авто-сбрасывает через 4000мс', () => {
38
- const { showError, snackbarText, snackbarTheme, snackbarIcon } = useSnackbar();
38
+ const { showError, snackbarText, snackbarIcon } = useSnackbar();
39
39
  showError('Error!');
40
40
  expect(snackbarText.value).toBe('Error!');
41
- expect(snackbarTheme.value).toBe('error');
42
41
  expect(snackbarIcon.value).toBe('close');
43
42
 
44
43
  vi.advanceTimersByTime(3999);
45
44
  expect(snackbarText.value).toBe('Error!');
46
45
  vi.advanceTimersByTime(1);
47
46
  expect(snackbarText.value).toBe('');
48
- expect(snackbarTheme.value).toBe('success');
49
47
  });
50
48
 
51
49
  it('setErrorState нормализует ошибку и показывает её', () => {
52
- const { setErrorState, snackbarTheme, snackbarIcon, snackbarText } = useSnackbar();
50
+ const { setErrorState, snackbarIcon, snackbarText } = useSnackbar();
53
51
  setErrorState({ message: 'Something failed' });
54
- expect(snackbarTheme.value).toBe('error');
55
52
  expect(snackbarIcon.value).toBe('close');
56
53
  expect(snackbarText.value).toBe('Something failed');
57
54
  });
@@ -67,19 +64,128 @@ describe('useSnackbar', () => {
67
64
  expect(snackbarText.value).toBe('');
68
65
  });
69
66
 
70
- it('showErrorSnack остаётся алиасом showError', () => {
71
- const { showErrorSnack, snackbarText, snackbarTheme } = useSnackbar();
72
- showErrorSnack('Legacy');
73
- expect(snackbarText.value).toBe('Legacy');
74
- expect(snackbarTheme.value).toBe('error');
67
+ it('опции задают иконку и подсветку', () => {
68
+ const { show, snackbarIcon, snackbarHighlight } = useSnackbar();
69
+ show('Unfollowed', { icon: 'minus', highlight: 'Top To Bottom' });
70
+ expect(snackbarIcon.value).toBe('minus');
71
+ expect(snackbarHighlight.value).toBe('Top To Bottom');
72
+ });
73
+
74
+ it('onAction включает кнопку с ключом undo по умолчанию и держит снекбар 6000мс', () => {
75
+ const { show, snackbarActionText, snackbarText } = useSnackbar();
76
+ show('Unfollowed', { onAction: () => {} });
77
+ expect(snackbarActionText.value).toBe('undo');
78
+
79
+ vi.advanceTimersByTime(5999);
80
+ expect(snackbarText.value).toBe('Unfollowed');
81
+ vi.advanceTimersByTime(1);
82
+ expect(snackbarText.value).toBe('');
83
+ expect(snackbarActionText.value).toBe('');
84
+ });
85
+
86
+ it('actionText переопределяет ключ подписи кнопки', () => {
87
+ const { show, snackbarActionText } = useSnackbar();
88
+ show('Video removed', { onAction: () => {}, actionText: 'restore' });
89
+ expect(snackbarActionText.value).toBe('restore');
90
+ });
91
+
92
+ it('без onAction кнопки нет', () => {
93
+ const { show, snackbarActionText } = useSnackbar();
94
+ show('Done');
95
+ expect(snackbarActionText.value).toBe('');
96
+ });
97
+
98
+ it('runSnackbarAction вызывает колбэк и закрывает снекбар', () => {
99
+ const { show, runSnackbarAction, snackbarText, snackbarActionText } = useSnackbar();
100
+ const undo = vi.fn();
101
+ show('Unfollowed', { onAction: undo });
102
+
103
+ runSnackbarAction();
104
+ expect(undo).toHaveBeenCalledTimes(1);
105
+ expect(snackbarText.value).toBe('');
106
+ expect(snackbarActionText.value).toBe('');
107
+
108
+ runSnackbarAction();
109
+ expect(undo).toHaveBeenCalledTimes(1);
110
+ });
111
+
112
+ it('новый показ без onAction сбрасывает предыдущий колбэк', () => {
113
+ const { show, runSnackbarAction } = useSnackbar();
114
+ const undo = vi.fn();
115
+ show('Unfollowed', { onAction: undo });
116
+ show('Done');
117
+
118
+ runSnackbarAction();
119
+ expect(undo).not.toHaveBeenCalled();
120
+ });
121
+
122
+ it('pauseDismiss останавливает автозакрытие', () => {
123
+ const { show, pauseDismiss, snackbarText } = useSnackbar();
124
+ show('Done');
125
+
126
+ vi.advanceTimersByTime(1000);
127
+ pauseDismiss();
128
+
129
+ vi.advanceTimersByTime(60_000);
130
+ expect(snackbarText.value).toBe('Done');
131
+ });
132
+
133
+ it('resumeDismiss добирает остаток, а не начинает заново', () => {
134
+ const { show, pauseDismiss, resumeDismiss, snackbarText } = useSnackbar();
135
+ show('Done');
136
+
137
+ vi.advanceTimersByTime(3000);
138
+ pauseDismiss();
139
+ vi.advanceTimersByTime(10_000);
140
+ resumeDismiss();
141
+
142
+ vi.advanceTimersByTime(999);
143
+ expect(snackbarText.value).toBe('Done');
144
+ vi.advanceTimersByTime(1);
145
+ expect(snackbarText.value).toBe('');
146
+ });
147
+
148
+ it('повторная пауза не съедает остаток дважды', () => {
149
+ const { show, pauseDismiss, resumeDismiss, snackbarText } = useSnackbar();
150
+ show('Done');
151
+
152
+ vi.advanceTimersByTime(1000);
153
+ pauseDismiss();
154
+ vi.advanceTimersByTime(5000);
155
+ pauseDismiss();
156
+ resumeDismiss();
157
+
158
+ vi.advanceTimersByTime(2999);
159
+ expect(snackbarText.value).toBe('Done');
160
+ vi.advanceTimersByTime(1);
161
+ expect(snackbarText.value).toBe('');
162
+ });
163
+
164
+ it('resumeDismiss без показанного снекбара ничего не запускает', () => {
165
+ const { resumeDismiss, snackbarText } = useSnackbar();
166
+ resumeDismiss();
167
+
168
+ vi.advanceTimersByTime(10_000);
169
+ expect(snackbarText.value).toBe('');
170
+ });
171
+
172
+ it('новый show перебивает паузу предыдущего снекбара', () => {
173
+ const { show, pauseDismiss, snackbarText } = useSnackbar();
174
+ show('First');
175
+ pauseDismiss();
176
+ show('Second');
177
+
178
+ vi.advanceTimersByTime(4000);
179
+ expect(snackbarText.value).toBe('');
75
180
  });
76
181
 
77
182
  it('resetSnackbar сбрасывает всё', () => {
78
- const { showError, resetSnackbar, snackbarText, snackbarTheme, snackbarIcon } = useSnackbar();
79
- showError('Error');
183
+ const { show, resetSnackbar, snackbarText, snackbarIcon, snackbarHighlight, snackbarActionText } = useSnackbar();
184
+ show('Unfollowed', { icon: 'minus', highlight: 'Top To Bottom', onAction: () => {} });
80
185
  resetSnackbar();
81
186
  expect(snackbarText.value).toBe('');
82
- expect(snackbarTheme.value).toBe('success');
83
187
  expect(snackbarIcon.value).toBe('check');
188
+ expect(snackbarHighlight.value).toBe('');
189
+ expect(snackbarActionText.value).toBe('');
84
190
  });
85
191
  });
@@ -1,21 +1,42 @@
1
1
  import { parseApiError } from '../runtime';
2
2
 
3
- const SUCCESS_TIME = 2000;
4
- const ERROR_TIME = 4000;
3
+ const DISMISS_TIME = 4000;
4
+ const ACTION_DISMISS_TIME = 6000;
5
5
 
6
6
  // Один таймер на логическое уведомление. Снекбар физически переезжает между
7
- // диалогом (s-popup) и общим макетом (default.vue), поэтому таймер не может жить
7
+ // диалогом (ui-popup) и общим макетом (default.vue), поэтому таймер не может жить
8
8
  // в компоненте — при перемонтировании он сбрасывался бы заново. Идентификатор
9
9
  // setTimeout создаётся только в браузере, на сервере он не заводится.
10
10
  let dismissTimer: null | ReturnType<typeof setTimeout> = null;
11
11
 
12
- /** Управляет состоянием snackbar-уведомления: показ успеха и ошибки, сброс, тема, иконка и таймер автозакрытия. */
12
+ // Остаток автозакрытия и момент его запуска — чтобы навести на снекбар, прочитать
13
+ // сообщение и дожать undo: на ховере/фокусе таймер встаёт на паузу, после уходя
14
+ // курсора он добирает недоигранное время, а не начинает отсчёт заново.
15
+ let dismissRemaining = 0;
16
+ let dismissStartedAt = 0;
17
+
18
+ // Колбэк кнопки лежит рядом с таймером, а не в useState: функция не сериализуется
19
+ // в payload, да и показывается снекбар всегда с клиента.
20
+ let actionHandler: null | (() => unknown) = null;
21
+
22
+ export interface ISnackbarOptions {
23
+ /** Имя иконки в кружке слева. */
24
+ icon?: string;
25
+ /** Часть сообщения, выводимая жирным — имя канала, плейлиста, видео. */
26
+ highlight?: string;
27
+ /** i18n-ключ внутри `snackbar.*` для подписи кнопки, по умолчанию `undo`. */
28
+ actionText?: string;
29
+ /** Действие кнопки — как правило откат того, о чём сообщает снекбар. */
30
+ onAction?: () => unknown;
31
+ }
32
+
33
+ /** Управляет состоянием snackbar-уведомления: показ сообщения, кнопка отмены действия, сброс, иконка и таймер автозакрытия. */
13
34
  export const useSnackbar = () => {
14
35
  const snackbarIcon = useState('snackbar-icon', () => 'check');
15
36
  const snackbarText = useState('snackbar-text', () => '');
16
- const snackbarButtonText = useState('snackbar-button-text', () => '');
37
+ const snackbarHighlight = useState('snackbar-highlight', () => '');
38
+ const snackbarActionText = useState('snackbar-action-text', () => '');
17
39
  const isSnackBarInPopup = useState('snackbar-in-popup', () => false);
18
- const snackbarTheme = useState<'success' | 'error' | 'default'>('snackbar-theme', () => 'success');
19
40
 
20
41
  function clearTimer() {
21
42
  if (dismissTimer) {
@@ -26,46 +47,80 @@ export const useSnackbar = () => {
26
47
 
27
48
  function scheduleDismiss(ms: number) {
28
49
  clearTimer();
50
+ dismissRemaining = ms;
29
51
  if (import.meta.client) {
52
+ dismissStartedAt = Date.now();
30
53
  dismissTimer = setTimeout(resetSnackbar, ms);
31
54
  }
32
55
  }
33
56
 
34
- function showSuccess(message: string) {
35
- snackbarTheme.value = 'success';
36
- snackbarIcon.value = 'check';
37
- snackbarText.value = message;
38
- scheduleDismiss(SUCCESS_TIME);
57
+ /** Пауза автозакрытия, пока на снекбаре курсор или фокус. */
58
+ function pauseDismiss() {
59
+ if (!dismissTimer) return;
60
+
61
+ dismissRemaining -= Date.now() - dismissStartedAt;
62
+ clearTimer();
63
+ }
64
+
65
+ /** Возврат к отсчёту с того места, где он остановился. */
66
+ function resumeDismiss() {
67
+ if (dismissTimer || !snackbarText.value) return;
68
+
69
+ if (dismissRemaining <= 0) {
70
+ resetSnackbar();
71
+ return;
72
+ }
73
+
74
+ scheduleDismiss(dismissRemaining);
39
75
  }
40
76
 
41
- function showError(message: string) {
42
- snackbarTheme.value = 'error';
43
- snackbarIcon.value = 'close';
77
+ function show(message: string, options: ISnackbarOptions = {}) {
78
+ actionHandler = options.onAction ?? null;
79
+ snackbarIcon.value = options.icon ?? 'check';
44
80
  snackbarText.value = message;
45
- scheduleDismiss(ERROR_TIME);
81
+ snackbarHighlight.value = options.highlight ?? '';
82
+ snackbarActionText.value = actionHandler ? options.actionText ?? 'undo' : '';
83
+ scheduleDismiss(actionHandler ? ACTION_DISMISS_TIME : DISMISS_TIME);
84
+ }
85
+
86
+ /** Ошибка отличается от обычного сообщения только иконкой — вид у снекбара один. */
87
+ function showError(message: string, options: ISnackbarOptions = {}) {
88
+ show(message, { icon: 'close', ...options });
46
89
  }
47
90
 
48
91
  function setErrorState(error: any) {
49
92
  showError(parseApiError(error));
50
93
  }
51
94
 
95
+ /** Запускает действие кнопки и закрывает снекбар, чтобы отменить нельзя было дважды. */
96
+ function runSnackbarAction() {
97
+ const handler = actionHandler;
98
+ resetSnackbar();
99
+ handler?.();
100
+ }
101
+
52
102
  function resetSnackbar() {
53
103
  clearTimer();
104
+ dismissRemaining = 0;
105
+ actionHandler = null;
54
106
  snackbarIcon.value = 'check';
55
107
  snackbarText.value = '';
56
- snackbarTheme.value = 'success';
108
+ snackbarHighlight.value = '';
109
+ snackbarActionText.value = '';
57
110
  }
58
111
 
59
112
  return {
60
113
  snackbarIcon,
61
114
  snackbarText,
62
- snackbarButtonText,
63
- snackbarTheme,
115
+ snackbarHighlight,
116
+ snackbarActionText,
64
117
  isSnackBarInPopup,
65
- showSuccess,
118
+ show,
66
119
  showError,
67
120
  setErrorState,
121
+ runSnackbarAction,
68
122
  resetSnackbar,
69
- showErrorSnack: showError,
123
+ pauseDismiss,
124
+ resumeDismiss,
70
125
  };
71
126
  };
@@ -23,7 +23,7 @@ function omit<T extends object, K extends keyof T>(obj: T, keys: K[]): Omit<T, K
23
23
  * @param apiService - сервис для API-запросов
24
24
  */
25
25
  export const useUser = (apiService) => {
26
- const { showSuccess, showError } = useSnackbar();
26
+ const { show, showError } = useSnackbar();
27
27
  const register = async (form: IRegistrateForm) => {
28
28
  const response = await apiService.register(form);
29
29
  if (response) {
@@ -72,7 +72,7 @@ export const useUser = (apiService) => {
72
72
  const changePassword = async (form: IChangePasswordForm) => {
73
73
  try {
74
74
  await apiService.changePassword(form);
75
- showSuccess('Password changed');
75
+ show('Password changed');
76
76
  } catch (err) {
77
77
  showError('Error while changing password');
78
78
  console.log(err, 'error change password');
@@ -82,7 +82,7 @@ export const useUser = (apiService) => {
82
82
  const changeEmail = async (form: IChangeEmail) => {
83
83
  try {
84
84
  await apiService.changeEmail(form);
85
- showSuccess('email_changed');
85
+ show('email_changed');
86
86
  } catch (err) {
87
87
  showError('Error while changing email');
88
88
  console.log(err, 'error change email');
@@ -93,7 +93,7 @@ export const useUser = (apiService) => {
93
93
  try {
94
94
  const { generateLink } = useGenerateLink();
95
95
  await apiService.recoveryPassword(form);
96
- showSuccess('Password recovered');
96
+ show('Password recovered');
97
97
  await useRouter().push(generateLink('/profile'));
98
98
  } catch (err) {
99
99
  showError('Error while recovery password');
package/package.json CHANGED
@@ -1,7 +1,7 @@
1
1
  {
2
2
  "name": "itube-specs",
3
3
  "type": "module",
4
- "version": "0.0.902",
4
+ "version": "0.0.910",
5
5
  "main": "./nuxt.config.ts",
6
6
  "types": "./types/index.d.ts",
7
7
  "scripts": {
@@ -175,7 +175,7 @@ const requiredFields = computed(() => getSubjectItems.value?.filter(item => item
175
175
 
176
176
  const loading = ref(false);
177
177
 
178
- const { showSuccess, showError } = useSnackbar();
178
+ const { show, showError } = useSnackbar();
179
179
 
180
180
  // Загружаем reCAPTCHA при открытии формы (onMounted) — даёт Google время собрать данные о поведении
181
181
  onMounted(async () => {
@@ -247,7 +247,7 @@ async function submit() {
247
247
  }
248
248
 
249
249
  await ContactsApiService.email(form.value);
250
- showSuccess('email_send');
250
+ show('email_send');
251
251
  resetForm();
252
252
  } catch (error) {
253
253
  console.log('error send email', error);
@@ -323,7 +323,7 @@ const breadcrumbs = [
323
323
  margin: 0;
324
324
  padding: 0;
325
325
  list-style: none;
326
- grid-template-columns: repeat(auto-fill, minmax(220px, 1fr));
326
+ grid-template-columns: repeat(auto-fill, minmax(170px, 1fr));
327
327
 
328
328
  @include from-br(sm) {
329
329
  gap: 24px;
@@ -8,6 +8,31 @@
8
8
  class="_breadcrumbs"
9
9
  />
10
10
 
11
+ <header class="playlists__header">
12
+ <UiSectionTitle
13
+ v-if="h1"
14
+ :title="h1"
15
+ :count="publicData.items.length"
16
+ icon="list"
17
+ />
18
+
19
+ <UiLink
20
+ :to="generateLink('/library')"
21
+ theme="secondary"
22
+ class="playlists__my-link"
23
+ >
24
+ <UiIcon
25
+ name="playlist"
26
+ size="16"
27
+ />
28
+ {{ $t('my_playlists_count', { count: myPlaylistsCount }) }}
29
+ <UiIcon
30
+ name="arrow-right"
31
+ size="16"
32
+ />
33
+ </UiLink>
34
+ </header>
35
+
11
36
  <ClientOnly>
12
37
  <template v-if="isAuthorized && filteredUserItems && filteredUserItems.length > 0">
13
38
  <UiSectionTitle
@@ -15,6 +40,7 @@
15
40
  :count="filteredUserItems.length"
16
41
  class="playlists__title"
17
42
  title-tag="h2"
43
+ size="xl"
18
44
  icon="clapperboard-play"
19
45
  />
20
46
 
@@ -27,12 +53,13 @@
27
53
  </template>
28
54
  </ClientOnly>
29
55
 
56
+ <SectionCollections :data-items="publicData.items"/>
57
+
30
58
  <UiSectionTitle
31
- v-if="h1"
32
- :title="h1"
33
- :count="publicData.items.length"
59
+ :title="$t('user_playlists')"
34
60
  class="playlists__title"
35
- icon="list"
61
+ title-tag="h2"
62
+ size="xl"
36
63
  />
37
64
 
38
65
  <SectionNavLinks
@@ -119,6 +146,11 @@ watch(
119
146
 
120
147
  const { t } = useI18n();
121
148
 
149
+ const { generateLink } = useGenerateLink();
150
+
151
+ /** До загрузки своих плейлистов (и у гостя) в скобках 0 */
152
+ const myPlaylistsCount = computed(() => filteredUserItems.value?.length ?? 0);
153
+
122
154
  const pagePureName = useGetPureRouteName();
123
155
  const { meta, h1 } = useMeta({
124
156
  t,
@@ -143,6 +175,25 @@ const breadcrumbs = [
143
175
  </script>
144
176
 
145
177
  <style lang="scss">
178
+ .playlists__header {
179
+ display: flex;
180
+ flex-direction: column;
181
+ align-items: flex-start;
182
+ row-gap: 12px;
183
+ margin-bottom: 24px;
184
+
185
+ @include from-br(xm) {
186
+ flex-direction: row;
187
+ align-items: center;
188
+ justify-content: space-between;
189
+ column-gap: 16px;
190
+ }
191
+ }
192
+
193
+ .playlists__my-link {
194
+ flex-shrink: 0;
195
+ }
196
+
146
197
  .playlists__title {
147
198
  margin-bottom: 24px;
148
199
  }
@@ -1 +0,0 @@
1
- <svg fill="none" stroke="currentColor" stroke-width="2" class="w-4 h-4" viewBox="0 0 24 24"><circle cx="12" cy="12" r="10"/><path d="M12 8v8m-4-4h8"/></svg>