itube-specs 0.0.892 → 0.0.895

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.
@@ -0,0 +1,147 @@
1
+ <!-- Список подписок «Библиотеки»: сортировка, блоки лент, подтверждение отписки -->
2
+ <template>
3
+ <div class="library-follows">
4
+ <UiSectionTitle
5
+ :title="title"
6
+ :count="items.length"
7
+ :icon="icon"
8
+ class="library-follows__title"
9
+ />
10
+
11
+ <div class="library-follows__sort">
12
+ <UiBtn
13
+ v-for="option in sortItems"
14
+ :key="option.name"
15
+ :theme="sort === option.name ? 'primary' : 'secondary'"
16
+ @click="sort = option.name"
17
+ >{{ option.title }}</UiBtn>
18
+ </div>
19
+
20
+ <div
21
+ v-if="sortedItems.length > 0"
22
+ class="library-follows__list"
23
+ >
24
+ <LibraryFeed
25
+ v-for="(item, index) in sortedItems"
26
+ :key="item.guid"
27
+ :item="item"
28
+ :following="isFollowing(item)"
29
+ :priority="index === 0"
30
+ @toggle-follow="onToggleFollow"
31
+ />
32
+ </div>
33
+
34
+ <UiEmptyState
35
+ v-else
36
+ without-reset
37
+ />
38
+
39
+ <UiConfirm
40
+ v-model="isUnfollowOpen"
41
+ :title="t('library.unfollow_title', { name: pendingUnfollow?.title })"
42
+ :confirm-text="t('library.unfollow_confirm')"
43
+ @confirm="onUnfollowConfirm"
44
+ >
45
+ <p class="library-follows__confirm">{{ t('library.unfollow_text') }}</p>
46
+ </UiConfirm>
47
+ </div>
48
+ </template>
49
+
50
+ <script setup lang="ts">
51
+ import type { ILibraryFeedItem } from '../../types';
52
+
53
+ const LibrarySort = {
54
+ New: 'new',
55
+ Alphabet: 'alphabet',
56
+ } as const;
57
+
58
+ type LibrarySortValue = typeof LibrarySort[keyof typeof LibrarySort];
59
+
60
+ const props = defineProps<{
61
+ items: ILibraryFeedItem[]
62
+ title: string
63
+ icon: string
64
+ }>();
65
+
66
+ const { t } = useI18n();
67
+
68
+ const sort = ref<LibrarySortValue>(LibrarySort.New);
69
+
70
+ const sortItems = computed(() => [
71
+ { name: LibrarySort.New, title: t('library.sort_new_first') },
72
+ { name: LibrarySort.Alphabet, title: t('library.sort_alphabet') },
73
+ ]);
74
+
75
+ /**
76
+ * «Сначала новые» — по числу непросмотренных новинок, а не по дате обновления: подписка
77
+ * с тремя новыми роликами важнее свежей, но с одним. Порядок равных сохраняет сортировка.
78
+ */
79
+ const sortedItems = computed(() => {
80
+ const items = [...props.items];
81
+
82
+ return sort.value === LibrarySort.Alphabet
83
+ ? items.sort((a, b) => a.title.localeCompare(b.title))
84
+ : items.sort((a, b) => b.newCount - a.newCount);
85
+ });
86
+
87
+ const unfollowed = ref<string[]>([]);
88
+
89
+ function isFollowing(item: ILibraryFeedItem) {
90
+ return !unfollowed.value.includes(item.guid);
91
+ }
92
+
93
+ /** Сущность, для которой открыт попап отписки. */
94
+ const pendingUnfollow = ref<ILibraryFeedItem | null>(null);
95
+
96
+ const isUnfollowOpen = computed({
97
+ get: () => pendingUnfollow.value !== null,
98
+ set: value => {
99
+ if (!value) pendingUnfollow.value = null;
100
+ },
101
+ });
102
+
103
+ /** Подписка — сразу, отписка — только через подтверждение: она сносит ленту обновлений. */
104
+ function onToggleFollow(item: ILibraryFeedItem) {
105
+ if (isFollowing(item)) {
106
+ pendingUnfollow.value = item;
107
+ return;
108
+ }
109
+
110
+ unfollowed.value = unfollowed.value.filter(guid => guid !== item.guid);
111
+ }
112
+
113
+ function onUnfollowConfirm() {
114
+ if (!pendingUnfollow.value) return;
115
+
116
+ unfollowed.value = [...unfollowed.value, pendingUnfollow.value.guid];
117
+ pendingUnfollow.value = null;
118
+ }
119
+ </script>
120
+
121
+ <style lang="scss">
122
+ .library-follows__title {
123
+ margin-bottom: 16px;
124
+ }
125
+
126
+ .library-follows__sort {
127
+ display: flex;
128
+ flex-wrap: wrap;
129
+ align-items: center;
130
+ gap: 8px;
131
+ margin-bottom: 24px;
132
+ }
133
+
134
+ .library-follows__list {
135
+ display: grid;
136
+ row-gap: 24px;
137
+ }
138
+
139
+ .library-follows__confirm {
140
+ @include font-sm;
141
+
142
+ max-width: 400px;
143
+ margin: 0 auto;
144
+ text-align: center;
145
+ color: $page-library-confirm-text;
146
+ }
147
+ </style>
@@ -5,6 +5,7 @@
5
5
  { '--expand-show': isExpandOpened },
