itube-specs 0.0.1054 → 0.0.1055

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/SITE_UPDATES.md CHANGED
@@ -11,6 +11,21 @@
11
11
  коммиты до бампа); `npm run patch` сам проставит ей номер версии. Формат пункта: что сделать и где,
12
12
  кому именно (всем / сайтам с N), что будет, если не сделать.
13
13
 
14
+ ## 0.0.1055 — 2026-10-30
15
+
16
+ - **Всем — к сведению:** related searches на странице категории (`useCategoryPage().relatedSearch`) теперь
17
+ отсортированы по убыванию `score` (раньше — в порядке ответа API). Новый ключ `appConfig.categoryRelatedSearchLimit`
18
+ (по умолчанию `null` — все пришедшие фразы, как было): число — показывать только топ-N по весу. Делать ничего не
19
+ надо; videosection-redesign ставит `20`, как на боевом videosection.
20
+
21
+ - **Всем — к сведению:** группы под плеером читаются из поля `entities` ответа видео v2 (бекенд: разделы категорий,
22
+ затем теги и поисковые фразы); `tags` снова только `string[]`. У узлов `tag`/`search` бекенд отдаёт в `title` сам
23
+ тип — заголовок берётся из переводов (новый ключ `tags`, и `related_searches`). BFF обеих ручек видео (по id и по
24
+ url) ходит в `GET /v2/video/url/{url}` — бекенд сам разбирает id или слаг; заглушка групп удалена. Временное демо (`server/utils/video-demo-mock.ts`): без описания — lorem, без разделов
25
+ категорий в `entities` — категории ролика одной группой «Categories»; уберём, когда данные появятся.
26
+ **Нужен бекенд с этой ручкой** (itube-gw `9d57077`): на API без неё страница видео отдаёт 500. Сайтам со своими локалями (hdsex `hi-IN`) — добавить `tags` и
27
+ `related_searches`, иначе заголовок будет на английском.
28
+
14
29
  ## 0.0.1054 — 2026-10-28
15
30
 
16
31
  - **Сайтам со своей темой (hdsex-remaster):** в `_theme.scss` добавить токены тегов в раскрытом описании видео —
package/app.config.ts CHANGED
@@ -14,6 +14,8 @@ export default defineAppConfig({
14
14
  /** Есть ли у сайта сгруппированный вид категорий (`/categories` — группы, `/categories/all` — A–Z).
15
15
  * `false` — `/categories` сам список A–Z: `CategoryNav` без табов Grouped / A-Z, «All» ведёт на `/categories`. */
16
16
  categoriesGrouped: true,
17
+ /** Сколько связанных поисковых фраз (по убыванию веса) показывать на странице категории, `null` — все, что пришли. */
18
+ categoryRelatedSearchLimit: null as number | null,
17
19
  /** Блок Live Cams в сетках листингов (при `FeatureWebcamsEnabled`), `null` — нет. `videos` — после какой строки
18
20
  * карточек и сколько врезок вебкам-тумб (хвост того же списка) в сетке видео листинга (не похожие, не плейлист,
19
21
  * не когда страница передала врезки сама); `models` — в основной сетке моделей (`priority`, не футер). */
@@ -21,6 +21,18 @@
21
21
  display: block;
22
22
  width: var(--size, 64px);
23
23
  height: var(--size, 64px);
24
- content: url('data:image/svg+xml,<svg xmlns="http://www.w3.org/2000/svg" viewBox="0 0 200 200"><radialGradient id="a12" cx=".66" fx=".66" cy=".3125" fy=".3125" gradientTransform="scale(1.5)"><stop offset="0" stop-color="%23FFFFFF"></stop><stop offset=".3" stop-color="%23FFFFFF" stop-opacity=".9"></stop><stop offset=".6" stop-color="%23FFFFFF" stop-opacity=".6"></stop><stop offset=".8" stop-color="%23FFFFFF" stop-opacity=".3"></stop><stop offset="1" stop-color="%23FFFFFF" stop-opacity="0"></stop></radialGradient><circle transform-origin="center" fill="none" stroke="url(%23a12)" stroke-width="15" stroke-linecap="round" stroke-dasharray="200 1000" stroke-dashoffset="0" cx="100" cy="100" r="70"><animateTransform type="rotate" attributeName="transform" calcMode="spline" dur="2" values="360;0" keyTimes="0;1" keySplines="0 0 1 1" repeatCount="indefinite"></animateTransform></circle><circle transform-origin="center" fill="none" opacity=".2" stroke="%23FFFFFF" stroke-width="15" stroke-linecap="round" cx="100" cy="100" r="70"></circle></svg>');
24
+ background-color: $loading-icon;
25
+ content: '';
26
+ mask-position: center;
27
+ mask-size: contain;
28
+ mask-repeat: no-repeat;
29
+ mask-image: url('data:image/svg+xml,<svg xmlns="http://www.w3.org/2000/svg" viewBox="0 0 200 200"><radialGradient id="a12" cx=".66" fx=".66" cy=".3125" fy=".3125" gradientTransform="scale(1.5)"><stop offset="0" stop-color="%23FFFFFF"></stop><stop offset=".3" stop-color="%23FFFFFF" stop-opacity=".9"></stop><stop offset=".6" stop-color="%23FFFFFF" stop-opacity=".6"></stop><stop offset=".8" stop-color="%23FFFFFF" stop-opacity=".3"></stop><stop offset="1" stop-color="%23FFFFFF" stop-opacity="0"></stop></radialGradient><circle transform-origin="center" fill="none" stroke="url(%23a12)" stroke-width="15" stroke-linecap="round" stroke-dasharray="200 1000" stroke-dashoffset="0" cx="100" cy="100" r="70"></circle><circle transform-origin="center" fill="none" opacity=".2" stroke="%23FFFFFF" stroke-width="15" stroke-linecap="round" cx="100" cy="100" r="70"></circle></svg>');
30
+ animation: loading-spin 2s linear infinite;
31
+ }
32
+ }
33
+
34
+ @keyframes loading-spin {
35
+ to {
36
+ transform: translate(-50%, -50%) rotate(-360deg);
25
37
  }
26
38
  }
