itube-specs 0.0.897 → 0.0.898

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,79 @@
1
+ <!-- Колокольчик уведомлений в шапке: ссылка на подписки + счётчик обновлений. Данные пока заглушка -->
2
+ <template>
3
+ <NuxtLink
4
+ :to="generateLink('/following')"
5
+ :aria-label="t('notifications')"
6
+ :title="t('notifications')"
7
+ class="layout-notifications"
8
+ >
9
+ <UiIcon
10
+ class="layout-notifications__icon"
11
+ name="bell"
12
+ size="24"
13
+ />
14
+ <span
15
+ v-if="count > 0"
16
+ class="layout-notifications__badge"
17
+ >{{ badge }}</span>
18
+ </NuxtLink>
19
+ </template>
20
+
21
+ <script setup lang="ts">
22
+ import { getFollowingUpdatesTotal } from '../../stubs/library-follows';
23
+
24
+ const MAX_COUNT = 99;
25
+
26
+ const { t } = useI18n();
27
+ const { generateLink } = useGenerateLink();
28
+
29
+ const count = computed(() => getFollowingUpdatesTotal());
30
+
31
+ const badge = computed(() => count.value > MAX_COUNT ? `${MAX_COUNT}+` : `${count.value}`);
32
+ </script>
33
+
34
+ <style lang="scss">
35
+ /* Бейдж лежит в потоке и наезжает на иконку отрицательным отступом: разряды уходят
36
+ вправо, растягивая контрол. Место под «99+» не резервируется, а зазор от края
37
+ бейджа до соседа в шапке одинаков при любом числе — его держит $badge-inset. */
38
+ $padding: 8px;
39
+ $badge-inset: 2px;
40
+ $badge-overlap: 10px;
41
+ $badge-raise: 6px;
42
+
43
+ .layout-notifications {
44
+ display: inline-flex;
45
+ align-items: center;
46
+ padding: $padding $badge-inset $padding $padding;
47
+ color: $notifications-icon;
48
+
49
+ /* На мобиле это такой же тап-контрол, как бургер рядом */
50
+ @include to-br(sm) {
51
+ color: $notifications-icon--mobile;
52
+ }
53
+
54
+ @include hover(color) {
55
+ color: $notifications-icon--hover;
56
+ }
57
+ }
58
+
59
+ .layout-notifications__badge {
60
+ @include font-4xs;
61
+
62
+ /* Левым краем на верхний правый угол иконки, дальше растёт вправо */
63
+ position: relative;
64
+ z-index: $z-index-plus;
65
+ display: grid;
66
+ place-content: center;
67
+ align-self: flex-start;
68
+ min-width: 16px;
69
+ height: 16px;
70
+ margin-top: -$badge-raise;
71
+ margin-left: -$badge-overlap;
72
+ padding-inline: 4px;
73
+ font-weight: 700;
74
+ color: $notifications-badge-text;
75
+ background-color: $notifications-badge-bg;
76
+ border-radius: $radius-full;
77
+ box-shadow: 0 0 0 2px $notifications-badge-ring;
78
+ }
79
+ </style>
@@ -1,13 +1,29 @@
1
1
  <!-- Горизонтальный свайпер аватаров моделей с заголовком и ссылкой «все» -->
2
2
  <template>
3
3
  <section class="section-swiper-cards">
4
- <UiSectionTitle
5
- :title="title || t('top_models')"
6
- :count="items.length"
7
- class="section-swiper-cards__title"
8
- icon="user"
9
- title-tag="h2"
10
- />
4
+ <header class="section-swiper-cards__header">
5
+ <UiSectionTitle
6
+ :title="title || t('top_models')"
7
+ :count="count ?? items.length"
8
+ :icon="icon"
9
+ class="section-swiper-cards__title"
10
+ title-tag="h2"
11
+ />
12
+
13
+ <!-- На мобиле плитки «все» в ленте нет, поэтому ссылка вынесена в шапку -->
14
+ <UiLink
15
+ v-if="allLink"
16
+ :to="generateLink(allLink)"
17
+ class="section-swiper-cards__link _to-sm"
18
+ theme="simple"
19
+ >
20
+ {{ headerLinkText ?? allLinkText }}
21
+ <UiIcon
22
+ name="arrow-right"
23
+ size="16"
24
+ />
25
+ </UiLink>
26
+ </header>
11
27
  <div class="section-swiper-cards__grid">
