itube-specs 0.0.919 → 0.0.920

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 (34) hide show
  1. package/assets/scss/themes/dark/_theme.scss +30 -5
  2. package/assets/scss/themes/light/_theme.scss +30 -5
  3. package/assets/scss/vars/_palette.scss +3 -0
  4. package/components/card/webcam/index.vue +141 -15
  5. package/components/card/webcam/languages.vue +7 -3
  6. package/components/filter/model.vue +56 -1
  7. package/components/grid/webcams.vue +78 -0
  8. package/components/ui/switcher.vue +114 -0
  9. package/components/view/webcams.vue +370 -0
  10. package/components/webcam/header.vue +1 -43
  11. package/components/webcam/icon.vue +49 -0
  12. package/components/webcam/main.vue +8 -62
  13. package/components/webcam/stripcash-card.vue +2 -2
  14. package/composables/fetch/use-fetch-models-count.ts +20 -0
  15. package/composables/fetch/use-fetch-webcam-models.ts +45 -0
  16. package/lib/index.ts +1 -0
  17. package/lib/scheme/sort-webcams.ts +22 -0
  18. package/package.json +1 -1
  19. package/pages/webcams/[[tag]].vue +75 -0
  20. package/server/api/models/count.get.ts +18 -0
  21. package/server/api/webcams/list.post.ts +41 -0
  22. package/services/api/models.service.ts +20 -0
  23. package/services/api/webcams.service.ts +34 -0
  24. package/types/index.d.ts +3 -0
  25. package/types/switcher-item.d.ts +13 -0
  26. package/types/webcam-facet.d.ts +7 -0
  27. package/types/webcam-models-response.d.ts +12 -0
  28. package/utils/webcams/__tests__/count-filters.test.ts +12 -0
  29. package/utils/webcams/__tests__/normalize-facets.test.ts +52 -0
  30. package/utils/webcams/__tests__/to-filter-payload.test.ts +17 -0
  31. package/utils/webcams/count-filters.ts +14 -0
  32. package/utils/webcams/facets.ts +31 -0
  33. package/utils/webcams/normalize-facets.ts +57 -0
  34. package/utils/webcams/to-filter-payload.ts +16 -0
@@ -1059,8 +1059,8 @@ $document-link: $color-indigo-400;
1059
1059
  $document-link--hover: $color-indigo-300;
1060
1060
 
1061
1061
  // ─── Вебкам-блок ───
1062
- $webcam-header-icon: $color-white;
1063
- $webcam-header-icon-bg: $color-white-10;
1062
+ $webcam-icon-text: $color-white;
1063
+ $webcam-icon-bg: $color-white-10;
1064
1064
  $webcam-header-promo-text: $color-white;
1065
1065
  $webcam-header-promo-bg: $color-indigo-600;
1066
1066
  $webcam-header-promo-bg--hover: $color-indigo-500;
@@ -1072,12 +1072,15 @@ $webcam-counters-language-bg: $color-zinc-800-70;
1072
1072
  $card-webcam-poster-bg: $color-zinc-800;
1073
1073
  $card-webcam-online-dot: $color-emerald-400;
1074
1074
  $card-webcam-online-ring: $color-black-42;
1075
- $card-webcam-new-text: $color-white-90;
1076
- $card-webcam-new-bg: $color-black-42;
1077
- $card-webcam-new-border: $color-white-25;
1075
+ $card-webcam-badge-text: $color-white-90;
1076
+ $card-webcam-badge-bg: $color-black-45;
1077
+ $card-webcam-badge-border: $color-white-25;
1078
+ $card-webcam-badge-shadow: $color-black-90;
1078
1079
  $card-webcam-goal-text: $color-white;
1079
1080
  $card-webcam-goal-label: $color-zinc-400;
1080
1081
  $card-webcam-goal-percent: $color-white-70;
1082
+ $card-webcam-goal-plain-text: $color-zinc-300;
1083
+ $card-webcam-goal-plain-percent: $color-zinc-400;
1081
1084
  $card-webcam-goal-track-bg: $color-white-15;
1082
1085
  $card-webcam-goal-fill-bg: $color-emerald-400;
1083
1086
  $card-webcam-goal-shade-from: $color-black-95;