6
6
  { '--alphabet': alphabet },
7
7
  { '--mobile-expand': mobileExpand },
8
+ { '--micro': micro },
8
9
  ]"
9
10
  class="ui-expand-row"
10
11
  >
@@ -27,12 +28,13 @@
27
28
  :alphabet="alphabet"
28
29
  is-link
29
30
  :mini="mini"
31
+ :micro="micro"
30
32
  />
31
33
  </slot>
32
34
  <div
33
35
  v-if="!hideMore"
34
36
  v-show="isElementsOverflow"
35
- :class="{'--small': mini}"
37
+ :class="{'--small': mini, '--micro': micro}"
36
38
  class="ui-expand-row__more-wrapper"
37
39
  >
38
40
  <UiChips
@@ -41,6 +43,7 @@
41
43
  icon: isExpandOpened ? 'minus' : 'plus',
42
44
  }"
43
45
  :mini="mini"
46
+ :micro="micro"
44
47
  class="ui-expand-row__button --more"
45
48
  @click="onMoreClick"
46
49
  />
@@ -57,6 +60,7 @@ defineProps<{
57
60
  alphabet?: boolean;
58
61
  hideMore?: boolean;
59
62
  mini?: boolean;
63
+ micro?: boolean;
60
64
  mobileExpand?: boolean;
61
65
  preText?: string;
62
66
  }>();
@@ -167,6 +171,12 @@ onBeforeUnmount(() => {
167
171
  }
168
172
  }
169
173
 