@@ -16,6 +16,9 @@ $main-bg: $color-black;
16
16
  // ─── Link ───
17
17
  $link-text: $color-white;
18
18
 
19
+ // ─── Loading ───
20
+ $loading-icon: $color-white;
21
+
19
22
  // ─── Cards (shared) ───
20
23
  $card-ring-shadow--hover: none;
21
24
 
@@ -27,6 +27,9 @@ $main-bg: $color-white;
27
27
  // ─── Link ───
28
28
  $link-text: $color-indigo-600;
29
29
 
30
+ // ─── Loading ───
31
+ $loading-icon: $color-indigo-600;
32
+
30
33
  // ─── Cards (shared) ───
31
34
  $card-ring-shadow--hover: 0 0 0 2px $main-bg, 0 0 0 3px $color-gray-400;
32
35
 
@@ -87,7 +87,8 @@
87
87
  :key="group.name || group.title"
88
88
  class="player-interface__tags-group"
89
89
  >
90
- <dt class="player-interface__tags-title">{{ group.title }}</dt>
90
+ <!-- у узлов тегов и поисков бекенд кладёт в title сам тип — заголовок берём из переводов -->
91
+ <dt class="player-interface__tags-title">{{ group.type === 'category' ? group.title : t(group.type === 'tag' ? 'tags' : 'related_searches') }}</dt>
91
92
  <dd class="player-interface__tags-list">
92
93
  <UiChips
93
94
  v-for="item in group.items"