@@ -1090,6 +1093,28 @@ $card-webcam-tag-text: $color-zinc-300;
1090
1093
  $card-webcam-tag-bg: $color-zinc-800;
1091
1094
  $card-webcam-tag-text--hover: $color-white;
1092
1095
  $card-webcam-tag-bg--hover: $color-zinc-700;
1096
+ $page-webcams-divider: $color-zinc-700;
1097
+ $page-webcams-control-bg: $color-zinc-800-30;
1098
+ $page-webcams-control-border: $color-zinc-700-30;
1099
+ $page-webcams-control-text: $color-white;
1100
+
1101
+ // ─── Switcher ───
1102
+ $switcher-bg: $color-zinc-900-80;
1103
+ $switcher-border: $color-zinc-800;
1104
+ $switcher-text: $color-zinc-400;
1105
+ $switcher-text--hover: $color-white;
1106
+ $switcher-text--active: $color-white;
1107
+ $switcher-bg--active: $color-indigo-600;
1108
+ $switcher-icon: $color-zinc-500;
1109
+ $switcher-icon--active: $color-emerald-400;
1110
+ $switcher-subtitle: $color-zinc-600;
1111
+ $switcher-subtitle--active: $color-white-60;
1112
+
1113
+ // ─── Filter: сброс всех фильтров в облаке чипсов ───
1114
+ $filter-model-clear-text: $color-zinc-500;
1115
+ $filter-model-clear-text--hover: $color-white;
1116
+
1117
+ // ─── Вебкам-карточка (продолжение) ───
1093
1118
  $card-webcam-languages-border: $color-white-20;
1094
1119
  $card-webcam-languages-fallback-bg: $color-zinc-800;
1095
1120
  $card-webcam-languages-fallback-icon: $color-zinc-300;
@@ -1087,8 +1087,8 @@ $document-link: $color-indigo-400;
1087
1087
  $document-link--hover: $color-indigo-300;
1088
1088
 
1089
1089
  // ─── Вебкам-блок ───
1090
- $webcam-header-icon: $color-gray-700;
1091
- $webcam-header-icon-bg: $color-gray-200;
1090
+ $webcam-icon-text: $color-gray-700;
1091
+ $webcam-icon-bg: $color-gray-200;
1092
1092
  $webcam-header-promo-text: $color-white;
1093
1093
  $webcam-header-promo-bg: $color-indigo-600;
1094
1094
  $webcam-header-promo-bg--hover: $color-indigo-500;
@@ -1100,12 +1100,15 @@ $webcam-counters-language-bg: $color-gray-200;
1100
1100
  $card-webcam-poster-bg: $color-gray-200;
1101
1101
  $card-webcam-online-dot: $color-emerald-500;
1102
1102
  $card-webcam-online-ring: $color-black-25;
1103
- $card-webcam-new-text: $color-white;
1104
- $card-webcam-new-bg: $color-black-42;
1105
- $card-webcam-new-border: $color-white-25;
1103
+ $card-webcam-badge-text: $color-white;
1104
+ $card-webcam-badge-bg: $color-black-45;
1105
+ $card-webcam-badge-border: $color-white-25;
1106
+ $card-webcam-badge-shadow: $color-black-90;
1106
1107
  $card-webcam-goal-text: $color-white;
1107
1108
  $card-webcam-goal-label: $color-gray-300;
1108
1109
  $card-webcam-goal-percent: $color-white-70;
1110
+ $card-webcam-goal-plain-text: $color-gray-700;
1111
+ $card-webcam-goal-plain-percent: $color-gray-500;
1109
1112
  $card-webcam-goal-track-bg: $color-white-15;
1110
1113
  $card-webcam-goal-fill-bg: $color-emerald-400;
1111
1114
  $card-webcam-goal-shade-from: $color-black-95;
@@ -1118,6 +1121,28 @@ $card-webcam-tag-text: $color-gray-700;
1118
1121
  $card-webcam-tag-bg: $color-gray-200;
1119
1122
  $card-webcam-tag-text--hover: $color-gray-950;
1120
1123
  $card-webcam-tag-bg--hover: $color-gray-300;
