itube-specs 0.0.885 → 0.0.889

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.
@@ -1 +1 @@
1
- <svg xmlns="http://www.w3.org/2000/svg" width="24" height="24" fill="none" viewBox="0 0 24 24"><path fill="currentColor" fill-rule="evenodd" d="M2 12C2 6.477 6.477 2 12 2s10 4.477 10 10-4.477 10-10 10S2 17.523 2 12m11-4a1 1 0 1 0-2 0v4a1 1 0 0 0 .293.707l3 3a1 1 0 0 0 1.414-1.414L13 11.586z" clip-rule="evenodd"/></svg>
1
+ <svg xmlns="http://www.w3.org/2000/svg" width="24" height="24" viewBox="0 0 24 24" fill="none" stroke="currentColor" stroke-width="2" stroke-linecap="round" stroke-linejoin="round"><path d="M12 6v6l4 2"/><circle cx="12" cy="12" r="10"/></svg>
@@ -1 +1 @@
1
- <svg xmlns="http://www.w3.org/2000/svg" width="24" height="24" fill="none" viewBox="0 0 24 24"><path fill="currentColor" d="m18.271 13.77 1.813-1.813a4.121 4.121 0 1 0-5.828-5.829l-1.722 1.696zM3.946 12.403c-1.65-1.712-1.65-4.487 0-6.198a4.116 4.116 0 0 1 5.973 0l7.832 8.128-3.583 3.719a3.29 3.29 0 0 1-4.778 0z"/></svg>
1
+ <svg xmlns="http://www.w3.org/2000/svg" width="24" height="24" viewBox="0 0 24 24" fill="none" stroke="currentColor" stroke-width="2" stroke-linecap="round" stroke-linejoin="round" class="lucide lucide-heart w-5 h-5" aria-hidden="true"><path d="M2 9.5a5.5 5.5 0 0 1 9.591-3.676.56.56 0 0 0 .818 0A5.49 5.49 0 0 1 22 9.5c0 2.29-1.5 4-3 5.5l-5.492 5.313a2 2 0 0 1-3 .019L5 15c-1.5-1.5-3-3.2-3-5.5"></path></svg>
@@ -10,7 +10,7 @@
10
10
  <slot name="trigger">
11
11
  <button
12
12
  :aria-label="$t('open_menu')"
13
- class="card-video-dropdown__trigger"
13
+ class="card-dropdown__trigger"
14
14
  type="button"
15
15
  >
16
16
  <UiIcon
@@ -23,7 +23,7 @@
23
23
  <template #header>
24
24
  <CardVideoMini
25
25
  :card="card"
26
- class="card-video-dropdown__preview _to-md"
26
+ class="card-dropdown__preview _to-md"
27
27
  />
28
28
  </template>
29
29
  <template #items="className">
@@ -139,7 +139,7 @@ defineExpose({ openDropdown });
139
139
  </script>
140
140
 
141
141
  <style lang="scss">