12
28
  <UiSwiper
13
29
  :items="items"
@@ -17,6 +33,7 @@
17
33
  <CardRound
18
34
  :card="item"
19
35
  :placeholder-avatar="placeholderAvatar"
36
+ :without-count="withoutCount"
20
37
  class="section-swiper-cards__card"
21
38
  />
22
39
  </template>
@@ -24,11 +41,12 @@
24
41
  v-if="allLink"
25
42
  #append
26
43
  >
27
- <NuxtLink
28
- :to="generateLink(allLink ?? '/')"
29
- class="section-swiper-cards__all"
30
- >{{ allLinkText }}
31
- </NuxtLink>
44
+ <UiViewAll
45
+ :to="allLink ?? '/'"
46
+ :text="allLinkText ?? ''"
47
+ :note="allLinkNote"
48
+ mini
49
+ />
32
50
  </template>
33
51
  </UiSwiper>
34
52
  </div>
@@ -38,14 +56,25 @@
38
56
  <script setup lang="ts">
39
57
  import type { IRoundCard } from '../../types';
40
58
 
41
- defineProps<{
59
+ withDefaults(defineProps<{
42
60
  items: IRoundCard[]
43
61
  onlyGrid?: boolean
44
62
  allLink?: string
45
63
  allLinkText?: string
64
+ /** Вторая строка в плитке «все» — «16 models» */
65
+ allLinkNote?: string
66
+ /** Подпись ссылки в шапке («All models»); по умолчанию — как в плитке */
67
+ headerLinkText?: string
46
68
  title?: string
69
+ /** Счётчик в шапке; по умолчанию — число карточек */
70
+ count?: string | number
71
+ icon?: string
47
72
  placeholderAvatar?: string
48
- }>();
73
+ /** Карточки только с аватаром и названием, без счётчика роликов */
74
+ withoutCount?: boolean
75
+ }>(), {
76
+ icon: 'user'
77
+ });
49
78
 
50
79
  const { t } = useI18n();
51
80
  const { generateLink } = useGenerateLink();
@@ -58,12 +87,22 @@ const { generateLink } = useGenerateLink();
58
87
  min-width: 0;
59
88
  }
60
89
 
90
+ .section-swiper-cards__header {
91
+ display: flex;
92
+ align-items: center;
93
+ justify-content: space-between;
94
+ column-gap: 16px;
95
+ margin-bottom: 16px;
96
+ }
97
+
98
+ /* text-transform здесь не нужен: капс и трекинг заголовку даёт его собственный
99
+ `._title`, а на корне он красил заодно и чип счётчика («6 UPDATES»). */
61
100
  .section-swiper-cards__title {
62
101
  @include font-lg;
102
+ }
63
103
 
64
- margin-bottom: 16px;
65
- text-transform: uppercase;
66
- letter-spacing: var(--tracking-wide);
104
+ .section-swiper-cards__link {
105
+ flex-shrink: 0;
67
106
  }
68
107
 
69
108
  .section-swiper-cards .section-swiper-cards__card {
@@ -74,24 +113,16 @@ const { generateLink } = useGenerateLink();
74
113
  min-width: 0;
75
114
  }
76
115
 