1124
+ $page-webcams-divider: $color-gray-300;
1125
+ $page-webcams-control-bg: $color-gray-100;
1126
+ $page-webcams-control-border: $color-gray-200;
1127
+ $page-webcams-control-text: $color-gray-950;
1128
+
1129
+ // ─── Switcher ───
1130
+ $switcher-bg: $color-gray-100;
1131
+ $switcher-border: $color-gray-200;
1132
+ $switcher-text: $color-gray-500;
1133
+ $switcher-text--hover: $color-gray-950;
1134
+ $switcher-text--active: $color-white;
1135
+ $switcher-bg--active: $color-indigo-600;
1136
+ $switcher-icon: $color-gray-500;
1137
+ $switcher-icon--active: $color-emerald-400;
1138
+ $switcher-subtitle: $color-gray-500;
1139
+ $switcher-subtitle--active: $color-white-60;
1140
+
1141
+ // ─── Filter: сброс всех фильтров в облаке чипсов ───
1142
+ $filter-model-clear-text: $color-gray-500;
1143
+ $filter-model-clear-text--hover: $color-gray-950;
1144
+
1145
+ // ─── Вебкам-карточка (продолжение) ───
1121
1146
  $card-webcam-languages-border: $color-gray-300;
1122
1147
  $card-webcam-languages-fallback-bg: $color-gray-200;
1123
1148
  $card-webcam-languages-fallback-icon: $color-gray-600;
@@ -5,6 +5,7 @@ $color-zinc-950-0: lab(2.51107% .242703 -.886115 / 0%); // zinc-950 / 0%
5
5
  $color-zinc-950-75: lab(2.51107% .242703 -.886115 / 75%); // zinc-950 / 75%
6
6
  $color-zinc-950-90: lab(2.51107% .242703 -.886115 / 90%); // zinc-950 / 90%
7
7
  $color-zinc-900: lab(8.30603% .618205 -2.16572); // #18181b
8
+ $color-zinc-900-80: lab(8.30603% .618205 -2.16572 / 80%);
8
9
  $color-zinc-900-70: lab(8.30603% .618205 -2.16572 / 70%); // zinc-900 / 70%
9
10
  $color-zinc-900-60: lab(8.30603% .618205 -2.16572 / 60%); // zinc-900 / 60%
10
11
  $color-zinc-900-50: lab(8.30603% .618205 -2.16572 / 50%); // #18181b
@@ -59,6 +60,7 @@ $color-indigo-500-20: lab(48.295% 38.3129 -81.9673 / 20%);
59
60
  $color-indigo-500-40: lab(48.295% 38.3129 -81.9673 / 40%);
60
61
  $color-indigo-500-50: lab(48.295% 38.3129 -81.9673 / 50%);
61
62
  $color-indigo-600: lab(38.4009% 52.6132 -92.3857); // #4f39f6
63
+ $color-indigo-600-10: lab(38.4009% 52.6132 -92.3857 / 10%);
62
64
  $color-indigo-600-20: lab(38.4009% 52.6132 -92.3857 / 20%);
63
65
  $color-indigo-600-90: lab(38.4009% 52.6132 -92.3857 / 90%);
64
66
  $color-indigo-700: lab(32.4486% 49.2217 -84.6695); // #432dd7
@@ -99,6 +101,7 @@ $mono-white-5: lab(100% 0 0 / 5%);
99
101
 
100
102
  // $color- duplicates of the mono-black/mono-white opacity scale
101
103
  $color-black-100: lab(0% 0 0);
104
+ $color-black-90: lab(0% 0 0 / 90%);
102
105
  $color-black-95: lab(0% 0 0 / 95%);
103
106
  $color-black-75: lab(0% 0 0 / 75%);
104
107
  $color-black-70: lab(0% 0 0 / 70%);
@@ -51,9 +51,24 @@
51
51
  />
52
52
 
53
53
  <span
54
- v-if="model.isNew"
55
- class="card-webcam__new"
56
- >{{ t('webcams.new_model') }}</span>
54
+ v-if="model.isNew || model.viewers"
55
+ class="card-webcam__badge"
56
+ >
57
+ <span
58
+ v-if="model.isNew"
59
+ class="card-webcam__badge-new"
60
+ >{{ t('webcams.new_model') }}</span>
61
+
62
+ <span
63
+ v-if="model.viewers"
64
+ class="card-webcam__badge-viewers"
65
+ >
66
+ <UiIcon
67
+ name="eye"
68
+ size="12"
69
+ />{{ $n(model.viewers) }}
70
+ </span>
71
+ </span>
57
72
 
