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.
- package/assets/scss/themes/dark/_theme.scss +18 -1
- package/assets/scss/themes/light/_theme.scss +18 -1
- package/components/card/dropdown/trigger.vue +9 -5
- package/components/card/history.vue +4 -3
- package/components/card/video/index.vue +21 -8
- package/components/library/feed.vue +340 -0
- package/components/library/follows.vue +147 -0
- package/components/ui/expand-row.vue +21 -2
- package/components/ui/video-preview.vue +62 -0
- package/package.json +1 -1
- package/pages/library/categories.vue +61 -0
- package/pages/library/channels.vue +62 -0
- package/pages/library/index.vue +366 -0
- package/pages/library/models.vue +63 -0
- package/pages/library.vue +1 -361
- package/stubs/history.ts +17 -14
- package/stubs/library-follows.ts +135 -0
- package/stubs/videos.ts +29 -0
- package/types/index.d.ts +1 -0
- package/types/library-feed.d.ts +34 -0
- package/components/card/preview.vue +0 -139
|
@@ -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
|
@@ -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>
|