itube-specs 0.0.999 → 0.0.1003

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.
Files changed (57) hide show
  1. package/components/card/model-mini.vue +1 -3
  2. package/components/card/model.vue +2 -3
  3. package/components/card/round.vue +1 -3
  4. package/components/card/webcam/index.vue +1 -5
  5. package/components/info/model.vue +3 -4
  6. package/components/search/item.vue +2 -3
  7. package/components/view/model-videos.vue +0 -2
  8. package/components/view/models.vue +0 -2
  9. package/components/view/video.vue +3 -6
  10. package/components/view/videos.vue +0 -2
  11. package/components/webcam/header.vue +1 -1
  12. package/composables/use-generate-link.ts +6 -6
  13. package/composables/use-model-placeholder.ts +10 -0
  14. package/composables/use-niches.ts +3 -1
  15. package/config/__tests__/niches.test.ts +61 -4
  16. package/config/niches.ts +73 -10
  17. package/middleware/niche.global.ts +12 -6
  18. package/package.json +1 -1
  19. package/pages/categories/[slug].vue +0 -2
  20. package/pages/channels/[channel].vue +0 -2
  21. package/pages/following.vue +1 -2
  22. package/pages/search/[slug]/index.vue +1 -5
  23. package/public/img/model-placeholder/gay-dark-portrait.avif +0 -0
  24. package/public/img/model-placeholder/gay-dark-portrait.webp +0 -0
  25. package/public/img/model-placeholder/gay-dark-square.avif +0 -0
  26. package/public/img/model-placeholder/gay-dark-square.webp +0 -0
  27. package/public/img/model-placeholder/gay-light-portrait.avif +0 -0
  28. package/public/img/model-placeholder/gay-light-portrait.webp +0 -0
  29. package/public/img/model-placeholder/gay-light-square.avif +0 -0
  30. package/public/img/model-placeholder/gay-light-square.webp +0 -0
  31. package/public/img/model-placeholder/shemale-dark-portrait.avif +0 -0
  32. package/public/img/model-placeholder/shemale-dark-portrait.webp +0 -0
  33. package/public/img/model-placeholder/shemale-dark-square.avif +0 -0
  34. package/public/img/model-placeholder/shemale-dark-square.webp +0 -0
  35. package/public/img/model-placeholder/shemale-light-portrait.avif +0 -0
  36. package/public/img/model-placeholder/shemale-light-portrait.webp +0 -0
  37. package/public/img/model-placeholder/shemale-light-square.avif +0 -0
  38. package/public/img/model-placeholder/shemale-light-square.webp +0 -0
  39. package/public/img/model-placeholder/straight-dark-portrait.avif +0 -0
  40. package/public/img/model-placeholder/straight-dark-portrait.webp +0 -0
  41. package/public/img/model-placeholder/straight-dark-square.avif +0 -0
  42. package/public/img/model-placeholder/straight-dark-square.webp +0 -0
  43. package/public/img/model-placeholder/straight-light-portrait.avif +0 -0
  44. package/public/img/model-placeholder/straight-light-portrait.webp +0 -0
  45. package/public/img/model-placeholder/straight-light-square.avif +0 -0
  46. package/public/img/model-placeholder/straight-light-square.webp +0 -0
  47. package/runtime/utils/__tests__/model-placeholder.test.ts +15 -8
  48. package/runtime/utils/model-placeholder.ts +21 -7
  49. package/utils/niche-helper.ts +20 -0
  50. package/public/img/model-placeholder-female-portrait.avif +0 -0
  51. package/public/img/model-placeholder-female-portrait.webp +0 -0
  52. package/public/img/model-placeholder-female-square.avif +0 -0
  53. package/public/img/model-placeholder-female-square.webp +0 -0
  54. package/public/img/model-placeholder-portrait.avif +0 -0
  55. package/public/img/model-placeholder-portrait.webp +0 -0
  56. package/public/img/model-placeholder-square.avif +0 -0
  57. package/public/img/model-placeholder-square.webp +0 -0
@@ -47,14 +47,12 @@
47
47
 
48
48
  <script setup lang="ts">
49
49
  import type { IModelCard } from '../../types';
50
- import { modelPlaceholder } from '../../runtime';
51
- import { NicheHelper } from '../../utils/niche-helper';
52
50
 
53
51
  const props = defineProps<{
54
52
  card: IModelCard
55
53
  }>();
56
54
 
57
- const placeholder = modelPlaceholder(props.card.gender, NicheHelper.getCurrentNiche(), 'square');
55
+ const placeholder = useModelPlaceholder()(props.card.gender, 'square');
58
56
  const fallbackImage = placeholder.webp;
59
57
  const fallbackAvifImage = placeholder.avif;
60
58
  const imageError = ref(false);
@@ -77,8 +77,7 @@
77
77
  </template>
78
78
 
79
79
  <script setup lang="ts">
80
- import { convertString, formatDate, modelPlaceholder } from '../../runtime';
81
- import { NicheHelper } from '../../utils/niche-helper';
80
+ import { convertString, formatDate } from '../../runtime';
82
81
  import type { IModelCard } from '../../types';
83
82
 