58
73
  <span class="card-webcam__online"/>
59
74
 
@@ -99,7 +114,29 @@
99
114
  >{{ t('webcams.years_old', { age: model.age }) }}</span>
100
115
  </div>
101
116
 
102
- <div class="card-webcam__meta">
117
+ <!-- На узких экранах ховера нет, поэтому цель стримера с прогрессом живёт под
118
+ карточкой; на десктопе она остаётся оверлеем на постере. -->
119
+ <div
120
+ v-if="model.goal"
121
+ class="card-webcam__goal-plain"
122
+ >
123
+ <span class="card-webcam__goal-plain-text _truncate">{{ model.goal.message }}</span>
124
+
125
+ <span class="card-webcam__goal-progress">
126
+ <span class="card-webcam__goal-track">
127
+ <span
128
+ :style="{ width: `${model.goal.percent}%` }"
129
+ class="card-webcam__goal-fill"
130
+ />
131
+ </span>
132
+ <span class="card-webcam__goal-percent --plain">{{ model.goal.percent }}%</span>
133
+ </span>
134
+ </div>
135
+
136
+ <div
137
+ v-if="model.viewers || model.country"
138
+ class="card-webcam__meta"
139
+ >
103
140
  <span
104
141
  v-if="model.viewers"
105
142
  class="card-webcam__viewers"
@@ -112,7 +149,7 @@
112
149
 
113
150
  <a
114
151
  v-if="model.country"
115
- :href="`${nichePath}/webcams?mf_country=${model.country}`"
152
+ :href="`${nichePath}/webcams?filter_country=${model.country}`"
116
153
  class="card-webcam__country _truncate"
117
154
  >{{ countryName }}</a>
118
155
  </div>
