itube-specs 0.0.893 → 0.0.896

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,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>
@@ -212,14 +212,14 @@ const following = computed<LibraryLink[]>(() => [
212
212
  badge: t('library.new_count', { count: libraryStub.newModels }),
213
213
  },
214
214
  {
215
- to: generateLink('/channels'),
215
+ to: generateLink('/library/channels'),
216
216
  icon: 'play-circle',
217
217
  title: t('channels'),
218
218
  subtitle: t('plural.channels', libraryStub.channels),
219
219
  badge: t('library.new_count', { count: libraryStub.newChannels }),
220
220
  },
221
221
  {
222
- to: generateLink('/categories'),
222
+ to: generateLink('/library/categories'),
223
223
  icon: 'grid',
224
224
  title: t('categories'),
225
225
  subtitle: t('plural.categories', libraryStub.categories),
@@ -1,120 +1,43 @@
1
- <!-- Подписки на моделей: сортировка и лента последних роликов по каждой модели. Данные пока заглушка -->
1
+ <!-- Подписки на моделей: аватар, теги, лента последних роликов. Данные пока заглушка -->
2
2
  <template>
3
- <div class="library-models _page _container">
3
+ <div class="_page _container">
4
4
  <UiBreadcrumbs
5
5
  :items="breadcrumbs"
6
6
  class="_breadcrumbs"
7
7
  />
8
8
 
9
- <UiSectionTitle
9
+ <LibraryFollows
10
+ :items="items"
10
11
  :title="t('library.my_models')"
11
- :count="models.length"
12
- class="library-models__title"
13
12
  icon="user"
14
13
  />
15
-
16
- <div class="library-models__sort">
17
- <UiBtn
18
- v-for="item in sortItems"
19
- :key="item.name"
20
- :theme="sort === item.name ? 'primary' : 'secondary'"
21
- @click="sort = item.name"
22
- >{{ item.title }}</UiBtn>
23
- </div>
24
-
25
- <div
26
- v-if="models.length > 0"
27
- class="library-models__list"
28
- >
29
- <LibraryModel
30
- v-for="(model, index) in models"
31
- :key="model.guid"
32
- :model="model"
33
- :following="isFollowing(model)"
34
- :priority="index === 0"
35
- @toggle-follow="onToggleFollow"
36
- />
37
- </div>
38
-
39
- <UiEmptyState
40
- v-else
41
- without-reset
42
- />
43
-
44
- <UiConfirm
45
- v-model="isUnfollowOpen"
46
- :title="t('library.unfollow_title', { name: pendingUnfollow?.title })"
47
- :confirm-text="t('library.unfollow_confirm')"
48
- @confirm="onUnfollowConfirm"
49
- >
50
- <p class="library-models__confirm">{{ t('library.unfollow_text') }}</p>
51
- </UiConfirm>
52
14
  </div>
53
15
  </template>
54
16
 
55
17
  <script setup lang="ts">
56
- import { getLibraryModelsStub } from '../../stubs/library-models';
57
- import type { LibraryModelStub } from '../../stubs/library-models';
58
- import type { IBreadcrumbItem } from '../../types';
59
-
60
- const LibrarySort = {
61
- New: 'new',
62
- Alphabet: 'alphabet',
63
- } as const;
64
-
65
- type LibrarySortValue = typeof LibrarySort[keyof typeof LibrarySort];
18
+ import { getLibraryModelsStub } from '../../stubs/library-follows';
19
+ import { convertString, formatDate } from '../../runtime';
20
+ import type { IBreadcrumbItem, ILibraryFeedItem } from '../../types';
66
21
 
67
22
  const { t } = useI18n();
68
-
69
- const sort = ref<LibrarySortValue>(LibrarySort.New);
70
-
71
- const sortItems = computed(() => [
72
- { name: LibrarySort.New, title: t('library.sort_new_first') },
73
- { name: LibrarySort.Alphabet, title: t('library.sort_alphabet') },
74
- ]);
75
-
76
- const unfollowed = ref<string[]>([]);
77
-
78
- const allModels = getLibraryModelsStub();
79
-
80
- const models = computed(() => {
81
- const items = [...allModels];
82
-
83
- return sort.value === LibrarySort.Alphabet
84
- ? items.sort((a, b) => a.title.localeCompare(b.title))
85
- : items.sort((a, b) => b.lastVideoAddedAt - a.lastVideoAddedAt);
86
- });
87
-
88
- function isFollowing(model: LibraryModelStub) {
89
- return !unfollowed.value.includes(model.guid);
90
- }
91
-
92
- /** Модель, для которой открыт попап отписки. */
93
- const pendingUnfollow = ref<LibraryModelStub | null>(null);
94
-
95
- const isUnfollowOpen = computed({
96
- get: () => pendingUnfollow.value !== null,
97
- set: value => {
98
- if (!value) pendingUnfollow.value = null;
99
- },
100
- });
101
-
102
- /** Подписка — сразу, отписка — только через подтверждение: она сносит ленту обновлений. */
103
- function onToggleFollow(model: LibraryModelStub) {
104
- if (isFollowing(model)) {
105
- pendingUnfollow.value = model;
106
- return;
107
- }
108
-
109
- unfollowed.value = unfollowed.value.filter(guid => guid !== model.guid);
110
- }
111
-
112
- function onUnfollowConfirm() {
113
- if (!pendingUnfollow.value) return;
114
-
115
- unfollowed.value = [...unfollowed.value, pendingUnfollow.value.guid];
116
- pendingUnfollow.value = null;
117
- }
23
+ const { generateLink } = useGenerateLink();
24
+
25
+ const items = computed<ILibraryFeedItem[]>(() => getLibraryModelsStub().map(model => ({
26
+ guid: model.guid,
27
+ title: model.title,
28
+ to: generateLink(`/models/${convertString().toSlug(model.title)}`),
29
+ avatarUrl: model.avatarUrl,
30
+ country: model.country,
31
+ tags: model.tags.map(tag => convertString().toChips(tag, 'tag')),
32
+ newCount: model.newCount,
33
+ meta: [
34
+ { icon: 'video-camera', text: String(model.videosCount) },
35
+ { text: `${model.age} ${t('yo')}` },
36
+ { icon: 'date', text: t('library.updated_at', { date: formatDate(model.lastVideoAddedAt) }) },
37
+ ],
38
+ videosCount: model.videosCount,
39
+ videos: model.videos,
40
+ })));
118
41
 
119
42
  useNoindex();
120
43
 
@@ -138,31 +61,3 @@ const breadcrumbs = [
138
61
  },
139
62
  ] as IBreadcrumbItem[];
140
63
  </script>
141
-
142
- <style lang="scss">
143
- .library-models__title {
144
- margin-bottom: 16px;
145
- }
146
-
147
- .library-models__sort {
148
- display: flex;
149
- flex-wrap: wrap;
150
- align-items: center;
151
- gap: 8px;
152
- margin-bottom: 24px;
153
- }
154
-
155
- .library-models__list {
156
- display: grid;
157
- row-gap: 24px;
158
- }
159
-
160
- .library-models__confirm {
161
- @include font-sm;
162
-
163
- max-width: 400px;
164
- margin: 0 auto;
165
- text-align: center;
166
- color: $page-library-confirm-text;
167
- }
168
- </style>
@@ -0,0 +1,135 @@
1
+ import { VIDEO_POOL } from './videos';
2
+ import type { StubVideo } from './videos';
3
+
4
+ const HOUR = 3600;
5
+ const DAY = 24 * HOUR;
6
+
7
+ /** Сколько последних роликов сущности показываем в ленте. */
8
+ const PREVIEW_LIMIT = 5;
9
+
10
+ export type LibraryFollowVideo = StubVideo & {
11
+ guid: string;
12
+ isNew: boolean;
13
+ };
14
+
15
+ type LibraryFollowBase = {
16
+ guid: string;
17
+ title: string;
18
+ videosCount: number;
19
+ /** Сколько роликов вышло с прошлого захода; 0 — плашки «новое» нет */
20
+ newCount: number;
21
+ /** Unix timestamp (сек.) последнего добавленного ролика */
22
+ lastVideoAddedAt: number;
23
+ videos: LibraryFollowVideo[];
24
+ };
25
+
26
+ export type LibraryModelStub = LibraryFollowBase & {
27
+ avatarUrl: string;
28
+ country: {
29
+ name: string
30
+ title: string
31
+ };
32
+ age: number;
33
+ tags: string[];
34
+ };
35
+
36
+ export type LibraryChannelStub = LibraryFollowBase & {
37
+ avatarUrl?: string;
38
+ followersCount: number;
39
+ tags: string[];
40
+ };
41
+
42
+ export type LibraryCategoryStub = LibraryFollowBase;
43
+
44
+ const now = () => Math.floor(Date.now() / 1000);
45
+
46
+ const toSlug = (title: string) => title.toLowerCase().replace(/\s+/g, '-');
47
+
48
+ /** Нарезает пул на ленту сущности: guid уникален внутри неё, первые `newCount` помечены новинками. */
49
+ function buildVideos(slug: string, offset: number, newCount: number): LibraryFollowVideo[] {
50
+ return Array.from({ length: PREVIEW_LIMIT }, (_, index) => {
51
+ const video = VIDEO_POOL[(offset + index) % VIDEO_POOL.length];
52
+
53
+ return {
54
+ ...video,
55
+ guid: `${slug}-${video.id}`,
56
+ isNew: index < newCount,
57
+ };
58
+ });
59
+ }
60
+
61
+ /** Порядок по числу новинок намеренно не совпадает с алфавитным — иначе обе сортировки дают одно и то же. */
62
+ const MODELS = [
63
+ { title: 'Dakota Lovell', image: 'Dakota_Lovell.png', country: { name: 'us', title: 'USA' }, age: 22, videosCount: 41, newCount: 3, tags: ['Twink', 'Jock', 'Versatile', 'Smooth', 'Bareback', 'Amateur'], updatedAgo: 6 * HOUR, videoOffset: 3 },
64
+ { title: 'Jax Thirio', image: 'Jax_Thirio.png', country: { name: 'ca', title: 'Canada' }, age: 38, videosCount: 17, newCount: 2, tags: ['Muscle', 'Daddy', 'Top', 'Tattooed'], updatedAgo: 2 * DAY, videoOffset: 9 },
65
+ { title: 'Austin Young', image: 'Austin_Young.png', country: { name: 'us', title: 'USA' }, age: 24, videosCount: 75, newCount: 1, tags: ['Twink', 'Smooth', 'Bottom', 'Blond', 'Solo'], updatedAgo: 9 * DAY, videoOffset: 0 },
66
+ { title: 'Devin Franco', image: 'Devin_Franco.png', country: { name: 'us', title: 'USA' }, age: 33, videosCount: 24, newCount: 0, tags: ['Muscle', 'Tattooed', 'Bottom', 'Fisting', 'Hardcore', 'Professional'], updatedAgo: 21 * DAY, videoOffset: 6 },
67
+ ];
68
+
69
+ const CHANNELS = [
70
+ { title: 'Top To Bottom', avatarUrl: undefined, videosCount: 17, followersCount: 12800, newCount: 3, tags: ['Versatile', 'Flip-Flop', 'HD', 'Professional', 'Exclusive', 'Bareback'], updatedAgo: 4 * HOUR, videoOffset: 2 },
71
+ { title: 'Icon Male', avatarUrl: 'https://nli01.joystorage.com/c/0/2/c02a3f8ef14943b5f3da537aaed46fcc.jpeg', videosCount: 128, followersCount: 128400, newCount: 2, tags: ['Muscle', 'Daddy', 'Bareback', 'Professional', 'Exclusive', 'HD', 'Studio'], updatedAgo: DAY, videoOffset: 0 },
72
+ { title: 'Sean Cody', avatarUrl: undefined, videosCount: 64, followersCount: 210500, newCount: 1, tags: ['Jock', 'Muscle', 'Bareback', 'Studio'], updatedAgo: 5 * DAY, videoOffset: 7 },
73
+ { title: 'Johnny Rapid', avatarUrl: 'https://nli01.joystorage.com/e/6/d/e6d0abd734a1a2eaef0baf7ceccfdb5c.jpeg', videosCount: 86, followersCount: 94200, newCount: 0, tags: ['Twink', 'Bareback', 'Group', 'Amateur'], updatedAgo: 14 * DAY, videoOffset: 4 },
74
+ ];
75
+
76
+ /**
77
+ * Широкие категории пополняются постоянно, поэтому новинки у них есть всегда; узкие вроде Firemen
78
+ * неделями стоят на месте — там `newCount: 0`, и лента откатывается на лучшее за неделю.
79
+ */
80
+ const CATEGORIES = [
81
+ { title: 'Bareback', videosCount: 214, newCount: 3, updatedAgo: 3 * HOUR, videoOffset: 1 },
82
+ { title: 'Twinks', videosCount: 176, newCount: 2, updatedAgo: 9 * HOUR, videoOffset: 5 },
83
+ { title: 'Bears', videosCount: 92, newCount: 0, updatedAgo: 11 * DAY, videoOffset: 10 },
84
+ { title: 'Firemen', videosCount: 61, newCount: 0, updatedAgo: 24 * DAY, videoOffset: 8 },
85
+ ];
86
+
87
+ /**
88
+ * Подписки пользователя. Ручек под них пока нет — вёрстка живёт на заглушке.
89
+ * Возраст ролика считаем от «сейчас», чтобы даты в разметке не устаревали.
90
+ */
91
+ export function getLibraryModelsStub(): LibraryModelStub[] {
92
+ const stamp = now();
93
+
94
+ return MODELS.map(({ image, updatedAgo, videoOffset, ...model }) => {
95
+ const guid = toSlug(model.title);
96
+
97
+ return {
98
+ ...model,
99
+ guid,
100
+ avatarUrl: `/img/models/${image}`,
101
+ lastVideoAddedAt: stamp - updatedAgo,
102
+ videos: buildVideos(guid, videoOffset, model.newCount),
103
+ };
104
+ });
105
+ }
106
+
107
+ export function getLibraryChannelsStub(): LibraryChannelStub[] {
108
+ const stamp = now();
109
+
110
+ return CHANNELS.map(({ updatedAgo, videoOffset, ...channel }) => {
111
+ const guid = toSlug(channel.title);
112
+
113
+ return {
114
+ ...channel,
115
+ guid,
116
+ lastVideoAddedAt: stamp - updatedAgo,
117
+ videos: buildVideos(guid, videoOffset, channel.newCount),
118
+ };
119
+ });
120
+ }
121
+
122
+ export function getLibraryCategoriesStub(): LibraryCategoryStub[] {
123
+ const stamp = now();
124
+
125
+ return CATEGORIES.map(({ updatedAgo, videoOffset, ...category }) => {
126
+ const guid = toSlug(category.title);
127
+
128
+ return {
129
+ ...category,
130
+ guid,
131
+ lastVideoAddedAt: stamp - updatedAgo,
132
+ videos: buildVideos(guid, videoOffset, category.newCount),
133
+ };
134
+ });
135
+ }
package/types/index.d.ts CHANGED
@@ -49,6 +49,7 @@ export * from './navigation-items.d.ts';
49
49
  export * from './link-item.d.ts';
50
50
  export * from './fluid-player.d.ts';
51
51
  export * from './chips-item.d.ts';
52
+ export * from './library-feed.d.ts';
52
53
  export * from './nav-link-item.d.ts';
53
54
  export * from './paginated-response.d.ts';
54
55
  export * from './request-pagination.d.ts';
@@ -0,0 +1,34 @@
1
+ import type { IChipsItem } from './chips-item';
2
+ import type { IVideoCard } from './video-card';
3
+
4
+ /** Счётчик в строке под тегами: «17», «128.4K followers», «Updated 04.06.2026». */
5
+ export interface ILibraryFeedMeta {
6
+ icon?: string
7
+ text: string
8
+ }
9
+
10
+ /**
11
+ * Блок одной подписки в «Библиотеке». Модели, каналы и категории отличаются только
12
+ * составом шапки, поэтому страница приводит их к этой форме, а вёрстка одна.
13
+ */
14
+ export interface ILibraryFeedItem {
15
+ guid: string
16
+ title: string
17
+ /** Ссылка на саму сущность */
18
+ to: string
19
+ /** Нет — вместо картинки рисуются инициалы */
20
+ avatarUrl?: string
21
+ /** Только у моделей */
22
+ country?: {
23
+ name: string
24
+ title: string
25
+ }
26
+ tags?: IChipsItem[]
27
+ /** Роликов с прошлого захода: плашка «N new» и порядок в сортировке «сначала новые». 0 — плашки нет */
28
+ newCount: number
29
+ meta: ILibraryFeedMeta[]
30
+ videosCount: number
31
+ /** Подпись над лентой: «Latest» / «Top this week». Нет — лента без подписи */
32
+ feedTitle?: string
33
+ videos: IVideoCard[]
34
+ }