142
- .card-video-dropdown__trigger {
142
+ .card-dropdown__trigger {
143
143
  position: relative;
144
144
 
145
145
  &::after {
@@ -147,7 +147,7 @@ defineExpose({ openDropdown });
147
147
  }
148
148
  }
149
149
 
150
- .card-video-dropdown__preview {
150
+ .card-dropdown__preview {
151
151
  padding: 16px 32px 16px 16px;
152
152
  border-bottom: 1px solid $video-card-dropdown-border;
153
153
  }
@@ -0,0 +1,54 @@
1
+ <!-- Кнопка меню карточки: до первого клика в DOM только она, чанк самого меню грузится по требованию -->
2
+ <template>
3
+ <LazyCardDropdown
4
+ v-if="isMounted"
5
+ v-bind="{ ...$attrs, ...props }"
6
+ auto-open
7
+ />
8
+ <button
9
+ v-else
10
+ v-bind="{ onClick: $attrs.onClick }"
11
+ :aria-label="t('open_menu')"
12
+ :class="[$attrs.class, 'card-dropdown__trigger']"
13
+ type="button"
14
+ @click="mountDropdown"
15
+ >
16
+ <UiIcon
17
+ name="dots-vertical"
18
+ size="18"
19
+ />
20
+ </button>
21
+ </template>
22
+
23
+ <script setup lang="ts">
24
+ import type { IVideoCard } from '../../../types';
25
+
26
+ defineOptions({ inheritAttrs: false });
27
+
28
+ const props = defineProps<{
29
+ card: IVideoCard
30
+ showWatchLater?: boolean
31
+ showShare?: boolean
32
+ showReport?: boolean
33
+ showRemove?: boolean
34
+ }>();
35
+
36
+ const { t } = useI18n();
37
+
38
+ const isMounted = ref(false);
39
+
40
+ async function mountDropdown() {
41
+ await import('./index.vue');
42
+ isMounted.value = true;
43
+ }
44
+ </script>
45
+
46
+ <style lang="scss">
47
+ .card-dropdown__trigger {
48
+ position: relative;
49
+
50
+ &::after {
51
+ @include area-point;
52
+ }
53
+ }
54
+ </style>
@@ -94,28 +94,14 @@
94
94
  </span>
95
95
  </p>
96
96
 
97
- <LazyCardVideoDropdown
98
- v-if="dropdownMounted"
97
+ <CardDropdownTrigger
99
98
  :card="videoCard"
100
99
  class="card-history__dropdown"
101
100
  show-remove
102
101
  show-share
103
- auto-open
104
102
  @share="emit('share', videoCard)"
105
103
  @remove="emit('remove', card)"
106
104
  />
107
- <button
108
- v-else
109
- :aria-label="t('open_menu')"
110
- class="card-history__dropdown card-video-dropdown__trigger"
111
- type="button"
112
- @click="dropdownMounted = true"
113
- >
114
- <UiIcon
115
- name="dots-vertical"
116
- size="18"
117
- />
118
- </button>
119
105
  </div>
120
106
  </div>
121
107
  </template>
@@ -188,7 +174,6 @@ const videoCard = computed<IVideoCard>(() => ({
188
174
  thumbUrls: props.card.thumbUrls,
189
175
  }));
190
176
 
191
- const dropdownMounted = ref(false);
192
177
  </script>
193
178
 
194
179
  <style lang="scss">
@@ -110,26 +110,12 @@
110
110
  />
111
111
  </div>
112
112
  </div>
113
- <LazyCardVideoDropdown
114
- v-if="dropdownMounted"
113
+ <CardDropdownTrigger
115
114
  :card="card"
116
115
  :show-watch-later="featureFlags.FeatureWatchLaterEnabled"
117
116
  class="card-video__dropdown"
118
117
  show-report
119
- auto-open
120
118
  />
121
- <button
122
- v-else
123
- :aria-label="t('open_menu')"
124
- class="card-video__dropdown card-video-dropdown__trigger"
125
- type="button"
126
- @click="dropdownMounted = true"
127
- >
128
- <UiIcon
129
- name="dots-vertical"
130
- size="18"
131
- />
132
- </button>
133
119
  </div>
134
120
  </div>
135
121
  </template>
@@ -167,8 +153,6 @@ const { activePreviewId, startPreviewByHover, stopPreviewByHover, startPreviewBy
167
153
 
168
154
  const isPreviewShow = computed(() => activePreviewId.value === previewId);
169
155
 
170
- const dropdownMounted = ref(false);
171
-
172
156
  const posterError = ref(false);
173
157
  const avatarError = ref(false);
174
158
 
@@ -266,13 +250,13 @@ $layout-breakpoint: 250px;
266
250
  }
267
251
  }
268
252
 
269
- .card-video-dropdown__trigger > .ui-icon {
253
+ .card-dropdown__trigger > .ui-icon {
270
254
  @include from-br(sm) {
271
255
  transition: var(--transition) transform;
272
256
  }
273
257
  }
274
258
 
275
- .card-video-dropdown__trigger:is(:hover, :focus-visible) > .ui-icon {
259
+ .card-dropdown__trigger:is(:hover, :focus-visible) > .ui-icon {
276
260
  @include from-br(sm) {
277
261
  transform: scale($video-card-dropdown-size--hover);
278
262
  }
@@ -104,7 +104,7 @@
104
104
  class="player-playlist__card-mini"
105
105
  link
106
106
  />
107
- <CardVideoDropdown
107
+ <CardDropdownTrigger
108
108
  :top="topDropdownIndex === index"
109
109
  :card="item"
110
110
  :show-remove="isOwner"
@@ -426,7 +426,8 @@ function onFollowClick() {
426
426
  }
427
427
 
428
428
  .player-playlist__card:is(:hover, :focus-visible) .player-playlist__card-dropdown,
429
- .player-playlist__card-dropdown.--open .player-playlist__card-dropdown {
429
+ .player-playlist__card-dropdown.--open,
430
+ .player-playlist__card-dropdown:focus-within {
430
431
  @include from-br(sm) {
431
432
  opacity: 1;
432
433
  }
@@ -4,7 +4,7 @@
4
4
  <UiLink
5
5
  v-for="(item, index) in items"
6
6
  :key="`sort-item-${index}`"
7
- :to="item.query ? linkTo(item, index) : generateLink(item.value)"
7
+ :to="item.key ? linkTo(item, index) : generateLink(item.value)"
8
8
  :active="isActive(item, index)"
9
9
  :size="size"
10
10
  :theme="theme"
@@ -59,7 +59,7 @@ function isActive(item: INavLinkItem, index: number) {
59
59
  return true;
60
60
  }
61
61
 
62
- if (item.key && item.query) {
62
+ if (item.key) {
63
63
  return route.query[item.key?.toLowerCase() || ''] === String(item.value)?.toLowerCase();
64
64
  }
65
65
 
@@ -30,6 +30,7 @@
30
30
  icon="video-camera"
31
31
  />
32
32
  <LazySectionToolbar
33
+ :sort-items="sortItems"
33
34
  class="view-videos__options"
34
35
  hydrate-on-idle
35
36
  />
@@ -89,6 +90,7 @@
89
90
  import type {
90
91
  IChipsItem,
91
92
  IModelCard,
93
+ INavLinkItem,
92
94
  IVideoCard,
93
95
  PaginatedResponse
94
96
  } from '../../types';
@@ -106,6 +108,7 @@ const props = defineProps<{
106
108
  dataModels: PaginatedResponse<IModelCard> | null
107
109
  topRandomCategories: IChipsItem[] | null
108
110
  flagImg?: string
111
+ sortItems?: INavLinkItem[]
109
112
  }>();
110
113
 
111
114
  const { t } = useI18n();
@@ -1,11 +1,13 @@
1
1
  import { selectDurationItems, sortItemsDefault, selectAddedItems } from '../../lib';
2
2
  import type { Language } from '../../runtime';
3
3
  import { AsyncData, getSelectedQuery } from '../../runtime';
4
- import type { IVideoCard, PaginatedResponse } from '../../types';
4
+ import type { INavLinkItem, IVideoCard, PaginatedResponse } from '../../types';
5
5
  import { useRoute } from 'vue-router';
6
6
 
7
7
  export const useFetchVideos = async (
8
- apiMethod: (...args: any[]) => Promise<PaginatedResponse<IVideoCard>>
8
+ apiMethod: (...args: any[]) => Promise<PaginatedResponse<IVideoCard>>,
9
+ // defaultOrder: null — сортировка не уходит в запрос, пока её не выбрали явно (выдача поиска)
10
+ { sortItems = sortItemsDefault, defaultOrder = '-country_order' }: { sortItems?: INavLinkItem[], defaultOrder?: string | null } = {}
9
11
  ) => {
10
12
  const route = useRoute();
11
13
 
@@ -30,7 +32,7 @@ export const useFetchVideos = async (
30
32
  ['per-page']: perPage,
31
33
  },
32
34
  filters.value,
33
- getSelectedQuery(route, sortItemsDefault) || '-country_order'
35
+ getSelectedQuery(route, sortItems) || defaultOrder || undefined
34
36
  )(),
35
37
  {
36
38
  watch: [
@@ -31,8 +31,9 @@ export function useMeta(t: (key: string, params?: Record<string, unknown>) => st
31
31
  const sortType = computed(() => {
32
32
  const values = sortOptions?.map(item => item.value);
33
33
  const queryValue = String(route?.query?.['sort']);
34
- const DEFAULT_VALUE = 'trending';
35
- return queryValue && values?.includes(queryValue) ? queryValue : DEFAULT_VALUE;
34
+ // Дефолт — первая кнопка набора: у выдачи поиска это relevant, у остальных listing'ов trending
35
+ const defaultValue = sortOptions?.[0]?.value || 'trending';
36
+ return queryValue && values?.includes(queryValue) ? queryValue : defaultValue;
36
37
  });
37
38
 
38
39
  function getPath(key: string) {
package/lib/index.ts CHANGED
@@ -11,6 +11,7 @@ export * from './scheme/sort-channels';
11
11
  export * from './scheme/sort-models';
12
12
  export * from './scheme/sort-playlists';
13
13
  export * from './scheme/sort-items-default';
14
+ export * from './scheme/sort-items-search';
14
15
  export * from './scheme/history-periods';
15
16
  export * from './scheme/categories-navigation';
16
17
  export * from './scheme/navigation-items';
@@ -0,0 +1,22 @@
1
+ import type { INavLinkItem } from '../../types';
2
+
3
+ /** Сортировки выдачи поиска: релевантность (без order — порядок отдаёт бэкенд) + те же new/longest. */
4
+ export const sortItemsSearch: Array<INavLinkItem> = [
5
+ {
6
+ title: 'sort.relevant',
7
+ value: 'relevant',
8
+ key: 'sort',
9
+ },
10
+ {
11
+ title: 'sort.new',
12
+ value: 'new-videos',
13
+ key: 'sort',
14
+ query: '-created',
15
+ },
16
+ {
17
+ title: 'sort.longest',
18
+ value: 'longest',
19
+ key: 'sort',
20
+ query: '-duration',
21
+ },
22
+ ];
package/package.json CHANGED
@@ -1,7 +1,7 @@
1
1
  {
2
2
  "name": "itube-specs",
3
3
  "type": "module",
4
- "version": "0.0.885",
4
+ "version": "0.0.889",
5
5
  "main": "./nuxt.config.ts",
6
6
  "types": "./types/index.d.ts",
7
7
  "scripts": {
package/pages/library.vue CHANGED
@@ -176,7 +176,7 @@ const newTotal = computed(() => libraryStub.newModels + libraryStub.newChannels
176
176
  const collections = computed<LibraryLink[]>(() => [
177
177
  {
178
178
  to: generateLink('/favorites'),
179
- icon: 'heart',
179
+ icon: 'heart-stroke',
180
180
  title: t('favorites'),
181
181
  subtitle: t('plural.videos', libraryStub.favorites),
182
182
  },
@@ -206,7 +206,7 @@ const collections = computed<LibraryLink[]>(() => [
206
206
  const following = computed<LibraryLink[]>(() => [
207
207
  {
208
208
  to: generateLink('/models'),
209
- icon: 'user-group',
209
+ icon: 'user',
210
210
  title: t('models'),
211
211
  subtitle: t('plural.models', libraryStub.models),
212
212
  badge: t('library.new_count', { count: libraryStub.newModels }),
@@ -17,34 +17,19 @@
17
17
  class="search__result"
18
18
  >
19
19
  <div
20
- v-if="videosData && videosData.items?.length"
20
+ v-if="categoriesData && Object.keys(categoriesData)?.length"
21
21
  class="search__result-section"
22
22
  >
23
23
  <UiSectionTitle
24
- :title="$t('videos')"
25
- :count="videosData.total"
26
- icon="video-camera"
24
+ :title="$t('categories')"
25
+ :count="Object.keys(categoriesData).length"
26
+ icon="grid"
27
27
  title-tag="h2"
28
28
  />
29
- <GridVideos
30
- :items="videosData.items.slice(0, MAXIMUM_VIDEOS)"
29
+ <GridCategories
30
+ :categories="categoriesData"
31
31
  priority
32
- >
33
- <template #grid-end>
34
- <LazyAdvNativeBottom
35
- v-if="featureFlags.AdsNativeEnabled"
36
- hydrate-on-visible
37
- />
38
- <NuxtLink
39
- v-if="videosData.items.length > MAXIMUM_VIDEOS || videosData.total > MAXIMUM_VIDEOS"
40
- :to="generateLink(`/search/${slug}/videos`)"
41
- class="search__show-all"
42
- >
43
- <span class="search__show-all-number">+{{ videosData.total - MAXIMUM_VIDEOS }}</span>
44
- {{ $t('show_more_videos') }}
45
- </NuxtLink>
46
- </template>
47
- </GridVideos>
32
+ />
48
33
  </div>
49
34
 
50
35
  <div
@@ -83,19 +68,34 @@
83
68
  </div>
84
69
 
85
70
  <div
86
- v-if="categoriesData && Object.keys(categoriesData)?.length"
71
+ v-if="videosData && videosData.items?.length"
87
72
  class="search__result-section"
88
73
  >
89
74
  <UiSectionTitle
90
- :title="$t('categories')"
91
- :count="Object.keys(categoriesData).length"
92
- icon="grid"
75
+ :title="$t('videos')"
76
+ :count="videosData.total"
77
+ icon="video-camera"
93
78
  title-tag="h2"
94
79
  />
95
- <GridCategories
96
- :categories="categoriesData"
80
+ <GridVideos
81
+ :items="videosData.items.slice(0, MAXIMUM_VIDEOS)"
97
82
  priority
98
- />
83
+ >
84
+ <template #grid-end>
85
+ <LazyAdvNativeBottom
86
+ v-if="featureFlags.AdsNativeEnabled"
87
+ hydrate-on-visible
88
+ />
89
+ <NuxtLink
90
+ v-if="videosData.items.length > MAXIMUM_VIDEOS || videosData.total > MAXIMUM_VIDEOS"
91
+ :to="generateLink(`/search/${slug}/videos`)"
92
+ class="search__show-all"
93
+ >
94
+ <span class="search__show-all-number">+{{ videosData.total - MAXIMUM_VIDEOS }}</span>
95
+ {{ $t('show_more_videos') }}
96
+ </NuxtLink>
97
+ </template>
98
+ </GridVideos>
99
99
  </div>
100
100
  </div>
101
101
  <UiEmptyState
@@ -139,10 +139,21 @@ const [
139
139
  useFetchRelatedSearch(SearchApiService.getPhrases),
140
140
  ]);
141
141
 
142
- useNoindexThinListing(() => videosData.value?.items?.length);
143
142
  const { t } = useI18n();
144
143
  const { generateLink } = useGenerateLink();
145
144
 
145
+ const hasVideosResults = Boolean(videosData.value?.items?.length);
146
+ const hasModelsResults = Boolean(modelsData.value?.items?.length);
147
+ const hasCategoriesResults = Boolean(categoriesData.value && Object.keys(categoriesData.value).length);
148
+
149
+ if (hasModelsResults && !hasVideosResults && !hasCategoriesResults) {
150
+ await navigateTo(generateLink(`/search/${slug.value}/models`), { redirectCode: 302, replace: true });
151
+ } else if (hasVideosResults && !hasModelsResults && !hasCategoriesResults) {
152
+ await navigateTo(generateLink(`/search/${slug.value}/videos`), { redirectCode: 302, replace: true });
153
+ }
154
+
155
+ useNoindexThinListing(() => videosData.value?.items?.length);
156
+
146
157
  const MAXIMUM_VIDEOS = 23;
147
158
  const MAXIMUM_MODELS = 15;
148
159
 
@@ -11,6 +11,7 @@
11
11
  :title="h1"
12
12
  :data-models="null"
13
13
  :top-random-categories="null"
14
+ :sort-items="sortItemsSearch"
14
15
  hide-categories
15
16
  />
16
17
  <div
@@ -24,7 +25,7 @@
24
25
  import type { IBreadcrumbItem } from '../../../types';
25
26
  import { convertString } from '../../../runtime';
26
27
  import { VideosApiService } from '../../../services/api/videos.service';
27
- import { sortItemsDefault } from '../../../lib';
28
+ import { sortItemsSearch } from '../../../lib';
28
29
 
29
30
  const slug = useSlug();
30
31
 
@@ -33,7 +34,7 @@ const { t } = useI18n();
33
34
  const [
34
35
  { data: videosData, status: videosStatus },
35
36
  ] = await Promise.all([
36
- useFetchVideos(VideosApiService.getSearchByNicheFiltered(slug.value)),
37
+ useFetchVideos(VideosApiService.getSearchByNicheFiltered(slug.value), { sortItems: sortItemsSearch, defaultOrder: null }),
37
38
  ]);
38
39
 
39
40
  useNoindexThinListing(() => videosData.value?.items?.length);
@@ -41,7 +42,7 @@ useNoindexThinListing(() => videosData.value?.items?.length);
41
42
  const pagePureName = useGetPureRouteName();
42
43
  const metaTitle = computed(() => convertString().toCapitalize(slug.value));
43
44
 
44
- const { meta, h1 } = useMeta(t, pagePureName, useAppConfig().brandName, sortItemsDefault, metaTitle);
45
+ const { meta, h1 } = useMeta(t, pagePureName, useAppConfig().brandName, sortItemsSearch, metaTitle);
45
46
 
46
47
  watchEffect(() => {
47
48
  if (slug) {
@@ -17,6 +17,15 @@ describe('getSelectedQuery', () => {
17
17
  expect(getSelectedQuery(route as any, items as any)).toBeUndefined();
18
18
  });
19
19
 
20
+ it('элемент найден, но без query → undefined (сортировка не уходит в запрос)', () => {
21
+ const route = { query: { sort: 'relevant' } };
22
+ const items = [
23
+ { value: 'relevant', key: 'sort' },
24
+ { value: 'newest', key: 'sort', query: '-created' },
25
+ ];
26
+ expect(getSelectedQuery(route as any, items as any)).toBeUndefined();
27
+ });
28
+
20
29
  it('пустой items → undefined', () => {
21
30
  const route = { query: { sort: 'popular' } };
22
31
  expect(getSelectedQuery(route as any, [])).toBeUndefined();
@@ -59,7 +59,7 @@ export class VideosApiService {
59
59
  lang: Language,
60
60
  pagination: RequestPagination,
61
61
  videosFilter: IRequestVideoFilter,
62
- order: string,
62
+ order?: string,
63
63
  niche = NicheHelper.getSiteDefaultNiche(),
64
64
  ): Promise<PaginatedResponse<IVideoCard>> => {
65
65
  return await ApiHelper.fetch(
@@ -74,7 +74,7 @@ export class VideosApiService {
74
74
  },
75
75
  params: {
76
76
  ...pagination,
77
- order,
77
+ ...(order ? { order } : {}),
78
78
  },
79
79
  body: {
80
80
  query: phrase,