@@ -367,6 +404,12 @@ onBeforeUnmount(() => {
367
404
  min-width: 0;
368
405
  }
369
406
 
407
+ // нормализация даёт каждому следующему ребёнку `<article>` отступ 1em — в карточке
408
+ // отступы задаются построчно, поэтому ресет тут гасим
409
+ .card-webcam > * + * {
410
+ margin-top: 0;
411
+ }
412
+
370
413
  .card-webcam__poster {
371
414
  position: relative;
372
415
  display: block;
@@ -399,26 +442,49 @@ onBeforeUnmount(() => {
399
442
  opacity: 1;
400
443
  }
401
444
 
402
- .card-webcam__new {
445
+ // одна плашка на постере: признак новой модели и зрители онлайн (на узких экранах
446
+ // меты под карточкой нет, поэтому зрители показываются здесь)
447
+ .card-webcam__badge {
403
448
  @include font-4xs;
404
449
 
405
450
  position: absolute;
406
451
  top: 8px;
407
452
  left: 8px;
408
453
  z-index: 2;
454
+ display: flex;
455
+ align-items: center;
456
+ gap: 6px;
409
457
  padding: 2px 6px;
410
458
  font-weight: 600;
411
459
  line-height: 1;
412
460
  text-transform: uppercase;
413
461
  letter-spacing: .025em;
414
- color: $card-webcam-new-text;
462
+ color: $card-webcam-badge-text;
463
+ text-shadow: 0 1px 2px $card-webcam-badge-shadow;
415
464
 
416
465
  // лёгкое затенение: на светлом постере плашка иначе сливается с кадром
417
- background-color: $card-webcam-new-bg;
418
- border: 1px solid $card-webcam-new-border;
466
+ background-color: $card-webcam-badge-bg;
467
+ border: 1px solid $card-webcam-badge-border;
419
468
  border-radius: $radius-small;
420
469
  }
421
470
 
471
+ .card-webcam__badge-viewers {
472
+ display: flex;
473
+ align-items: center;
474
+ gap: 4px;
475
+
476
+ @include from-br(xm) {
477
+ display: none;
478
+ }
479
+ }
480
+
481
+ // на десктопе плашка нужна только под признак новой модели
482
+ .card-webcam__badge:not(:has(.card-webcam__badge-new)) {
483
+ @include from-br(xm) {
484
+ display: none;
485
+ }
486
+ }
487
+
422
488
  .card-webcam__online {
423
489
  position: absolute;
424
490
  top: 8px;
@@ -492,10 +558,34 @@ onBeforeUnmount(() => {
492
558
 
493
559
  flex-shrink: 0;
494
560
  min-width: 4ch;
561
+ line-height: 16px;
495
562
  font-weight: 500;
496
563
  font-variant-numeric: tabular-nums;
497
564
  text-align: right;
498
565
  color: $card-webcam-goal-percent;
566
+
567
+ &.--plain {
568
+ color: $card-webcam-goal-plain-percent;
569
+ }
570
+ }
571
+
572
+ // цель под карточкой — только там, где нет ховера
573
+ .card-webcam__goal-plain {
574
+ display: grid;
575
+ row-gap: 6px;
576
+ margin-top: 8px;
577
+
578
+ @include from-br(xm) {
579
+ display: none;
580
+ }
581
+ }
582
+
583
+ .card-webcam__goal-plain-text {
584
+ @include font-xs;
585
+
586
+ --line-clamp: 1;
587
+
588
+ color: $card-webcam-goal-plain-text;
499
589
  }
500
590
 
501
591
  .card-webcam__head {
@@ -525,15 +615,21 @@ onBeforeUnmount(() => {
525
615
  color: $card-webcam-meta-text;
526
616
  }
527
617
 
618
+ // на узких экранах мету не показываем: зрители уехали в плашку на постере, а
619
+ // место под карточкой занимает цель стримера
528
620
  .card-webcam__meta {
529
621
  @include font-xs;
530
622
 
531
- display: flex;
623
+ display: none;
532
624
  align-items: center;
533
625
  justify-content: space-between;
534
626
  gap: 8px;
535
627
  margin-top: 8px;
536
628
  color: $card-webcam-meta-text;
629
+
630
+ @include from-br(xm) {
631
+ display: flex;
632
+ }
537
633
  }
538
634
 
539
635
  .card-webcam__viewers {
@@ -545,25 +641,48 @@ onBeforeUnmount(() => {
545
641
  .card-webcam__country {
546
642
  --line-clamp: 1;
547
643
 
644
+ margin-left: auto;
645
+
548
646
  &:is(:hover, :focus) {
549
647
  color: $card-webcam-meta-text--hover;
550
648
  }
551
649
  }
552
650
 
553
- .card-webcam__meta:has(+ .card-webcam__tags) {
554
- margin-bottom: 10px;
555
- }
556
-
557
- .card-webcam__tags {
651
+ // селектор с родителем не случайно: ресет обнуляет отступы у `ul[class]`, и
652
+ // одиночного класса ему не хватает
653
+ .card-webcam > .card-webcam__tags {
558
654
  display: flex;
559
655
  flex-wrap: wrap;
560
656
  gap: 6px;
657
+ margin-top: 10px;
658
+ }
659
+
660
+ // на узких экранах теги в один ряд с горизонтальным скроллом: в две колонки они
661
+ // не влезают, а перенос делал карточки в сетке разной высоты
662
+ .card-webcam > .card-webcam__tags {
663
+ @include to-br(xm) {
664
+ flex-wrap: nowrap;
665
+ overflow-x: auto;
666
+ padding-right: 24px;
667
+ scrollbar-width: none;
668
+
669
+ &::-webkit-scrollbar {
670
+ display: none;
671
+ }
672
+ }
673
+ }
674
+
675
+ // не сжимаем элементы ряда: иначе подпись внутри чипса переносится и ряд
676
+ // становится двухстрочным
677
+ .card-webcam__tags > li {
678
+ flex-shrink: 0;
561
679
  }
562
680
 
563
681
  .card-webcam__tag {
564
682
  @include font-xs;
565
683
 
566
684
  display: block;
685
+ white-space: nowrap;
567
686
  padding: 4px 8px;
568
687
  color: $card-webcam-tag-text;
569
688
  background-color: $card-webcam-tag-bg;
@@ -573,5 +692,12 @@ onBeforeUnmount(() => {
573
692
  color: $card-webcam-tag-text--hover;
574
693
  background-color: $card-webcam-tag-bg--hover;
575
694
  }
695
+
696
+ @include to-br(xm) {
697
+ @include font-3xs;
698
+
699
+ padding: 2px 8px;
700
+ line-height: 18px;
701
+ }
576
702
  }
577
703
  </style>
@@ -68,9 +68,13 @@ const broken = ref<Record<string, boolean>>({});
68
68
  const items = computed(() => {
69
69
  const first = (props.firstLang || locale.value).slice(0, 2).toLowerCase();
70
70
 
71
- const all = props.languages.map((lang, index) => ({
71
+ // провайдер вместо пустого массива присылает null — дефолт пропа в этом случае
72
+ // не срабатывает, поэтому подстраховываемся на самой границе компонента
73
+ const flags = props.flags || [];
74
+
75
+ const all = (props.languages || []).map((lang, index) => ({
72
76
  lang,
73
- country: (props.flags[index] || LANG_TO_FLAG[lang] || lang).toLowerCase(),
77
+ country: (flags[index] || LANG_TO_FLAG[lang] || lang).toLowerCase(),
74
78
  }));
75
79
 
76
80
  return [...all.filter(item => item.lang === first), ...all.filter(item => item.lang !== first)]
@@ -81,7 +85,7 @@ const items = computed(() => {
81
85
  const names = computed(() => {
82
86
  const display = new Intl.DisplayNames([locale.value], { type: 'language' });
83
87
 
84
- return props.languages.map(lang => display.of(lang) || lang.toUpperCase()).join(', ');
88
+ return (props.languages || []).map(lang => display.of(lang) || lang.toUpperCase()).join(', ');
85
89
  });
86
90
  </script>
87
91
 
@@ -4,7 +4,14 @@
4
4
  <FilterChips
5
5
  :items="chipsItems"
6
6
  @click="onChipsClick"
7
- />
7
+ >
8
+ <button
9
+ v-if="chipsItems.length > 1"
10
+ class="filter-model__clear"
11
+ type="button"
12
+ @click="onClearAllClick"
13
+ >{{ t('reset_all') }}</button>
14
+ </FilterChips>
8
15
  <transition appear>
9
16
  <FilterPopup
10
17
  v-model="model"
@@ -18,6 +25,19 @@
18
25
  @chip-click="onPendingChipsClick"
19
26
  @field-update="onFieldUpdate"
20
27
  >
28
+ <template #quick-field="{ item, index }">
29
+ <FilterModelField
30
+ v-if="quickFilter(item as { name: string })"
31
+ :field="quickFilter(item as { name: string }) as IModelFilter"
32
+ :index="index"
33
+ :pending-query="pendingQuery"
34
+ :units="units"
35
+ :unit-items="unitItems"
36
+ @update:pending-query="onQueryUpdate"
37
+ @update:units="units = $event"
38
+ />
39
+ </template>
40
+
21
41
  <template #field="{ field, index }">
22
42
  <FilterModelField
23
43
  :field="field as IModelFilter"
@@ -147,6 +167,11 @@ watch(units, () => {
147
167
  pendingQuery.value = next;
148
168
  });
149
169
 
170
+ /** Быстрые поля в модалку приходят уже маппингом — исходный фильтр ищем по имени. */
171
+ function quickFilter(item: { name: string }) {
172
+ return quickFilters.value.find(filter => filter.name === item.name);
173
+ }
174
+
150
175
  function selectItems(item: IModelFilter) {
151
176
  return item.options.map(option => ({
152
177
  value: option.name,
@@ -218,6 +243,17 @@ function onResetClick() {
218
243
  pendingQuery.value = {};
219
244
  }
220
245
 
246
+ /** «Сбросить всё» в облаке чипсов: снимает фильтры, сортировку не трогает. */
247
+ function onClearAllClick() {
248
+ const query = { ...route.query };
249
+ Object.keys(query)
250
+ .filter(key => key.startsWith('filter_'))
251
+ .forEach(key => delete query[key]);
252
+ delete query['page'];
253
+
254
+ router.replace({ query });
255
+ }
256
+
221
257
  function onApplyClick() {
222
258
  model.value = false;
223
259
  router.replace({ query: pendingQuery.value });
@@ -229,6 +265,25 @@ function onApplyClick() {
229
265
  margin-top: 8px;
230
266
  }
231
267
 
268
+ // в облаке чипсов кнопка идёт последней, а слот `FilterChips` кладёт её первой
269
+ .filter-model__clear {
270
+ @include font-sm;
271
+
272
+ order: 1;
273
+ padding: 6px 8px;
274
+ color: $filter-model-clear-text;
275
+
276
+ &:is(:hover, :focus) {
277
+ color: $filter-model-clear-text--hover;
278
+ }
279
+ }
280
+
281
+ // диапазон в сетке быстрых фильтров занимает обе колонки: слайдеру нужна вся
282
+ // ширина под шкалу и подписи границ
283
+ .filter-popup__grid > .filter-model-field__slider {
284
+ grid-column: 1 / -1;
285
+ }
286
+
232
287
  .filter-model__footer-buttons {
233
288
  display: flex;
234
289
  justify-content: space-between;
@@ -0,0 +1,78 @@
1
+ <!-- Сетка вебкам-тумб: те же колонки, что у основной сетки видео (2 / 3 / 4).
2
+
3
+ Компонент отвечает только за раскладку и за выбор обёртки провайдера: обёртка
4
+ берётся из мапы адаптеров по конфигу и рендерится через `<component :is>`, так
5
+ что сетка не знает ни про Stripcash, ни про формат его полей.
6
+
7
+ События тумб (клик и появление постера) уходят наружу вместе с индексом — сквозной
8
+ индекс тумбы на странице нужен статистике, а карточка про страницу не знает. -->
9
+ <template>
10
+ <ul class="grid-webcams">
11
+ <li
12
+ v-for="(model, index) in items"
13
+ :key="`webcam-${model.id || model.username}`"
14
+ class="grid-webcams__item"
15
+ >
16
+ <component
17
+ :is="adapter"
18
+ :item="model"
19
+ :touch-preview="isMobile"
20
+ :first-lang="locale"
21
+ @click="emit('model-click', { model, index })"
22
+ @poster="element => emit('model-view', { model, index, element })"
23
+ />
24
+ </li>
25
+ </ul>
26
+ </template>
27
+
28
+ <script setup lang="ts">
29
+ import type { IStripcashModel, IWebcamsConfig } from '../../types';
30
+ import { WEBCAMS_DEFAULTS } from '../../runtime';
31
+ import { resolveWebcamAdapter } from '../../utils/webcams/adapters';
32
+
33
+ defineProps<{
34
+ items: IStripcashModel[]
35
+ }>();
36
+
37
+ /**
38
+ * Клики и показы тумб сетка отдаёт наружу: индексы она знает, а куда их
39
+ * отправлять — дело сайта (у статистики свой контекст страницы и устройства).
40
+ */
41
+ const emit = defineEmits<{
42
+ 'model-click': [payload: { model: IStripcashModel, index: number }]
43
+ 'model-view': [payload: { model: IStripcashModel, index: number, element: HTMLElement }]
44
+ }>();
45
+
46
+ const { locale } = useI18n();
47
+ const isMobile = useState<boolean>('isMobile');
48
+
49
+ const webcamsConfig = computed(() => (useRuntimeConfig().public.webcamsConfig || {}) as IWebcamsConfig);
50
+
51
+ /** Обёртка активного провайдера: ленивый импорт из мапы адаптеров. */
52
+ const provider = computed(() => String(webcamsConfig.value.provider || WEBCAMS_DEFAULTS.provider));
53
+ const adapter = computed(() => defineAsyncComponent(resolveWebcamAdapter(provider.value)));
54
+ </script>
55
+
56
+ <style lang="scss">
57
+ .grid-webcams {
58
+ display: grid;
59
+ gap: 12px;
60
+ grid-template-columns: repeat(2, minmax(0, 1fr));
61
+
62
+ @include from-br(xs) {
63
+ grid-template-columns: repeat(3, minmax(0, 1fr));
64
+ }
65
+
66
+ @include from-br(xm) {
67
+ gap: 24px;
68
+ }
69
+
70
+ @include from-br(md) {
71
+ grid-template-columns: repeat(4, minmax(0, 1fr));
72
+ }
73
+ }
74
+
75
+ .grid-webcams__item {
76
+ min-width: 0;
77
+ }
78
+ </style>