@@ -183,8 +183,9 @@ const emit = defineEmits<{
183
183
 
184
184
  const tagChips = computed((): IChipsItem[] => props.data.tags?.map(item => ({ title: item, value: item, prefix: 'tag' })) ?? []);
185
185
 
186
- // v2: элементы всех групп одной строкой (без повторов) — категории ролика уже в них, отдельные не добавляем
186
+ // v2: теги и поиски из групп (без повторов). Категории — из `categories`: в группы попадают только те, у кого есть раздел
187
187
  const tagGroupChips = computed((): IChipsItem[] => [...new Map((props.data.tagGroups ?? [])
188
+ .filter(group => group.type !== 'category')
188
189
  .flatMap(group => group.items.map(item => convertTagGroupItemToChips(group.type, item)))
189
190
  .map(chip => [`${chip.prefix}/${chip.value}`, chip])).values()]);
190
191
 
@@ -198,13 +199,11 @@ const categoriesChips = computed((): IChipsItem[] => props.data.categories && pr
198
199
 
199
200
  const categoriesTitles = computed<string[]>(() => props.data.categories && props.data.categories?.map(item => item.title?.replace(/\s+/g, '').toLowerCase() ?? ''));
200
201
 
201
- const filterTagChips = computed(() => {
202
- if (categoriesTitles.value && categoriesTitles.value.length > 0) {
203
- return tagChips.value.filter(tagItem => !categoriesTitles.value.includes(tagItem.title.replace(/\s+/g, '').toLowerCase()));
204
- } else {
205
- return tagChips.value;
206
- }
207
- });
202
+ const withoutCategoryTitles = (chips: IChipsItem[]) => (categoriesTitles.value?.length
203
+ ? chips.filter(chip => !categoriesTitles.value.includes(chip.title.replace(/\s+/g, '').toLowerCase()))
204
+ : chips);
205
+
206
+ const filterTagChips = computed(() => withoutCategoryTitles(tagChips.value));
208
207
 
209
208
  const recentSearches = computed(() => {
210
209
  if (props.recent && typeof props.recent === 'object' && Object.keys(props.recent).length === 0) {
@@ -225,7 +224,7 @@ const topChips = computed(() => {
225
224
  prefix: 'search',
226
225
  } as IChipsItem));
227
226
  } else if (tagGroupChips.value.length) {
228
- return tagGroupChips.value;
227
+ return [...categoriesChips.value || [], ...withoutCategoryTitles(tagGroupChips.value)];
229
228
  } else if (categoriesChips.value || filterTagChips.value) {
230
229
  return [...categoriesChips.value || [], ...filterTagChips.value || []];
231
230
  }
@@ -27,7 +27,7 @@ export async function useCategoryPageData() {
27
27
 
28
28
  /**
29
29
  * Логика страницы категории поверх `useCategoryPageData()`: мета и 404, хлебные крошки, номер страницы,
30
- * карточка инфо и чипсы связанных фраз. Синхронная — вызывать в setup после `await useCategoryPageData()`.
30
+ * карточка инфо и чипсы связанных фраз (по убыванию `score`, не больше `appConfig.categoryRelatedSearchLimit`). Синхронная — вызывать в setup после `await useCategoryPageData()`.
31
31
  *
32
32
  * Мета по умолчанию — из API (`metaTitle` / `metaDescription`, без них — название), h1 — `header` или название.
33
33
  * `metaKeys: true` — мета и h1 через `useMeta` по ключам `meta.<page>.<sort>.*` (`{text}` — название, с анти-дублем
@@ -87,14 +87,20 @@ export function useCategoryPage(
87
87
  miniCardTitle: categoryInfo.value?.title,
88
88
  } as ICardInfo));
89
89
 
90
+ const relatedSearchLimit = useAppConfig().categoryRelatedSearchLimit;
91
+
90
92
  const relatedSearch = computed<IChipsItem[]>(() => {
91
93
  if (DISALLOWED_SLUGS.some(item => slug.value.includes(item))) return [];
92
94
 
93
- return relatedData.value?.items?.map(item => ({
95
+ const items = [...(relatedData.value?.items || [])]
96
+ .sort((a, b) => Number(b.score) - Number(a.score))
97
+ .slice(0, relatedSearchLimit ?? undefined);
98
+
99
+ return items.map(item => ({
94
100
  title: item.query,
95
101
  value: item.query,
96
102
  prefix: 'search',
97
- })) || [];
103
+ }));
98
104
  });
99
105
 
100
106
  const relatedCategories = computed(() => categoryInfo.value?.related);
@@ -128,6 +128,7 @@
128
128
  "show_all_models": "Alle anzeigen",
129
129
  "all_categories": "Alphabetisch A-Z",
130
130
  "related_searches": "Ähnliche Suchen",
131
+ "tags": "Tags",
131
132
  "related_models": "Ähnliche Models",
132
133
  "other_names": "Andere Namen",
133
134
  "popular_models": "Beliebte Models",
@@ -207,6 +207,7 @@
207
207
  "show_all_models": "Show all",
208
208
  "all_categories": "Alphabetical A-Z",
209
209
  "related_searches": "Related searches",
210
+ "tags": "Tags",
210
211
  "related_models": "Related models",
211
212
  "other_names": "Other names",
212
213
  "popular_models": "Popular models",
@@ -128,6 +128,7 @@
128
128
  "show_all_models": "Ver todos",
129
129
  "all_categories": "Alfabético A-Z",
130
130
  "related_searches": "Búsquedas relacionadas",
131
+ "tags": "Etiquetas",
131
132
  "related_models": "Modelos relacionados",
132
133
  "other_names": "Otros nombres",
133
134
  "popular_models": "Modelos populares",
@@ -128,6 +128,7 @@
128
128
  "show_all_models": "Voir tout",
129
129
  "all_categories": "Alphabétique A-Z",
130
130
  "related_searches": "Recherches associées",
131
+ "tags": "Tags",
131
132
  "related_models": "Modèles associés",
132
133
  "other_names": "Autres noms",
133
134
  "popular_models": "Modèles populaires",
@@ -128,6 +128,7 @@
128
128
  "show_all_models": "Vedi tutti",
129
129
  "all_categories": "Alfabetico A-Z",
130
130
  "related_searches": "Ricerche correlate",
131
+ "tags": "Tag",
131
132
  "related_models": "Modelli correlati",
132
133
  "other_names": "Altri nomi",
133
134
  "popular_models": "Modelli popolari",
@@ -128,6 +128,7 @@
128
128
  "show_all_models": "すべて見る",
129
129
  "all_categories": "アルファベット A-Z",
130
130
  "related_searches": "関連検索",
131
+ "tags": "タグ",
131
132
  "related_models": "関連モデル",
132
133
  "other_names": "他の名前",
133
134
  "popular_models": "人気モデル",
@@ -128,6 +128,7 @@
128
128
  "show_all_models": "전체 보기",
129
129
  "all_categories": "알파벳 A-Z",
130
130
  "related_searches": "관련 검색",
131
+ "tags": "태그",
131
132
  "related_models": "관련 모델",
132
133
  "other_names": "다른 이름",
133
134
  "popular_models": "인기 모델",
@@ -149,6 +149,7 @@
149
149
  "show_all_models": "Показать все",
150
150
  "all_categories": "По алфавиту А-Я",
151
151
  "related_searches": "Похожие запросы",
152
+ "tags": "Теги",
152
153
  "related_models": "Похожие модели",
153
154
  "other_names": "Другие имена",
154
155
  "popular_models": "Популярные модели",
@@ -128,6 +128,7 @@
128
128
  "show_all_models": "查看全部",
129
129
  "all_categories": "字母 A-Z",
130
130
  "related_searches": "相关搜索",
131
+ "tags": "标签",
131
132
  "related_models": "相关模特",
132
133
  "other_names": "其他名称",
133
134
  "popular_models": "热门模特",
package/package.json CHANGED
@@ -1,7 +1,7 @@
1
1
  {
2
2
  "name": "itube-specs",
3
3
  "type": "module",
4
- "version": "0.0.1054",
4
+ "version": "0.0.1055",
5
5
  "main": "./nuxt.config.ts",
6
6
  "types": "./types/index.d.ts",
7
7
  "scripts": {
@@ -172,35 +172,29 @@ describe('cleanVideoData', () => {
172
172
  });
173
173
  });
174
174
 
175
- const raw = (tags: unknown) => ({ tags } as unknown as IRawVideoData);
175
+ const raw = (entities: unknown) => ({ tags: ['anal'], entities } as unknown as IRawVideoData);
176
176
 
177
- describe('cleanVideoData — теги', () => {
178
- it('v1: строки остаются плоским списком, групп нет', () => {
179
- const data = cleanVideoData(raw(['Anal', 'Big Ass']));
180
- expect(data.tags).toEqual(['Anal', 'Big Ass']);
177
+ describe('cleanVideoData — группы под плеером', () => {
178
+ it('v1: групп нет, теги — как пришли', () => {
179
+ const data = cleanVideoData(raw(undefined));
180
+ expect(data.tags).toEqual(['anal']);
181
181
  expect(data.tagGroups).toEqual([]);
182
182
  });
183
183
 
184
- it('v2: группы по order, элементы внутри по order, пустые и неизвестного типа выкинуты, плоский список — склейка без повторов', () => {
184
+ it('v2: группы по order, элементы внутри по order, пустые и неизвестного типа выкинуты', () => {
185
185
  const data = cleanVideoData(raw([
186
186
  { type: 'category', name: 'positions', title: 'Positions', order: 1, items: [{ name: 'doggy', title: 'Doggy style' }] },
187
187
  { type: 'category', name: 'empty', title: 'Empty', order: 2, items: [] },
188
188
  { type: 'unknown', name: 'odd', title: 'Odd', order: 3, items: [{ name: 'odd', title: 'Odd' }] },
189
- { type: 'tag', name: 'actions', title: 'Actions', order: 0, items: [
190
- { name: 'rimming', title: 'Rimming', order: 2 },
191
- { name: 'anal', title: 'Anal', order: 1 },
192
- { name: 'doggy', title: 'Doggy style', order: 3 },
189
+ { type: 'tag', name: 'tag', title: 'tag', order: 0, items: [
190
+ { name: 'rimming', title: 'rimming', order: 2 },
191
+ { name: 'anal', title: 'anal', order: 1 },
192
+ { name: 'doggy', title: 'doggy', order: 3 },
193
193
  ] },
194
194
  ]));
195
195
 
196
- expect(data.tagGroups.map(group => group.name)).toEqual(['actions', 'positions']);
197
- expect(data.tagGroups[0].type).toBe('tag');
196
+ expect(data.tagGroups.map(group => group.name)).toEqual(['tag', 'positions']);
198
197
  expect(data.tagGroups[0].items.map(item => item.name)).toEqual(['anal', 'rimming', 'doggy']);
199
- expect(data.tags).toEqual(['Anal', 'Rimming', 'Doggy style']);
200
- });
201
-
202
- it('нет тегов — пусто', () => {
203
- expect(cleanVideoData(raw(undefined)).tagGroups).toEqual([]);
204
- expect(cleanVideoData(raw(undefined)).tags).toEqual([]);
198
+ expect(data.tags).toEqual(['anal']);
205
199
  });
206
200
  });
@@ -68,26 +68,19 @@ const byOrder = <T extends { order?: number }>(a: T, b: T) => (a.order ?? 0) - (
68
68
 
69
69
  const TAG_GROUP_TYPES = new Set<string>(['tag', 'category', 'search']);
70
70
 
71
- // v2 отдаёт в `tags` группы, v1 — строки. Группы сортируются по `order` (и элементы внутри, если он задан);
72
- // пустые и неизвестного типа (ссылку не построить) отбрасываются; плоский список — склейка элементов всех групп без повторов.
73
- const cleanTags = (raw: IRawVideoData['tags']): { tags: string[], tagGroups: IVideoTagGroup[] } => {
74
- if (!Array.isArray(raw) || !raw.length) return { tags: [], tagGroups: [] };
75
- if (typeof raw[0] === 'string') return { tags: raw as string[], tagGroups: [] };
76
-
77
- const tagGroups = (raw as IVideoTagGroup[])
78
- .filter(group => group?.title && TAG_GROUP_TYPES.has(group.type) && Array.isArray(group.items))
79
- .map(group => ({
80
- type: group.type,
81
- name: group.name || '',
82
- title: group.title,
83
- order: group.order ?? 0,
84
- items: group.items.filter(item => item?.title && item.name).sort(byOrder),
85
- }))
86
- .filter(group => group.items.length > 0)
87
- .sort(byOrder);
88
-
89
- return { tags: [...new Set(tagGroups.flatMap(group => group.items.map(item => item.title)))], tagGroups };
90
- };
71
+ // v2 отдаёт группы в `entities`. Группы сортируются по `order` (и элементы внутри, если он задан);
72
+ // пустые и неизвестного типа (ссылку не построить) отбрасываются.
73
+ const cleanTagGroups = (raw: IRawVideoData['entities']): IVideoTagGroup[] => (Array.isArray(raw) ? raw : [])
74
+ .filter(group => group?.title && TAG_GROUP_TYPES.has(group.type) && Array.isArray(group.items))
75
+ .map(group => ({
76
+ type: group.type,
77
+ name: group.name || '',
78
+ title: group.title,
79
+ order: group.order ?? 0,
80
+ items: group.items.filter(item => item?.title && item.name).sort(byOrder),
81
+ }))
82
+ .filter(group => group.items.length > 0)
83
+ .sort(byOrder);
91
84
 
92
85
  /** Нормализует сырые данные видео к типу IVideoData. */
93
86
  export const cleanVideoData = (data: IRawVideoData, theme?: string): IVideoData => ({
@@ -99,7 +92,8 @@ export const cleanVideoData = (data: IRawVideoData, theme?: string): IVideoData
99
92
  views: data.views || 0,
100
93
  likes: data.likes || 0,
101
94
  id: data.id || '',
102
- ...cleanTags(data.tags),
95
+ tags: data.tags || [],
96
+ tagGroups: cleanTagGroups(data.entities),
103
97
  models: cleanVideoModels(data.models, theme),
104
98
  categories: data.categories || [],
105
99
  actions: data.actions || null,
@@ -13,8 +13,8 @@ export default defineEventHandler(async (event): Promise<IVideoData> => {
13
13
  'user-agent': requestHeaders[ 'user-agent' ] ?? '',
14
14
  };
15
15
 
16
- // ручка видео v2 (`/v2/video/id/…`, теги — группами) ещё не готова: v1 + мок групп (`withMockTagGroups`)
17
- const videoUrl = getUrlWithProxiedParams(`${apiUrl}/v1/video/id/${id}`, event);
16
+ // v2 ищет ролик по `url` — это id или слаг, бекенд разбирает сам; группы под плеером — в `entities`
17
+ const videoUrl = getUrlWithProxiedParams(`${apiUrl}/v2/video/url/${id}`, event);
18
18
  const modelsUrl = getUrlWithProxiedParams(`${apiUrl}/v2/models/list-by-video-id/${id}`, event);
19
19
 
20
20
  const [videoRaw, modelsRaw] = await Promise.all([
@@ -25,7 +25,7 @@ export default defineEventHandler(async (event): Promise<IVideoData> => {
25
25
  apiFetch(modelsUrl, { method: 'GET', headers: clientHeaders }).catch(() => null),
26
26
  ]);
27
27
 
28
- const videoData = withMockTagGroups(videoRaw.data as IRawVideoData);
28
+ const videoData = withDemoFallbacks(videoRaw.data as IRawVideoData);
29
29
  const modelsData = modelsRaw?.data as Record<'items', Array<IRawModelCard>>;
30
30
 
31
31
  return cleanVideoData(
@@ -12,8 +12,7 @@ export default defineEventHandler(async (event): Promise<IVideoData> => {
12
12
  ...requestHeaders,
13
13
  'user-agent': requestHeaders[ 'user-agent' ] ?? '',
14
14
  };
15
- // ручка видео v2 (`/v2/video/url/…`, теги — группами) ещё не готова: v1 + мок групп (`withMockTagGroups`)
16
- const videoUrl = getUrlWithProxiedParams(`${apiUrl}/v1/video/url/${url}`, event);
15
+ const videoUrl = getUrlWithProxiedParams(`${apiUrl}/v2/video/url/${url}`, event);
17
16
  const modelsUrl = getUrlWithProxiedParams(`${apiUrl}/v2/models/list-by-video-url/${url}`, event);
18
17
 
19
18
  const [videoRaw, modelsRaw] = await Promise.all([
@@ -24,7 +23,7 @@ export default defineEventHandler(async (event): Promise<IVideoData> => {
24
23
  apiFetch(modelsUrl, { method: 'GET', headers: clientHeaders }).catch(() => null),
25
24
  ]);
26
25
 
27
- const videoData = withMockTagGroups(videoRaw.data as IRawVideoData);
26
+ const videoData = withDemoFallbacks(videoRaw.data as IRawVideoData);
28
27
  const modelsData = modelsRaw?.data as Record<'items', Array<IRawModelCard>>;
29
28
 
30
29
  return cleanVideoData(
@@ -0,0 +1,26 @@
1
+ import type { IRawVideoData } from '../../types';
2
+
3
+ // заглушка нарочно lorem — сразу видно, что не настоящее описание
4
+ const MOCK_DESCRIPTION = 'Lorem ipsum dolor sit amet, consectetur adipiscing elit, sed do eiusmod tempor incididunt ut labore et '
5
+ + 'dolore magna aliqua. Ut enim ad minim veniam, quis nostrud exercitation ullamco laboris nisi ut aliquip ex ea commodo '
6
+ + 'consequat. Duis aute irure dolor in reprehenderit in voluptate velit esse cillum dolore eu fugiat nulla pariatur.';
7
+
8
+ /**
9
+ * shortcut: демо блока под плеером, пока у роликов нет описаний и в админке не заведены разделы категорий — без описания
10
+ * подставляется lorem, без разделов категории ролика идут одной группой «Categories». Удалить, когда данные появятся.
11
+ */
12
+ export function withDemoFallbacks(video: IRawVideoData): IRawVideoData {
13
+ if (!video) return video;
14
+
15
+ const entities = video.entities ?? [];
16
+ const hasSections = entities.some(group => group.type === 'category');
17
+ const categories = (video.categories ?? []).map(({ name, title, icon }) => ({ name, title, icon }));
18
+
19
+ return {
20
+ ...video,
21
+ description: video.description || MOCK_DESCRIPTION,
22
+ entities: hasSections || !categories.length
23
+ ? entities
24
+ : [{ type: 'category' as const, name: 'categories', title: 'Categories', order: -1, items: categories }, ...entities],
25
+ };
26
+ }
@@ -18,8 +18,9 @@ export interface IRawVideoData {
18
18
  dislikes: number;
19
19
  popularity: number; // unused
20
20
  id: string;
21
- /** v1 — названия тегов; v2 — группы (`IVideoTagGroup[]`). */
22
- tags: string[] | IVideoTagGroup[];
21
+ tags: string[];
22
+ /** Только v2: категории по разделам, затем теги и поисковые фразы (`tag`/`search` — с `title`, равным типу). */
23
+ entities?: IVideoTagGroup[];
23
24
  models: {
24
25
  guid: string;
25
26
  title: string;
@@ -66,7 +66,6 @@ export interface IVideoData {
66
66
  likes: number;
67
67
  dislikes: number;
68
68
  id: string;
69
- /** Все теги одним списком: в v2 — склейка элементов всех групп. */
70
69
  tags: string[];
71
70
  /** Группы под плеером (раскрытое описание); у ответа v1 групп нет — пусто. */
72
71
  tagGroups: IVideoTagGroup[];
@@ -1,33 +0,0 @@
1
- import type { IRawVideoData, IVideoTagGroup } from '../../types';
2
-
3
- const item = (name: string) => ({ name, title: name.charAt(0).toUpperCase() + name.slice(1) });
4
- const group = (order: number, name: string, title: string, names: string[], type: IVideoTagGroup['type'] = 'category'): IVideoTagGroup =>
5
- ({ type, name, title, order, items: names.map(item) });
6
-
7
- /** Статичная заглушка групп (контракт ручки видео v2): настоящие категории, теги и поиски сайта (все три типа узлов),
8
- * категорийных групп и элементов — как в макете. Одна на все ролики — только для отладки вида и поведения; группировать будет бекенд. */
9
- const MOCK_TAG_GROUPS: IVideoTagGroup[] = [
10
- group(0, 'actions', 'Actions', ['blowjob', 'cumshot', 'creampie', 'deep throat', 'handjob', 'fingering', 'squirt']),
11
- group(1, 'positions', 'Positions', ['cowgirl', 'missionary', 'face sitting']),
12
- group(2, 'body_type', 'Body type', ['big boobs', 'big ass', 'petite', 'big cock']),
13
- group(3, 'appearance', 'Appearance', ['blond', 'brunette', 'redhead', 'tattoo']),
14
- group(4, 'location', 'Location', ['bedroom']),
15
- group(5, 'format', 'Format', ['amateur', 'point of view', 'compilation', 'homemade']),
16
- group(6, 'tags', 'Tags', ['moaning', 'loud moaning', 'female orgasm', 'bouncing tits', 'natural big tits'], 'tag'),
17
- group(7, 'searches', 'Related searches', ['amateur wife', 'big tits teen', 'homemade blowjob'], 'search'),
18
- ];
19
-
20
- // заглушка нарочно lorem — сразу видно, что не настоящее описание
21
- const MOCK_DESCRIPTION = 'Lorem ipsum dolor sit amet, consectetur adipiscing elit, sed do eiusmod tempor incididunt ut labore et '
22
- + 'dolore magna aliqua. Ut enim ad minim veniam, quis nostrud exercitation ullamco laboris nisi ut aliquip ex ea commodo '
23
- + 'consequat. Duis aute irure dolor in reprehenderit in voluptate velit esse cillum dolore eu fugiat nulla pariatur.';
24
-
25
- /**
26
- * shortcut: ручка видео v2 ещё не готова — BFF ходит в v1 и отдаёт вместо тегов статичную заглушку групп (и описание,
27
- * если его нет: у роликов с группами оно будет всегда). Удалить вместе с переходом на `/v2/video/*`.
28
- */
29
- export function withMockTagGroups(video: IRawVideoData): IRawVideoData {
30
- if (!video) return video;
31
-
32
- return { ...video, tags: MOCK_TAG_GROUPS, description: video.description || MOCK_DESCRIPTION };
33
- }