77
- .section-swiper-cards__all {
78
- @include font-sm;
79
-
80
- display: grid;
81
- place-content: center;
82
- width: $card-round-size;
83
- height: $card-round-size;
84
- aspect-ratio: 1/1;
85
- border-radius: $radius-full;
86
- object-fit: cover;
87
- border: 2px solid $card-round-border;
88
- background-color: $card-round-all-bg;
89
- color: $card-round-all-text;
90
- text-align: center;
116
+ /* Слайд ровно по круглой карточке: 80px на мобиле, 96px с sm */
117
+ .section-swiper-cards .ui-swiper__slide {
118
+ width: var(--card-round-size);
119
+ }
91
120
 
92
- @include hover(border-color, background-color) {
93
- border-color: $card-round-border--hover;
94
- background-color: $card-round-all-bg--hover;
121
+ /* Плитка «все» — только с sm, на мобиле её роль играет ссылка в шапке */
122
+ .section-swiper-cards .ui-swiper__slide--append {
123
+ @include to-br(sm) {
124
+ display: none;
95
125
  }
96
126
  }
127
+
97
128
  </style>
@@ -161,4 +161,19 @@ const slotText = computed(() => {
161
161
  background-color: var(--button-bg--hover, #{$button-warning-bg--hover});
162
162
  }
163
163
  }
164
+
165
+ /* Без подложки, рамки и паддингов — обычная текстовая ссылка с иконкой */
166
+ .ui-btn.--simple {
167
+ min-height: 0;
168
+ padding: 0;
169
+ column-gap: 4px;
170
+ color: var(--button-text, #{$button-simple-text});
171
+ background-color: transparent;
172
+ border-color: transparent;
173
+ border-radius: 0;
174
+
175
+ @include hover(color) {
176
+ color: var(--button-text--hover, #{$button-simple-text--hover});
177
+ }
178
+ }
164
179
  </style>
@@ -313,10 +313,6 @@ const { snackbarText, isSnackBarInPopup } = useSnackbar();
313
313
  .ui-popup__footer {
314
314
  padding: 16px 20px;
315
315
  border-top: 1px solid $popup-footer-border;
316
-
317
- @include from-br(sm) {
318
- padding: 16px 24px;
319
- }
320
316
  }
321
317
 
322
318
  .ui-popup[open].--drawer {
@@ -97,6 +97,10 @@ onMounted(() => {
97
97
  </script>
98
98
 
99
99
  <style lang="scss">
100
+ /* Запас под ring ховера карточек: у трека overflow-x: auto, а значит по спецификации
101
+ и overflow-y становится auto — без запаса тень обрезается по всем краям. */
102
+ $ring-room: 4px;
103
+
100
104
  .ui-swiper {
101
105
  position: relative;
102
106
 
@@ -113,8 +117,13 @@ onMounted(() => {
113
117
  scroll-snap-type: x mandatory;
114
118
  -webkit-overflow-scrolling: touch;
115
119
  scrollbar-width: none;
120
+ padding-block: $ring-room;
116
121
  padding-inline: var(--container-padding-inline);
117
122
 
123
+ /* Без scroll-padding mandatory-снап считает точкой привязки сам край скроллпорта
124
+ и на старте подтягивает первую карточку под padding — она липнет к краю вьюпорта. */
125
+ scroll-padding-inline: var(--container-padding-inline);
126
+
118
127
  &::-webkit-scrollbar {
119
128
  display: none;
120
129
  }
@@ -123,9 +132,12 @@ onMounted(() => {
123
132
  gap: 8px;
124
133
  }
125
134
 
135
+ /* Отрицательный margin гасит боковой запас, чтобы первая карточка осталась по краю */
126
136
  @include from-br(sm) {
127
137
  gap: 16px;
128
- padding-inline: 0;
138
+ padding-inline: $ring-room;
139
+ scroll-padding-inline: $ring-room;
140
+ margin-inline: -$ring-room;
129
141
  }
130
142
  }
131
143
 
@@ -144,7 +156,7 @@ $nav-gap: 4px;
144
156
  $size: 40px;
145
157
 
146
158
  position: absolute;
147
- top: calc(var(--slide-width) / 2);
159
+ top: calc(var(--slide-width) / 2 + #{$ring-room});
148
160
  transform: translateY(-50%);
149
161
  z-index: calc($z-index-plus + 2);
150
162
  display: grid;
@@ -165,15 +177,18 @@ $nav-gap: 4px;
165
177
  background-color: $swiper-nav-bg--hover;
166
178
  }
167
179
 
180
+ /* Трек вылезает за .ui-swiper на $ring-room, поэтому градиент уводим на
181
+ ($nav-gap + $ring-room) — иначе между затемнением и краем свайпера
182
+ остаётся полоска непокрытых карточек. */
168
183
  &::before {
169
184
  position: absolute;
170
185
  content: '';
171
186
  top: 50%;
172
- right: $nav-gap * -1;
187
+ right: -($nav-gap + $ring-room);
173
188
  transform: translateY(-50%);
174
189
  display: block;
175
190
  width: 64px;
176
- height: var(--slide-width);
191
+ height: calc(var(--slide-width) + #{$ring-room * 2});
177
192
  background: linear-gradient(270deg, $swiper-fade 0%, $swiper-fade-end 100%);
178
193
  }
179
194
 
@@ -0,0 +1,113 @@
1
+ <!-- Плитка-ссылка «смотреть все» в конце ленты: точечная текстура, стрелка и подпись. mini — круглая, под свайпер аватаров -->
2
+ <template>
3
+ <NuxtLink
4
+ :class="{'--mini': mini}"
5
+ :to="generateLink(to)"
6
+ class="ui-view-all"
7
+ >
8
+ <span
9
+ v-if="!mini"
10
+ class="ui-view-all__icon"
11
+ >
12
+ <UiIcon
13
+ name="arrow-right"
14
+ size="20"
15
+ />
16
+ </span>
17
+
18
+ <span class="ui-view-all__content">
19
+ <span class="ui-view-all__text">{{ text }}</span>
20
+ <span
21
+ v-if="note"
22
+ class="ui-view-all__note"
23
+ >{{ note }}</span>
24
+ </span>
25
+ </NuxtLink>
26
+ </template>
27
+
28
+ <script setup lang="ts">
29
+ defineProps<{
30
+ to: string
31
+ text: string
32
+ /** Вторая строка: «16 models» */
33
+ note?: string
34
+ /** Круглый вариант в размер аватар-карточки — для свайпера */
35
+ mini?: boolean
36
+ }>();
37
+
38
+ const { generateLink } = useGenerateLink();
39
+ </script>
40
+
41
+ <style lang="scss">
42
+ .ui-view-all {
43
+ display: grid;
44
+ place-content: center;
45
+ justify-items: center;
46
+ row-gap: 12px;
47
+ padding: 12px;
48
+ text-align: center;
49
+ border: 1px solid $view-all-border;
50
+ border-radius: $radius-xl;
51
+
52
+ /* точки поверх градиента — двумя слоями, без отдельной ноды-оверлея */
53
+ background-image:
54
+ radial-gradient(circle, $view-all-dots 1px, transparent 1px),
55
+ linear-gradient(to bottom right in oklab, $view-all-gradient-from, $view-all-gradient-via, $view-all-gradient-to);
56
+ background-size: 14px 14px, cover;
57
+
58
+ @include hover(border-color) {
59
+ border-color: $view-all-border--hover;
60
+ }
61
+ }
62
+
63
+ .ui-view-all__icon {
64
+ display: grid;
65
+ place-content: center;
66
+ width: 40px;
67
+ height: 40px;
68
+ color: $view-all-icon;
69
+ background-color: $view-all-icon-bg;
70
+ border: 1px solid $view-all-icon-border;
71
+ border-radius: $radius-full;
72
+ }
73
+
74
+ .ui-view-all__content {
75
+ display: grid;
76
+ row-gap: 2px;
77
+ }
78
+
79
+ .ui-view-all__text {
80
+ @include font-sm;
81
+
82
+ font-weight: 600;
83
+ color: $view-all-text;
84
+ }
85
+
86
+ .ui-view-all__note {
87
+ @include font-xs;
88
+
89
+ color: $view-all-note;
90
+ }
91
+
92
+ .ui-view-all.--mini {
93
+ width: var(--card-round-size);
94
+ height: var(--card-round-size);
95
+ padding: 8px;
96
+ row-gap: 0;
97
+ border-width: 2px;
98
+ border-color: $card-round-border;
99
+ border-radius: $radius-full;
100
+ background-size: 12px 12px, cover;
101
+
102
+ @include hover(border-color) {
103
+ border-color: $card-round-border--hover;
104
+ }
105
+ }
106
+
107
+ .ui-view-all.--mini .ui-view-all__text,
108
+ .ui-view-all.--mini .ui-view-all__note {
109
+ @include font-xs;
110
+
111
+ line-height: 1.2;
112
+ }
113
+ </style>
package/package.json CHANGED
@@ -1,7 +1,7 @@
1
1
  {
2
2
  "name": "itube-specs",
3
3
  "type": "module",
4
- "version": "0.0.897",
4
+ "version": "0.0.898",
5
5
  "main": "./nuxt.config.ts",
6
6
  "types": "./types/index.d.ts",
7
7
  "scripts": {
@@ -0,0 +1,201 @@
1
+ <!-- Подписки: что нового у моделей, каналов, категорий и плейлистов. Данные пока заглушка -->
2
+ <template>
3
+ <div class="following _page _container">
4
+ <UiBreadcrumbs
5
+ :items="breadcrumbs"
6
+ class="_breadcrumbs"
7
+ />
8
+
9
+ <h1 class="_title">{{ t('following.title') }}</h1>
10
+
11
+ <SectionSwiperCards
12
+ :items="models"
13
+ :count="t('following.updates', { count: modelsUpdates })"
14
+ :title="t('models')"
15
+ :all-link-text="t('following.view_all')"
16
+ :all-link-note="t('plural.models', models.length)"
17
+ :header-link-text="t('following.all_models')"
18
+ all-link="/library/models"
19
+ icon="user"
20
+ placeholder-avatar="/img/model-placeholder.webp"
21
+ class="following__section"
22
+ without-count
23
+ />
24
+
25
+ <SectionSwiperCards
26
+ :items="channels"
27
+ :count="t('following.updates', { count: channelsUpdates })"
28
+ :title="t('channels')"
29
+ :all-link-text="t('following.view_all')"
30
+ :all-link-note="t('plural.channels', channels.length)"
31
+ :header-link-text="t('following.all_channels')"
32
+ all-link="/library/channels"
33
+ icon="play-circle"
34
+ placeholder-avatar="/img/logo-placeholder.svg"
35
+ class="following__section"
36
+ without-count
37
+ />
38
+
39
+ <section class="following__section">
40
+ <header class="following__header">
41
+ <UiSectionTitle
42
+ :title="t('categories')"
43
+ :count="t('following.updates', { count: categoriesUpdates })"
44
+ icon="grid"
45
+ title-tag="h2"
46
+ />
47
+
48
+ <!-- На мобиле плитки «все» в гриде нет, поэтому ссылка вынесена в шапку -->
49
+ <UiLink
50
+ :to="generateLink('/library/categories')"
51
+ class="following__link _to-sm"
52
+ theme="simple"
53
+ >
54
+ {{ t('following.all_categories') }}
55
+ <UiIcon
56
+ name="arrow-right"
57
+ size="16"
58
+ />
59
+ </UiLink>
60
+ </header>
61
+
62
+ <GridCategories :categories="categories">
63
+ <UiViewAll
64
+ :text="t('following.view_all')"
65
+ :note="t('plural.categories', categories.length)"
66
+ class="_from-sm"
67
+ to="/library/categories"
68
+ />
69
+ </GridCategories>
70
+ </section>
71
+
72
+ <section class="following__section">
73
+ <header class="following__header">
74
+ <UiSectionTitle
75
+ :title="t('playlists')"
76
+ :count="t('following.updates', { count: playlistsUpdates })"
77
+ icon="playlist"
78
+ title-tag="h2"
79
+ />
80
+
81
+ <UiLink
82
+ :to="generateLink('/playlists')"
83
+ class="following__link _to-sm"
84
+ theme="simple"
85
+ >
86
+ {{ t('following.all_playlists') }}
87
+ <UiIcon
88
+ name="arrow-right"
89
+ size="16"
90
+ />
91
+ </UiLink>
92
+ </header>
93
+
94
+ <GridPlaylists :items="playlists">
95
+ <UiViewAll
96
+ :text="t('following.view_all')"
97
+ :note="t('plural.playlists', playlists.length)"
98
+ class="_from-sm"
99
+ to="/playlists"
100
+ />
101
+ </GridPlaylists>
102
+ </section>
103
+ </div>
104
+ </template>
105
+
106
+ <script setup lang="ts">
107
+ import {
108
+ getLibraryCategoriesStub,
109
+ getLibraryChannelsStub,
110
+ getLibraryModelsStub,
111
+ getLibraryPlaylistsStub
112
+ } from '../stubs/library-follows';
113
+ import { convertString } from '../runtime';
114
+ import type { IBreadcrumbItem, ICategoryCard, IRoundCard } from '../types';
115
+
116
+ const { t } = useI18n();
117
+ const { generateLink } = useGenerateLink();
118
+
119
+ /** Сначала те, у кого больше новинок — ради этого страница и открывается. */
120
+ const byNewCount = <T extends { newCount: number }>(items: T[]) => [...items].sort((a, b) => b.newCount - a.newCount);
121
+
122
+ const sumUpdates = (items: { newCount: number }[]) => items.reduce((total, item) => total + item.newCount, 0);
123
+
124
+ const modelsStub = computed(() => byNewCount(getLibraryModelsStub()));
125
+ const channelsStub = computed(() => byNewCount(getLibraryChannelsStub()));
126
+ const categoriesStub = computed(() => byNewCount(getLibraryCategoriesStub()));
127
+ const playlists = computed(() => byNewCount(getLibraryPlaylistsStub()));
128
+
129
+ const models = computed<IRoundCard[]>(() => modelsStub.value.map(model => ({
130
+ title: model.title,
131
+ primaryImageUrl: model.avatarUrl,
132
+ videosCount: model.videosCount,
133
+ link: `/models/${convertString().toSlug(model.title)}`,
134
+ newCount: model.newCount,
135
+ })));
136
+
137
+ const channels = computed<IRoundCard[]>(() => channelsStub.value.map(channel => ({
138
+ title: channel.title,
139
+ primaryImageUrl: channel.avatarUrl ?? '',
140
+ videosCount: channel.videosCount,
141
+ link: `/channels/${convertString().toSlug(channel.title)}`,
142
+ newCount: channel.newCount,
143
+ })));
144
+
145
+ const categories = computed<ICategoryCard[]>(() => categoriesStub.value.map(category => ({
146
+ guid: category.guid,
147
+ name: convertString().toSlug(category.title),
148
+ title: category.title,
149
+ videosCount: category.videosCount,
150
+ thumbUrls: category.videos[0]?.thumbUrls,
151
+ newCount: category.newCount,
152
+ })));
153
+
154
+ const modelsUpdates = computed(() => sumUpdates(modelsStub.value));
155
+ const channelsUpdates = computed(() => sumUpdates(channelsStub.value));
156
+ const categoriesUpdates = computed(() => sumUpdates(categoriesStub.value));
157
+ const playlistsUpdates = computed(() => sumUpdates(playlists.value));
158
+
159
+ useNoindex();
160
+
161
+ const { brandName } = useAppConfig();
162
+
163
+ useHead(() => ({
164
+ title: `${t('library.following')} — ${brandName}`,
165
+ }));
166
+
167
+ const breadcrumbs = [
168
+ {
169
+ name: `${t('navigation.main')}`,
170
+ to: '/',
171
+ },
172
+ {
173
+ name: `${t('library.following')}`,
174
+ },
175
+ ] as IBreadcrumbItem[];
176
+ </script>
177
+
178
+ <style lang="scss">
179
+ .following__section {
180
+ margin-bottom: 48px;
181
+ }
182
+
183
+ .following__header {
184
+ display: flex;
185
+ align-items: center;
186
+ justify-content: space-between;
187
+ column-gap: 16px;
188
+ margin-bottom: 16px;
189
+ }
190
+
191
+ .following__link {
192
+ flex-shrink: 0;
193
+ }
194
+
195
+ /* Плитка «смотреть все» часто оказывается одна в новом ряду: без равных рядов
196
+ ряд сжимается по её контенту и она выходит ниже карточек. */
197
+ .following .grid-categories,
198
+ .following .grid-playlists {
199
+ grid-auto-rows: 1fr;
200
+ }
201
+ </style>
@@ -97,13 +97,16 @@
97
97
  {{ t('library.following') }}
98
98
  </h2>
99
99
 
100
- <UiBtn theme="secondary">
100
+ <UiLink
101
+ :to="generateLink('/following')"
102
+ theme="secondary"
103
+ >
101
104
  {{ t('library.whats_new', { count: newTotal }) }}
102
105
  <UiIcon
103
106
  name="arrow-right"
104
107
  size="16"
105
108
  />
106
- </UiBtn>
109
+ </UiLink>
107
110
  </header>
108
111
 
109
112
  <ul class="library__grid">