84
83
  const props = withDefaults(defineProps<{
@@ -99,7 +98,7 @@ const { t } = useI18n();
99
98
  */
100
99
  const SIZES = 'auto, (max-width: 449px) 50vw, (max-width: 1385px) 215px, 250px';
101
100
 
102
- const placeholder = modelPlaceholder(props.card.gender, NicheHelper.getCurrentNiche(), 'portrait');
101
+ const placeholder = useModelPlaceholder()(props.card.gender, 'portrait');
103
102
  const fallbackImage = placeholder.webp;
104
103
  const fallbackAvifImage = placeholder.avif;
105
104
  const imageError = ref(false);
@@ -43,8 +43,6 @@
43
43
  <script setup lang="ts">
44
44
  import { CSS_BREAKPOINTS } from '../../config/css-breakpoints';
45
45
  import type { IRoundCard } from '../../types';
46
- import { modelPlaceholder } from '../../runtime';
47
- import { NicheHelper } from '../../utils/niche-helper';
48
46
 
49
47
  const props = withDefaults(defineProps<{
50
48
  card: IRoundCard
@@ -68,7 +66,7 @@ const SIZES = `auto, (max-width: ${CSS_BREAKPOINTS.sm - 1}px) 80px, 96px`;
68
66
  // У карточек моделей есть `gender` (пусть и пустой) — им заглушка по полу и нише, остальным — из пропа.
69
67
  const modelFallback = props.card.gender === undefined
70
68
  ? null
71
- : modelPlaceholder(props.card.gender, NicheHelper.getCurrentNiche(), 'square');
69
+ : useModelPlaceholder()(props.card.gender, 'square');
72
70
  const fallbackImage = modelFallback?.webp || props.placeholderAvatar;
73
71
  const imageError = ref(false);
74
72
 
@@ -233,11 +233,7 @@ const ageFilterUrl = computed(() => {
233
233
 
234
234
  const webcamsPath = useWebcamsPath();
235
235
 
236
- const nichePath = computed(() => {
237
- const niche = NicheHelper.getCurrentNiche();
238
-
239
- return niche && niche !== 'straight' ? `/${niche}` : '';
240
- });
236
+ const nichePath = computed(() => NicheHelper.getNichePath());
241
237
 
242
238
  // Короткая форма («США», а не «Соединенные Штаты») теперь дефолт утилиты — своего
243
239
  // вызова ICU карточке больше не надо
@@ -136,8 +136,7 @@
136
136
  </template>
137
137
 
138
138
  <script setup lang="ts">
139
- import { AuthSteps, modelPlaceholder } from '../../runtime';
140
- import { NicheHelper } from '../../utils/niche-helper';
139
+ import { AuthSteps } from '../../runtime';
141
140
  import type { ICardInfo, IGroupedParameter, ILinkItem, IModelCard } from '../../types';
142
141
  import { CSS_BREAKPOINTS } from '../../config/css-breakpoints';
143
142
  import { AuthorizationApiService } from '../../services/api/authorization.service';
@@ -152,11 +151,11 @@ const props = defineProps<{
152
151
  const { t, locale } = useI18n();
153
152
 
154
153
  const featureFlags = useRuntimeConfig().public.featureFlags;
155
- const niche = NicheHelper.getCurrentNiche();
154
+ const placeholderOf = useModelPlaceholder();
156
155
 
157
156
  const KNOWN_ICONS = ['facebook', 'instagram', 'tiktok', 'twitter', 'x', 'youtube', 'snapchat'];
158
157
 
159
- const placeholder = computed(() => modelPlaceholder(props.card.gender, niche, 'portrait'));
158
+ const placeholder = computed(() => placeholderOf(props.card.gender, 'portrait'));
160
159
 
161
160
  /** `.info-model__img` — 160px до sm, 240px дальше. `auto` впереди — см. `UiImg`. */
162
161
  const IMG_SIZES = `auto, (max-width: ${CSS_BREAKPOINTS.sm - 1}px) 160px, 240px`;
@@ -31,8 +31,7 @@
31
31
 
32
32
  <script setup lang="ts">
33
33
  import type { ISearchTopModelsType } from '../../types';
34
- import { convertString, modelPlaceholder } from '../../runtime';
35
- import { NicheHelper } from '../../utils/niche-helper';
34
+ import { convertString } from '../../runtime';
36
35
  import DOMPurify from 'dompurify';
37
36
 
38
37
  const props = defineProps<{
@@ -43,7 +42,7 @@ const props = defineProps<{
43
42
  }>();
44
43
 
45
44
  /** Иконка 32px — квадратный кроп по лицу, если он есть в ответе. */
46
- const placeholder = modelPlaceholder(props.item?.gender, NicheHelper.getCurrentNiche(), 'square');
45
+ const placeholder = useModelPlaceholder()(props.item?.gender, 'square');
47
46
  const icon = computed(() => ({
48
47
  webp: props.item?.imageUrl || placeholder.webp,
49
48
  avif: props.item?.imageUrl ? props.item.imageAvifUrl : placeholder.avif,
@@ -90,8 +90,6 @@
90
90
  v-if="similarModels && similarModels.length > 0"
91
91
  :items="modelsRoundCards"
92
92
  :title="$t('similar_models')"
93
- placeholder-avatar="/img/model-placeholder-square.webp"
94
- placeholder-avatar-avif="/img/model-placeholder-square.avif"
95
93
  class="view-model-videos__other-models"
96
94
  hydrate-on-visible
97
95
  />
@@ -6,8 +6,6 @@
6
6
  v-if="topCards.length >= MINIMUM_TOP_THUMBS"
7
7
  :items="topCards"
8
8
  class="view-models__popular"
9
- placeholder-avatar="/img/model-placeholder-square.webp"
10
- placeholder-avatar-avif="/img/model-placeholder-square.avif"
11
9
  only-grid
12
10
  />
13
11
 
@@ -91,8 +91,6 @@
91
91
  :items="modelsRoundCards"
92
92
  :title="$t('related_models')"
93
93
  class="view-video__popular-models"
94
- placeholder-avatar="/img/model-placeholder-square.webp"
95
- placeholder-avatar-avif="/img/model-placeholder-square.avif"
96
94
  hydrate-on-visible
97
95
  />
98
96
 
@@ -112,8 +110,7 @@
112
110
 
113
111
  <script setup lang="ts">
114
112
  import type { IChipsItem, IModelCard, IStripcashModel, IVideoCard, IVideoData, PaginatedResponse } from '../../types';
115
- import { convertModelToRoundCard, modelPlaceholder } from '../../runtime';
116
- import { NicheHelper } from '../../utils/niche-helper';
113
+ import { convertModelToRoundCard } from '../../runtime';
117
114
  import type { AsyncDataRequestStatus } from '#app';
118
115
 
119
116
  const featureFlags = useRuntimeConfig().public.featureFlags;
@@ -206,10 +203,10 @@ const topChips = computed(() => {
206
203
  const topChipsStrings = computed(() => topChips.value?.map(item => item.value));
207
204
 
208
205
  /** Портрет в чипсе — квадратный кроп по лицу; своего у видео нет, его подмешивает BFF. */
209
- const niche = NicheHelper.getCurrentNiche();
206
+ const placeholderOf = useModelPlaceholder();
210
207
 
211
208
  function getModelChips(model: IVideoData['models'][number]): IChipsItem {
212
- const placeholder = modelPlaceholder(model.gender, niche, 'square');
209
+ const placeholder = placeholderOf(model.gender, 'square');
213
210
 
214
211
  return {
215
212
  prefix: '/models',
@@ -49,8 +49,6 @@
49
49
  :all-link-text="$t('show_all_models')"
50
50
  :header-link-text-short="$t('all_models')"
51
51
  class="view-videos__popular-models"
52
- placeholder-avatar="/img/model-placeholder-square.webp"
53
- placeholder-avatar-avif="/img/model-placeholder-square.avif"
54
52
  hydrate-on-visible
55
53
  />
56
54
  </GridVideos>
@@ -41,7 +41,7 @@ const { t, te } = useI18n();
41
41
 
42
42
  const webcamsConfig = computed(() => (useRuntimeConfig().public.webcamsConfig || {}) as IWebcamsConfig);
43
43
  const niche = computed(() => NicheHelper.getCurrentNiche() || 'straight');
44
- const nichePath = computed(() => (niche.value === 'straight' ? '' : `/${niche.value}`));
44
+ const nichePath = computed(() => NicheHelper.getNichePath(niche.value));
45
45
 
46
46
  /**
47
47
  * Заголовок нишевый: у straight — общий, у остальных ниш берём собственный ключ
@@ -1,4 +1,5 @@
1
1
  import type { Language, Niche } from '../runtime';
2
+ import { nicheSlug, withNichePrefix, type NicheSlugs } from '../config/niches';
2
3
  import { NicheHelper } from '../utils/niche-helper';
3
4
 
4
5
  /** Возвращает generateLink — генерирует путь с учётом текущей ниши и локали. */
@@ -25,13 +26,12 @@ export const useGenerateLink = () => {
25
26
  const nicheFreeRoots = (appConfig.nicheFreeRoots as string[] | undefined) || [];
26
27
  const isNicheFree = nicheFreeRoots.some(root => cleanPath === root || cleanPath.startsWith(`${root}/`));
27
28
 
28
- const withNiche =
29
- !niche.value || niche.value === defaultNiche || isOneNiche || isNicheFree
30
- ? cleanPath
31
- : `/${niche.value}${cleanPath}`;
32
-
33
29
  const locale = localeArg || undefined;
34
- return localePath(withNiche, locale);
30
+ const localized = localePath(cleanPath, locale);
31
+
32
+ if (!niche.value || niche.value === defaultNiche || isOneNiche || isNicheFree) return localized;
33
+
34
+ return withNichePrefix(localized, `/${nicheSlug(niche.value, appConfig.nicheSlugs as NicheSlugs | undefined)}`, localePath('/', locale));
35
35
  };
36
36
 
37
37
  return { generateLink };
@@ -0,0 +1,10 @@
1
+ import { modelPlaceholder, type ModelPlaceholderShape } from '../runtime';
2
+ import { NicheHelper } from '../utils/niche-helper';
3
+
4
+ /** Заглушка портрета модели под нишу страницы и тему сайта (`appConfig.theme`); пол модели — аргументом. */
5
+ export const useModelPlaceholder = () => {
6
+ const niche = NicheHelper.getCurrentNiche();
7
+ const theme = useAppConfig().theme as string | undefined;
8
+
9
+ return (gender: string | undefined, shape: ModelPlaceholderShape) => modelPlaceholder(gender, niche, theme, shape);
10
+ };
@@ -1,4 +1,5 @@
1
1
  import type { Niche } from '../runtime';
2
+ import { withNichePrefix } from '../config/niches';
2
3
  import { NicheHelper } from '../utils/niche-helper';
3
4
 
4
5
  /** Иконка из assets/icons/common под каждую нишу. */
@@ -40,7 +41,8 @@ export const useNiches = () => {
40
41
  function changeNiche(name: Niche) {
41
42
  if (name === current.value) return;
42
43
  current.value = name;
43
- window.location.href = localePath(name === defaultNiche ? '/' : `/${name}`);
44
+ const root = localePath('/');
45
+ window.location.href = withNichePrefix(root, NicheHelper.getNichePath(name), root);
44
46
  }
45
47
 
46
48
  return { niches, currentNiche, changeNiche };
@@ -1,5 +1,5 @@
1
1
  import { describe, it, expect } from 'vitest';
2
- import { addNicheAliases, nicheRoutePrefix } from '../niches';
2
+ import { addNicheAliases, nicheFromPath, nicheFromSlug, nicheRoutePrefix, nicheSlug, nichePath, withNichePrefix } from '../niches';
3
3
 
4
4
  const CONFIG = { niches: ['straight', 'gay', 'shemale'], defaultNiche: 'straight', contentRoots: ['/', '/videos', '/models'] };
5
5
 
@@ -19,13 +19,70 @@ describe('niches', () => {
19
19
  ];
20
20
  addNicheAliases(pages, CONFIG);
21
21
 
22
- expect(pages[0]?.alias).toEqual(['/:niche(gay|shemale)']);
23
- expect(pages[1]?.alias).toEqual(['/v/:slug', '/:niche(gay|shemale)/videos/:slug']);
24
- expect(pages[2]?.alias).toEqual(['/:niche(gay|shemale)/models']);
22
+ expect(pages[0]?.alias).toEqual(['/gay', '/shemale']);
23
+ expect(pages[1]?.alias).toEqual(['/v/:slug', '/gay/videos/:slug', '/shemale/videos/:slug']);
24
+ expect(pages[2]?.alias).toEqual(['/gay/models', '/shemale/models']);
25
25
  expect(pages[3]?.alias).toBeUndefined();
26
26
  expect(pages[4]?.alias).toBeUndefined();
27
27
  });
28
28
 
29
+ it('слаги ниш: в префиксе роута слаг, ниша без слага — под кодом', () => {
30
+ const slugs = { gay: 'gays' };
31
+ expect(nicheRoutePrefix({ ...CONFIG, slugs })).toBe('/:niche(gays|shemale)');
32
+
33
+ const pages = [{ path: '/videos/:slug' }];
34
+ addNicheAliases(pages, { ...CONFIG, slugs });
35
+ expect(pages[0]?.alias).toEqual(['/gays/videos/:slug', '/shemale/videos/:slug']);
36
+ });
37
+
38
+ it('префикс пути ниши: слаг, у дефолтной и одной ниши пусто', () => {
39
+ const slugs = { gay: 'gays' };
40
+ expect(nichePath('gay', { ...CONFIG, slugs })).toBe('/gays');
41
+ expect(nichePath('shemale', { ...CONFIG, slugs })).toBe('/shemale');
42
+ expect(nichePath('straight', CONFIG)).toBe('');
43
+ expect(nichePath(undefined, CONFIG)).toBe('');
44
+ expect(nichePath('gay', { niches: ['gay'], defaultNiche: 'straight' })).toBe('');
45
+ });
46
+
47
+ it('префикс ниши в локализованном пути — после языка', () => {
48
+ expect(withNichePrefix('/models', '/gays')).toBe('/gays/models');
49
+ expect(withNichePrefix('/', '/gays')).toBe('/gays');
50
+ expect(withNichePrefix('/?page=2', '/gays')).toBe('/gays?page=2');
51
+ expect(withNichePrefix('/ru/models', '/gays', '/ru')).toBe('/ru/gays/models');
52
+ expect(withNichePrefix('/ru', '/gays', '/ru')).toBe('/ru/gays');
53
+ expect(withNichePrefix('/ru?page=2', '/gays', '/ru')).toBe('/ru/gays?page=2');
54
+ expect(withNichePrefix('/rubber', '/gays', '/ru')).toBe('/gays/rubber');
55
+ expect(withNichePrefix('/models', '')).toBe('/models');
56
+ expect(withNichePrefix('https://ru.example.com/models', '/gays')).toBe('https://ru.example.com/gays/models');
57
+ expect(withNichePrefix('https://ru.example.com', '/gays')).toBe('https://ru.example.com/gays');
58
+ });
59
+
60
+ it('слаг ↔ ниша', () => {
61
+ const slugs = { gay: 'gays', shemale: 'shemales' };
62
+ expect(nicheSlug('gay', slugs)).toBe('gays');
63
+ expect(nicheSlug('straight', slugs)).toBe('straight');
64
+ expect(nicheSlug('gay')).toBe('gay');
65
+ expect(nicheFromSlug('gays', CONFIG.niches, slugs)).toBe('gay');
66
+ expect(nicheFromSlug('gay', CONFIG.niches, slugs)).toBeUndefined();
67
+ expect(nicheFromSlug('gay', CONFIG.niches)).toBe('gay');
68
+ expect(nicheFromSlug('videos', CONFIG.niches, slugs)).toBeUndefined();
69
+ });
70
+
71
+ it('ниша по пути: с языковым префиксом и без, со слагами', () => {
72
+ const locales = ['en', 'ru', 'ja'];
73
+ const slugs = { gay: 'gays', shemale: 'shemales' };
74
+
75
+ expect(nicheFromPath('/gay/videos', CONFIG, locales)).toBe('gay');
76
+ expect(nicheFromPath('/ru/gay/videos', CONFIG, locales)).toBe('gay');
77
+ expect(nicheFromPath('/ru/gays/videos', { ...CONFIG, slugs }, locales)).toBe('gay');
78
+ expect(nicheFromPath('/shemales', { ...CONFIG, slugs }, locales)).toBe('shemale');
79
+ expect(nicheFromPath('/ru', CONFIG, locales)).toBe('straight');
80
+ expect(nicheFromPath('/ru/videos', CONFIG, locales)).toBe('straight');
81
+ expect(nicheFromPath('/', CONFIG, locales)).toBe('straight');
82
+ expect(nicheFromPath('/straight/videos', CONFIG, locales)).toBe('straight');
83
+ expect(nicheFromPath('/ru/gay', CONFIG)).toBe('straight');
84
+ });
85
+
29
86
  it('сайт с одной нишей: роуты без изменений', () => {
30
87
  const pages = [{ path: '/videos' }];
31
88
  addNicheAliases(pages, { niches: ['gay'], defaultNiche: 'gay', contentRoots: ['/videos'] });
package/config/niches.ts CHANGED
@@ -17,6 +17,21 @@ export interface INicheRouting {
17
17
  * они одни на весь сайт.
18
18
  */
19
19
  contentRoots: readonly string[]
20
+ /** Слаг ниши в URL, если он не совпадает с её кодом (`{ gay: 'gays' }`). */
21
+ slugs?: NicheSlugs
22
+ }
23
+
24
+ /** Карта «код ниши → слаг в URL». Ниши без записи живут в URL под своим кодом. */
25
+ export type NicheSlugs = Readonly<Record<string, string>>;
26
+
27
+ /** Слаг ниши в URL: из карты сайта, без неё — код ниши. */
28
+ export function nicheSlug(niche: string, slugs?: NicheSlugs | null): string {
29
+ return slugs?.[niche] || niche;
30
+ }
31
+
32
+ /** Ниша по сегменту URL — обратное к `nicheSlug`. Не ниша — `undefined`. */
33
+ export function nicheFromSlug(slug: string, niches: readonly string[], slugs?: NicheSlugs | null): string | undefined {
34
+ return niches.find(niche => nicheSlug(niche, slugs) === slug);
20
35
  }
21
36
 
22
37
  /** То, что нужно от записи роута: путь и его алиасы (`NuxtPage` подходит как есть). */
@@ -25,17 +40,34 @@ export interface INicheAliasablePage {
25
40
  alias?: string | string[]
26
41
  }
27
42
 
43
+ /**
44
+ * Ниша страницы по пути: первый сегмент после языкового префикса (`/ru/gays/videos` → `gay`), не ниша или
45
+ * дефолтная — дефолтная. Языковой сегмент пропускается по кодам локалей сайта (`prefix_except_default`);
46
+ * на локалях-поддоменах префикса нет, и пропускать нечего.
47
+ */
48
+ export function nicheFromPath(
49
+ path: string,
50
+ { niches, defaultNiche, slugs }: Pick<INicheRouting, 'niches' | 'defaultNiche' | 'slugs'>,
51
+ localeCodes: readonly string[] = [],
52
+ ): string {
53
+ const segments = path.split('/').filter(Boolean);
54
+ const segment = (segments[0] && localeCodes.includes(segments[0]) ? segments[1] : segments[0]) || '';
55
+ const niche = nicheFromSlug(segment, niches, slugs);
56
+
57
+ return niche && niche !== defaultNiche ? niche : defaultNiche;
58
+ }
59
+
28
60
  /** Ниши, которые живут под префиксом (все, кроме дефолтной). */
29
61
  export function prefixedNiches({ niches, defaultNiche }: Pick<INicheRouting, 'niches' | 'defaultNiche'>): string[] {
30
62
  return niches.filter(niche => niche !== defaultNiche);
31
63
  }
32
64
 
33
65
  /**
34
- * Префикс пути для нишевых алиасов: `/:niche(gay|shemale)`. У сайта с одной нишей — пусто.
35
- * Сайт берёт его, если строит собственные нишевые роуты рядом с алиасами.
66
+ * Префикс пути для нишевых алиасов: `/:niche(gay|shemale)`, со слагами — `/:niche(gays|shemales)`.
67
+ * У сайта с одной нишей — пусто. Сайт берёт его, если строит собственные нишевые роуты рядом с алиасами.
36
68
  */
37
- export function nicheRoutePrefix(config: Pick<INicheRouting, 'niches' | 'defaultNiche'>): string {
38
- const prefixed = prefixedNiches(config);
69
+ export function nicheRoutePrefix(config: Pick<INicheRouting, 'niches' | 'defaultNiche' | 'slugs'>): string {
70
+ const prefixed = prefixedNiches(config).map(niche => nicheSlug(niche, config.slugs));
39
71
 
40
72
  return prefixed.length ? `/:niche(${prefixed.join('|')})` : '';
41
73
  }
@@ -47,19 +79,50 @@ function isUnderRoot(path: string, roots: readonly string[]): boolean {
47
79
 
48
80
  /**
49
81
  * Клонирует контентные разделы под нишевые префиксы — алиасом той же записи роута:
50
- * `/videos/:slug` начинает отвечать и на `/gay/videos/:slug`. Сайту с одной нишей ничего не
51
- * добавляет. Мутирует переданный список — так устроен хук `pages:extend`.
82
+ * `/videos/:slug` начинает отвечать и на `/gay/videos/:slug`. Алиасы статичные, по одному на нишу:
83
+ * у алиаса vue-router требует те же параметры, что у оригинала, поэтому параметр `:niche` сюда нельзя.
84
+ * Сайту с одной нишей ничего не добавляет. Мутирует переданный список — так устроен хук `pages:extend`.
52
85
  */
53
86
  export function addNicheAliases(pages: INicheAliasablePage[], config: INicheRouting): void {
54
- const prefix = nicheRoutePrefix(config);
55
- if (!prefix) return;
87
+ const prefixes = prefixedNiches(config).map(niche => `/${nicheSlug(niche, config.slugs)}`);
88
+ if (!prefixes.length) return;
56
89
 
57
90
  for (const page of pages) {
58
91
  if (!page.path || !isUnderRoot(page.path, config.contentRoots)) continue;
59
92
 
60
- const alias = page.path === '/' ? prefix : `${prefix}${page.path}`;
93
+ const aliases = prefixes.map(prefix => (page.path === '/' ? prefix : `${prefix}${page.path}`));
61
94
  const current = Array.isArray(page.alias) ? page.alias : page.alias ? [page.alias] : [];
62
95
 
63
- page.alias = [...current, alias];
96
+ page.alias = [...current, ...aliases];
64
97
  }
65
98
  }
99
+
100
+ /** Префикс пути ниши (`/gays`). У дефолтной ниши и у сайта с одной нишей — пусто. */
101
+ export function nichePath(niche: string | undefined, { niches, defaultNiche, slugs }: Pick<INicheRouting, 'niches' | 'defaultNiche' | 'slugs'>): string {
102
+ if (!niche || niches.length <= 1 || niche === defaultNiche) return '';
103
+
104
+ return `/${nicheSlug(niche, slugs)}`;
105
+ }
106
+
107
+ const ORIGIN_RE = /^https?:\/\/[^/?#]+/;
108
+
109
+ /**
110
+ * Вставляет префикс ниши в уже локализованный путь — после языкового префикса: `/ru/models` → `/ru/gays/models`.
111
+ * Префикс ниши добавляется после `localePath`, а не до: на нишевом пути i18n находит роут по алиасу и
112
+ * пересобирает путь по имени роута — то есть без ниши (так же `switchLocalePath`). `localeRoot` —
113
+ * `localePath('/')` той же локали. Абсолютный URL (локали на поддоменах) — префикс в его путь.
114
+ */
115
+ export function withNichePrefix(localizedPath: string, nichePath: string, localeRoot = '/'): string {
116
+ if (!nichePath) return localizedPath;
117
+
118
+ const origin = localizedPath.match(ORIGIN_RE)?.[0] || '';
119
+ if (origin) return `${origin}${withNichePrefix(localizedPath.slice(origin.length) || '/', nichePath, localeRoot)}`;
120
+
121
+ const localeRootPath = localeRoot.replace(ORIGIN_RE, '').replace(/\/$/, '');
122
+ const afterRoot = localizedPath.slice(localeRootPath.length);
123
+ const root = localeRootPath && localizedPath.startsWith(localeRootPath) && (!afterRoot || /^[/?#]/.test(afterRoot)) ? localeRootPath : '';
124
+ const rest = localizedPath.slice(root.length);
125
+ const tail = rest === '/' ? '' : rest.startsWith('/?') || rest.startsWith('/#') ? rest.slice(1) : rest;
126
+
127
+ return `${root}${nichePath}${tail}`;
128
+ }
@@ -1,17 +1,23 @@
1
1
  import type { Niche } from '../runtime';
2
+ import { nicheFromPath, type NicheSlugs } from '../config/niches';
2
3
  import { NicheHelper } from '../utils/niche-helper';
3
4
 
4
5
  /**
5
- * Ниша страницы из префикса пути (`/gay/...` → `gay`, без префикса — дефолтная). Работает только
6
- * у сайтов с несколькими нишами (`appConfig.niches`); у сайта с одной нишей — no-op.
6
+ * Ниша страницы из префикса пути (`/gay/...` → `gay`, со слагами `/gays/...` → `gay`, с языком
7
+ * `/ru/gays/...` → `gay`, без префикса — дефолтная). Работает только у сайтов с несколькими нишами
8
+ * (`appConfig.niches`); у сайта с одной нишей — no-op.
7
9
  */
8
10
  export default defineNuxtRouteMiddleware((to) => {
9
- const niches = (useAppConfig().niches as Niche[] | undefined) || [];
11
+ const appConfig = useAppConfig();
12
+ const niches = (appConfig.niches as Niche[] | undefined) || [];
10
13
  if (niches.length <= 1) return;
11
14
 
12
- const defaultNiche = NicheHelper.getSiteDefaultNiche();
13
- const firstSegment = to.path.split('/')[1] as Niche;
14
- const niche = firstSegment !== defaultNiche && niches.includes(firstSegment) ? firstSegment : defaultNiche;
15
+ const localeCodes = (useRuntimeConfig().public.locales?.codes as string[] | undefined) || [];
16
+ const niche = nicheFromPath(to.path, {
17
+ niches,
18
+ defaultNiche: NicheHelper.getSiteDefaultNiche(),
19
+ slugs: appConfig.nicheSlugs as NicheSlugs | undefined,
20
+ }, localeCodes) as Niche;
15
21
 
16
22
  useState<Niche>('niche', () => niche).value = niche;
17
23
  });
package/package.json CHANGED
@@ -1,7 +1,7 @@
1
1
  {
2
2
  "name": "itube-specs",
3
3
  "type": "module",
4
- "version": "0.0.999",
4
+ "version": "0.0.1003",
5
5
  "main": "./nuxt.config.ts",
6
6
  "types": "./types/index.d.ts",
7
7
  "scripts": {
@@ -55,8 +55,6 @@
55
55
  :items="modelsRoundCards"
56
56
  :title="$t('category_top_models_title', {name: title})"
57
57
  class="category__models"
58
- placeholder-avatar="/img/model-placeholder-square.webp"
59
- placeholder-avatar-avif="/img/model-placeholder-square.avif"
60
58
  />
61
59
  </template>
62
60
  </GridVideos>
@@ -52,8 +52,6 @@
52
52
  :items="modelsRoundCards"
53
53
  :title="t('channels_popular_models')"
54
54
  class="channel__videos-models"
55
- placeholder-avatar="/img/model-placeholder-square.webp"
56
- placeholder-avatar-avif="/img/model-placeholder-square.avif"
57
55
  hydrate-on-visible
58
56
  />
59
57
  </template>
@@ -18,8 +18,6 @@
18
18
  all-link="/library/models"
19
19
  icon="user"
20
20
  variant="section"
21
- placeholder-avatar="/img/model-placeholder-square.webp"
22
- placeholder-avatar-avif="/img/model-placeholder-square.avif"
23
21
  class="following__section"
24
22
  without-count
25
23
  />
@@ -136,6 +134,7 @@ const playlists = computed(() => byNewCount(getLibraryPlaylistsStub()));
136
134
  const models = computed<IRoundCard[]>(() => modelsStub.value.map(model => ({
137
135
  title: model.title,
138
136
  primaryImageUrl: model.avatarUrl,
137
+ gender: '',
139
138
  videosCount: model.videosCount,
140
139
  link: `/models/${convertString().toSlug(model.title)}`,
141
140
  newCount: model.newCount,
@@ -47,11 +47,7 @@
47
47
  :slide-width="96"
48
48
  >
49
49
  <template #default="item">
50
- <CardRound
51
- :card="item"
52
- placeholder-avatar="/img/model-placeholder-square.webp"
53
- placeholder-avatar-avif="/img/model-placeholder-square.avif"
54
- />
50
+ <CardRound :card="item"/>
55
51
  </template>
56
52
  <template
57
53
  v-if="modelsData.total > MAXIMUM_MODELS"
@@ -2,16 +2,23 @@ import { describe, it, expect } from 'vitest';
2
2
  import { modelGender, modelPlaceholder } from '../model-placeholder';
3
3
 
4
4
  describe('modelPlaceholder', () => {
5
- it('по полю пола: мужчине мужская, девушке и трансгендер — женская', () => {
6
- expect(modelPlaceholder('male', 'straight', 'portrait').webp).toBe('/img/model-placeholder-portrait.webp');
7
- expect(modelPlaceholder('female', 'gay', 'square').avif).toBe('/img/model-placeholder-female-square.avif');
8
- expect(modelPlaceholder('transgender', 'shemale', 'square').webp).toBe('/img/model-placeholder-female-square.webp');
5
+ it('набор по полу: мужчине гей, транс — шимейл, девушке стрейт', () => {
6
+ expect(modelPlaceholder('male', 'straight', 'dark', 'portrait').webp).toBe('/img/model-placeholder/gay-dark-portrait.webp');
7
+ expect(modelPlaceholder('female', 'gay', 'light', 'square').avif).toBe('/img/model-placeholder/straight-light-square.avif');
8
+ expect(modelPlaceholder('transgender', 'straight', 'dark', 'square').webp).toBe('/img/model-placeholder/shemale-dark-square.webp');
9
+ expect(modelPlaceholder('trans woman', 'gay', 'dark', 'square').webp).toBe('/img/model-placeholder/shemale-dark-square.webp');
9
10
  });
10
11
 
11
- it('пол неизвестен — по нише: гей мужская, стрейт и шимейл женская', () => {
12
- expect(modelPlaceholder('', 'gay', 'square').webp).toBe('/img/model-placeholder-square.webp');
13
- expect(modelPlaceholder(undefined, 'straight', 'portrait').webp).toBe('/img/model-placeholder-female-portrait.webp');
14
- expect(modelPlaceholder('', 'shemale', 'portrait').webp).toBe('/img/model-placeholder-female-portrait.webp');
12
+ it('пол неизвестен — по нише, неизвестная ниша — стрейт', () => {
13
+ expect(modelPlaceholder('', 'gay', 'dark', 'square').webp).toBe('/img/model-placeholder/gay-dark-square.webp');
14
+ expect(modelPlaceholder(undefined, 'shemale', 'light', 'portrait').webp).toBe('/img/model-placeholder/shemale-light-portrait.webp');
15
+ expect(modelPlaceholder('', undefined, 'dark', 'portrait').webp).toBe('/img/model-placeholder/straight-dark-portrait.webp');
16
+ });
17
+
18
+ it('тема: dark/light, неизвестная — dark', () => {
19
+ expect(modelPlaceholder('', 'gay', 'light', 'square').webp).toBe('/img/model-placeholder/gay-light-square.webp');
20
+ expect(modelPlaceholder('', 'gay', 'black', 'square').webp).toBe('/img/model-placeholder/gay-dark-square.webp');
21
+ expect(modelPlaceholder('', 'gay', undefined, 'square').webp).toBe('/img/model-placeholder/gay-dark-square.webp');
15
22
  });
16
23
  });
17
24
 
@@ -7,6 +7,9 @@ type GenderSource = {
7
7
  parameters?: Array<{ name: string, values?: Array<{ name: string }> }> | null
8
8
  };
9
9
 
10
+ const PLACEHOLDER_NICHES = ['straight', 'gay', 'shemale'];
11
+ const PLACEHOLDER_THEMES = ['dark', 'light'];
12
+
10
13
  /**
11
14
  * Пол сырой модели (`female`, `male`, `transgender`, `trans woman`…): поле `gender` (v1-ручки) или
12
15
  * параметр `gender` (v2). Нет ни того, ни другого — пусто.
@@ -15,16 +18,27 @@ export const modelGender = (model?: GenderSource | null): string => model?.gende
15
18
  || (Array.isArray(model?.parameters) && model.parameters.find(item => item.name === 'gender')?.values?.[0]?.name)
16
19
  || '';
17
20
 
21
+ /** Набор заглушки по полу: мужчинам — гей, транс — шимейл, девушкам — стрейт; пол неизвестен — ниша страницы. */
22
+ const placeholderNiche = (gender: string | undefined, niche: string | undefined): string => {
23
+ const value = (gender || '').toLowerCase();
24
+
25
+ if (value === 'male') return 'gay';
26
+ if (/trans|shemale/.test(value)) return 'shemale';
27
+ if (value === 'female') return 'straight';
28
+
29
+ return niche && PLACEHOLDER_NICHES.includes(niche) ? niche : 'straight';
30
+ };
31
+
18
32
  /**
19
- * Заглушка вместо портрета модели. По полю пола: мужчинам — мужская, остальным — женская (у трансгендер
20
- * своей пока нет). Пол неизвестен — по нише: гей-нише мужская, остальным женская.
33
+ * Заглушка вместо портрета модели: набор (стрейт/гей/шимейл) × тема (dark/light) × кроп. Набор — по полу,
34
+ * пол неизвестен — по нише; неизвестная тема — dark.
21
35
  *
22
- * Файлы лежат в `public/img` сайта: `model-placeholder-{portrait,square}.{webp,avif}` (мужская) и
23
- * `model-placeholder-female-{portrait,square}.{webp,avif}`.
36
+ * Файлы лежат в слое: `public/img/model-placeholder/<niche>-<theme>-<shape>.{webp,avif}`. Сайт перекрывает
37
+ * любой своим файлом с тем же путём.
24
38
  */
25
- export const modelPlaceholder = (gender: string | undefined, niche: string | undefined, shape: ModelPlaceholderShape) => {
26
- const isMale = gender ? gender === 'male' : niche === 'gay';
27
- const base = `/img/model-placeholder-${isMale ? '' : 'female-'}${shape}`;
39
+ export const modelPlaceholder = (gender: string | undefined, niche: string | undefined, theme: string | undefined, shape: ModelPlaceholderShape) => {
40
+ const tone = theme && PLACEHOLDER_THEMES.includes(theme) ? theme : 'dark';
41
+ const base = `/img/model-placeholder/${placeholderNiche(gender, niche)}-${tone}-${shape}`;
28
42
 
29
43
  return { webp: `${base}.webp`, avif: `${base}.avif` };
30
44
  };
@@ -1,4 +1,5 @@
1
1
  import type { Niche } from '../runtime';
2
+ import { nichePath, type NicheSlugs } from '../config/niches';
2
3
 
3
4
  /** Хелпер для работы с нишами */
4
5
  export class NicheHelper {
@@ -14,4 +15,23 @@ export class NicheHelper {
14
15
  public static getCurrentNiche(): Niche {
15
16
  return (useState<Niche>('niche').value || NicheHelper.getSiteDefaultNiche()) as Niche;
16
17
  }
18
+
19
+ /**
20
+ * Префикс пути ниши (`/gay`, со слагами из `appConfig.nicheSlugs` — `/gays`). У дефолтной ниши и у
21
+ * сайта с одной нишей — пусто.
22
+ */
23
+ public static getNichePath(niche: Niche = NicheHelper.getCurrentNiche()): string {
24
+ return nichePath(niche, NicheHelper.getNicheRouting());
25
+ }
26
+
27
+ /** Ниши сайта, дефолтная и слаги — то, что нужно `nichePath` и прочим хелперам `itube-specs/config`. */
28
+ public static getNicheRouting(): { niches: Niche[], defaultNiche: Niche, slugs?: NicheSlugs } {
29
+ const appConfig = useAppConfig();
30
+
31
+ return {
32
+ niches: (appConfig.niches as Niche[] | undefined) || [],
33
+ defaultNiche: NicheHelper.getSiteDefaultNiche(),
34
+ slugs: appConfig.nicheSlugs as NicheSlugs | undefined,
35
+ };
36
+ }
17
37
  }