174
+ /* Обрезка идёт по высоте одной строки, а она зависит от размера чипса — иначе из-под края
175
+ выглядывает следующий ряд. Правило стоит до `--expand-show`: у них равная специфичность. */
176
+ .ui-expand-row.--micro {
177
+ max-height: var(--chips-height-micro);
178
+ }
179
+
170
180
  .ui-expand-row__wrapper {
171
181
  display: inline-flex;
172
182
  flex-wrap: wrap;
@@ -193,7 +203,7 @@ onBeforeUnmount(() => {
193
203
  height: var(--chips-height);
194
204
  z-index: $z-index-plus;
195
205
  padding-left: 40px;
196
- background: linear-gradient(90deg, $expand-row-fade-start 0%, $expand-row-fade-end 22.64%);
206
+ background: linear-gradient(90deg, $expand-row-fade-start 0%, var(--expand-row-fade-end, #{$expand-row-fade-end}) 22.64%);
197
207
  }
198
208
 
199
209
  .ui-expand-row__more-wrapper.--small {
@@ -204,6 +214,15 @@ onBeforeUnmount(() => {
204
214
  }
205
215
  }
206
216
 
217
+ .ui-expand-row__more-wrapper.--micro {
218
+ top: 0;
219
+ height: var(--chips-height-micro);
220
+ }
221
+
222
+ .ui-expand-row.--micro .ui-expand-row__wrapper {
223
+ gap: 4px;
224
+ }
225
+
207
226
  .ui-expand-row__button.--more {
208
227
  text-transform: capitalize;
209
228
  }
@@ -0,0 +1,62 @@
1
+ <!-- Видео-предпросмотр (автоплей) поверх постера + индикатор загрузки -->
2
+ <template>
3
+ <span
4
+ :class="{'_loading': !isPlaying}"
5
+ class="ui-video-preview"
6
+ >
7
+ <video
8
+ ref="videoRef"
9
+ :src="url"
10
+ :class="{'--playing': isPlaying}"
11
+ class="ui-video-preview__video"
12
+ loop
13
+ muted
14
+ autoplay
15
+ playsinline
16
+ @playing="isPlaying = true"
17
+ @waiting="isPlaying = false"
18
+ @pause="isPlaying = false"
19
+ />
20
+ </span>
21
+ </template>
22
+
23
+ <script setup lang="ts">
24
+ defineProps<{
25
+ url: string
26
+ }>();
27
+
28
+ const videoRef = ref<HTMLVideoElement | null>(null);
29
+ const isPlaying = ref(false);
30
+
31
+ onMounted(() => {
32
+ const video = videoRef.value;
33
+ if (!video) return;
34
+
35
+ video.muted = true;
36
+ video.play().catch(() => {
37
+ isPlaying.value = false;
38
+ });
39
+ });
40
+ </script>
41
+
42
+ <style lang="scss">
43
+ .ui-video-preview {
44
+ --size: 40px;
45
+
46
+ position: absolute;
47
+ inset: 0;
48
+ z-index: $z-index-plus;
49
+ }
50
+
51
+ .ui-video-preview__video {
52
+ width: 100%;
53
+ height: 100%;
54
+ object-fit: cover;
55
+ opacity: 0;
56
+ }
57
+
58
+ /* Пока автоплей не стартовал, браузер рисует поверх видео нативную кнопку плей — держим элемент невидимым */
59
+ .ui-video-preview__video.--playing {
60
+ opacity: 1;
61
+ }
62
+ </style>
package/package.json CHANGED
@@ -1,7 +1,7 @@
1
1
  {
2
2
  "name": "itube-specs",
3
3
  "type": "module",
4
- "version": "0.0.892",
4
+ "version": "0.0.895",
5
5
  "main": "./nuxt.config.ts",
6
6
  "types": "./types/index.d.ts",
7
7
  "scripts": {
@@ -0,0 +1,61 @@
1
+ <!-- Подписки на категории: широкие показывают лучшее за неделю, узкие — новинки. Данные пока заглушка -->
2
+ <template>
3
+ <div class="_page _container">
4
+ <UiBreadcrumbs
5
+ :items="breadcrumbs"
6
+ class="_breadcrumbs"
7
+ />
8
+
9
+ <LibraryFollows
10
+ :items="items"
11
+ :title="t('library.my_categories')"
12
+ icon="grid"
13
+ />
14
+ </div>
15
+ </template>
16
+
17
+ <script setup lang="ts">
18
+ import { getLibraryCategoriesStub } from '../../stubs/library-follows';
19
+ import { convertString, formatDate } from '../../runtime';
20
+ import type { IBreadcrumbItem, ILibraryFeedItem } from '../../types';
21
+
22
+ const { t } = useI18n();
23
+ const { generateLink } = useGenerateLink();
24
+
25
+ const items = computed<ILibraryFeedItem[]>(() => getLibraryCategoriesStub().map(category => ({
26
+ guid: category.guid,
27
+ title: category.title,
28
+ to: generateLink(`/categories/${convertString().toSlug(category.title)}`),
29
+ newCount: category.newCount,
30
+ meta: [
31
+ { icon: 'video-camera', text: String(category.videosCount) },
32
+ { icon: 'date', text: t('library.updated_at', { date: formatDate(category.lastVideoAddedAt) }) },
33
+ ],
34
+ videosCount: category.videosCount,
35
+ // Нечего показать в новинках — лента откатывается на лучшее за неделю
36
+ feedTitle: category.newCount ? t('library.latest') : t('library.top_this_week'),
37
+ videos: category.videos,
38
+ })));
39
+
40
+ useNoindex();
41
+
42
+ const { brandName } = useAppConfig();
43
+
44
+ useHead(() => ({
45
+ title: `${t('library.my_categories')} — ${brandName}`,
46
+ }));
47
+
48
+ const breadcrumbs = [
49
+ {
50
+ name: `${t('navigation.main')}`,
51
+ to: '/',
52
+ },
53
+ {
54
+ name: `${t('navigation.library')}`,
55
+ to: '/library',
56
+ },
57
+ {
58
+ name: `${t('library.my_categories')}`,
59
+ },
60
+ ] as IBreadcrumbItem[];
61
+ </script>
@@ -0,0 +1,62 @@
1
+ <!-- Подписки на каналы: аватар, теги, счётчик подписчиков, лента последних роликов. Данные пока заглушка -->
2
+ <template>
3
+ <div class="_page _container">
4
+ <UiBreadcrumbs
5
+ :items="breadcrumbs"
6
+ class="_breadcrumbs"
7
+ />
8
+
9
+ <LibraryFollows
10
+ :items="items"
11
+ :title="t('library.my_channels')"
12
+ icon="play-circle"
13
+ />
14
+ </div>
15
+ </template>
16
+
17
+ <script setup lang="ts">
18
+ import { getLibraryChannelsStub } from '../../stubs/library-follows';
19
+ import { convertString, formatDate } from '../../runtime';
20
+ import type { IBreadcrumbItem, ILibraryFeedItem } from '../../types';
21
+
22
+ const { t, n } = useI18n();
23
+ const { generateLink } = useGenerateLink();
24
+
25
+ const items = computed<ILibraryFeedItem[]>(() => getLibraryChannelsStub().map(channel => ({
26
+ guid: channel.guid,
27
+ title: channel.title,
28
+ to: generateLink(`/channels/${convertString().toSlug(channel.title)}`),
29
+ avatarUrl: channel.avatarUrl,
30
+ tags: channel.tags.map(tag => convertString().toChips(tag, 'tag')),
31
+ newCount: channel.newCount,
32
+ meta: [
33
+ { icon: 'video-camera', text: String(channel.videosCount) },
34
+ { icon: 'user-group', text: t('library.followers', { count: n(channel.followersCount, 'compact') }) },
35
+ { icon: 'date', text: t('library.updated_at', { date: formatDate(channel.lastVideoAddedAt) }) },
36
+ ],
37
+ videosCount: channel.videosCount,
38
+ videos: channel.videos,
39
+ })));
40
+
41
+ useNoindex();
42
+
43
+ const { brandName } = useAppConfig();
44
+
45
+ useHead(() => ({
46
+ title: `${t('library.my_channels')} — ${brandName}`,
47
+ }));
48
+
49
+ const breadcrumbs = [
50
+ {
51
+ name: `${t('navigation.main')}`,
52
+ to: '/',
53
+ },
54
+ {
55
+ name: `${t('navigation.library')}`,
56
+ to: '/library',
57
+ },
58
+ {
59
+ name: `${t('library.my_channels')}`,
60
+ },
61
+ ] as IBreadcrumbItem[];
62
+ </script>