itube-specs 0.0.919 → 0.0.921

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 (35) hide show
  1. package/assets/scss/themes/dark/_theme.scss +31 -5
  2. package/assets/scss/themes/light/_theme.scss +31 -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/player/core.vue +37 -1
  9. package/components/ui/switcher.vue +114 -0
  10. package/components/view/webcams.vue +370 -0
  11. package/components/webcam/header.vue +1 -43
  12. package/components/webcam/icon.vue +49 -0
  13. package/components/webcam/main.vue +8 -62
  14. package/components/webcam/stripcash-card.vue +2 -2
  15. package/composables/fetch/use-fetch-models-count.ts +20 -0
  16. package/composables/fetch/use-fetch-webcam-models.ts +45 -0
  17. package/lib/index.ts +1 -0
  18. package/lib/scheme/sort-webcams.ts +22 -0
  19. package/package.json +1 -1
  20. package/pages/webcams/[[tag]].vue +75 -0
  21. package/server/api/models/count.get.ts +18 -0
  22. package/server/api/webcams/list.post.ts +41 -0
  23. package/services/api/models.service.ts +20 -0
  24. package/services/api/webcams.service.ts +34 -0
  25. package/types/index.d.ts +3 -0
  26. package/types/switcher-item.d.ts +13 -0
  27. package/types/webcam-facet.d.ts +7 -0
  28. package/types/webcam-models-response.d.ts +12 -0
  29. package/utils/webcams/__tests__/count-filters.test.ts +12 -0
  30. package/utils/webcams/__tests__/normalize-facets.test.ts +52 -0
  31. package/utils/webcams/__tests__/to-filter-payload.test.ts +17 -0
  32. package/utils/webcams/count-filters.ts +14 -0
  33. package/utils/webcams/facets.ts +31 -0
  34. package/utils/webcams/normalize-facets.ts +57 -0
  35. package/utils/webcams/to-filter-payload.ts +16 -0
@@ -720,6 +720,7 @@ $player-menu-option-bar--active: $color-zinc-400; // bg-zinc-400
720
720
  $player-menu-row-bg--hover: $mono-white-10;
721
721
  $player-menu-option-bg--hover: $mono-white-5; // hover:bg-white/5
722
722
  $player-menu-toggle-bg--active: $color-indigo-500; // switch on: bg-indigo-500
723
+ $player-center-label-shadow: $color-black-50; // мета в центре бара поверх видео
723
724
 
724
725
  // accordion (scene types)
725
726
  $player-menu-acc-border: $color-zinc-800-60; // border-zinc-800/60
@@ -1059,8 +1060,8 @@ $document-link: $color-indigo-400;
1059
1060
  $document-link--hover: $color-indigo-300;
1060
1061
 
1061
1062
  // ─── Вебкам-блок ───
1062
- $webcam-header-icon: $color-white;
1063
- $webcam-header-icon-bg: $color-white-10;
1063
+ $webcam-icon-text: $color-white;
1064
+ $webcam-icon-bg: $color-white-10;
1064
1065
  $webcam-header-promo-text: $color-white;
1065
1066
  $webcam-header-promo-bg: $color-indigo-600;
1066
1067
  $webcam-header-promo-bg--hover: $color-indigo-500;
@@ -1072,12 +1073,15 @@ $webcam-counters-language-bg: $color-zinc-800-70;
1072
1073
  $card-webcam-poster-bg: $color-zinc-800;
1073
1074
  $card-webcam-online-dot: $color-emerald-400;
1074
1075
  $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;
1076
+ $card-webcam-badge-text: $color-white-90;
1077
+ $card-webcam-badge-bg: $color-black-45;
1078
+ $card-webcam-badge-border: $color-white-25;
1079
+ $card-webcam-badge-shadow: $color-black-90;
1078
1080
  $card-webcam-goal-text: $color-white;
1079
1081
  $card-webcam-goal-label: $color-zinc-400;
1080
1082
  $card-webcam-goal-percent: $color-white-70;
1083
+ $card-webcam-goal-plain-text: $color-zinc-300;
1084
+ $card-webcam-goal-plain-percent: $color-zinc-400;
1081
1085
  $card-webcam-goal-track-bg: $color-white-15;
1082
1086
  $card-webcam-goal-fill-bg: $color-emerald-400;
1083
1087
  $card-webcam-goal-shade-from: $color-black-95;
@@ -1090,6 +1094,28 @@ $card-webcam-tag-text: $color-zinc-300;
1090
1094
  $card-webcam-tag-bg: $color-zinc-800;
1091
1095
  $card-webcam-tag-text--hover: $color-white;
1092
1096
  $card-webcam-tag-bg--hover: $color-zinc-700;
1097
+ $page-webcams-divider: $color-zinc-700;
1098
+ $page-webcams-control-bg: $color-zinc-800-30;
1099
+ $page-webcams-control-border: $color-zinc-700-30;
1100
+ $page-webcams-control-text: $color-white;
1101
+
1102
+ // ─── Switcher ───
1103
+ $switcher-bg: $color-zinc-900-80;
1104
+ $switcher-border: $color-zinc-800;
1105
+ $switcher-text: $color-zinc-400;
1106
+ $switcher-text--hover: $color-white;
1107
+ $switcher-text--active: $color-white;
1108
+ $switcher-bg--active: $color-indigo-600;
1109
+ $switcher-icon: $color-zinc-500;
1110
+ $switcher-icon--active: $color-emerald-400;
1111
+ $switcher-subtitle: $color-zinc-600;
1112
+ $switcher-subtitle--active: $color-white-60;
1113
+
1114
+ // ─── Filter: сброс всех фильтров в облаке чипсов ───
1115
+ $filter-model-clear-text: $color-zinc-500;
1116
+ $filter-model-clear-text--hover: $color-white;
1117
+
1118
+ // ─── Вебкам-карточка (продолжение) ───
1093
1119
  $card-webcam-languages-border: $color-white-20;
1094
1120
  $card-webcam-languages-fallback-bg: $color-zinc-800;
1095
1121
  $card-webcam-languages-fallback-icon: $color-zinc-300;
@@ -771,6 +771,7 @@ $player-menu-option-bar--active: $color-gray-400; // bg-zinc-400
771
771
  $player-menu-row-bg--hover: $mono-white-10;
772
772
  $player-menu-option-bg--hover: $mono-white-5; // hover:bg-white/5
773
773
  $player-menu-toggle-bg--active: $color-indigo-500; // switch on: bg-indigo-500
774
+ $player-center-label-shadow: $color-black-50; // мета в центре бара поверх видео
774
775
 
775
776
  // accordion (scene types)
776
777
  $player-menu-acc-border: $color-slate-700; // border-zinc-800/60
@@ -1087,8 +1088,8 @@ $document-link: $color-indigo-400;
1087
1088
  $document-link--hover: $color-indigo-300;
1088
1089
 
1089
1090
  // ─── Вебкам-блок ───
1090
- $webcam-header-icon: $color-gray-700;
1091
- $webcam-header-icon-bg: $color-gray-200;
1091
+ $webcam-icon-text: $color-gray-700;
1092
+ $webcam-icon-bg: $color-gray-200;
1092
1093
  $webcam-header-promo-text: $color-white;
1093
1094
  $webcam-header-promo-bg: $color-indigo-600;
1094
1095
  $webcam-header-promo-bg--hover: $color-indigo-500;
@@ -1100,12 +1101,15 @@ $webcam-counters-language-bg: $color-gray-200;
1100
1101
  $card-webcam-poster-bg: $color-gray-200;
1101
1102
  $card-webcam-online-dot: $color-emerald-500;
1102
1103
  $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;
1104
+ $card-webcam-badge-text: $color-white;
1105
+ $card-webcam-badge-bg: $color-black-45;
1106
+ $card-webcam-badge-border: $color-white-25;
1107
+ $card-webcam-badge-shadow: $color-black-90;
1106
1108
  $card-webcam-goal-text: $color-white;
1107
1109
  $card-webcam-goal-label: $color-gray-300;
1108
1110
  $card-webcam-goal-percent: $color-white-70;
1111
+ $card-webcam-goal-plain-text: $color-gray-700;
1112
+ $card-webcam-goal-plain-percent: $color-gray-500;
1109
1113
  $card-webcam-goal-track-bg: $color-white-15;
1110
1114
  $card-webcam-goal-fill-bg: $color-emerald-400;
1111
1115
  $card-webcam-goal-shade-from: $color-black-95;
@@ -1118,6 +1122,28 @@ $card-webcam-tag-text: $color-gray-700;
1118
1122
  $card-webcam-tag-bg: $color-gray-200;
1119
1123
  $card-webcam-tag-text--hover: $color-gray-950;
1120
1124
  $card-webcam-tag-bg--hover: $color-gray-300;
1125
+ $page-webcams-divider: $color-gray-300;
1126
+ $page-webcams-control-bg: $color-gray-100;
1127
+ $page-webcams-control-border: $color-gray-200;
1128
+ $page-webcams-control-text: $color-gray-950;
1129
+
1130
+ // ─── Switcher ───
1131
+ $switcher-bg: $color-gray-100;
1132
+ $switcher-border: $color-gray-200;
1133
+ $switcher-text: $color-gray-500;
1134
+ $switcher-text--hover: $color-gray-950;
1135
+ $switcher-text--active: $color-white;
1136
+ $switcher-bg--active: $color-indigo-600;
1137
+ $switcher-icon: $color-gray-500;
1138
+ $switcher-icon--active: $color-emerald-400;
1139
+ $switcher-subtitle: $color-gray-500;
1140
+ $switcher-subtitle--active: $color-white-60;
1141
+
1142
+ // ─── Filter: сброс всех фильтров в облаке чипсов ───
1143
+ $filter-model-clear-text: $color-gray-500;
1144
+ $filter-model-clear-text--hover: $color-gray-950;
1145
+
1146
+ // ─── Вебкам-карточка (продолжение) ───
1121
1147
  $card-webcam-languages-border: $color-gray-300;
1122
1148
  $card-webcam-languages-fallback-bg: $color-gray-200;
1123
1149
  $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>
@@ -208,7 +208,7 @@ function nextPreviewMeta(card: IVideoCard): PreviewMetaItem[] {
208
208
  { text: card.channelName || t('user_uploaded'), icon: CHANNEL_META_ICON },
209
209
  ];
210
210
  if (!props.hideViews && card.views != null) {
211
- meta.push(`${n(card.views, 'compact')} ${t('views')}`);
211
+ meta.push(`${n(card.views, 'compact')} ${t('views', card.views)}`);
212
212
  }
213
213
  return meta;
214
214
  }
@@ -244,6 +244,32 @@ watch(() => props.data.videoUrl, () => {
244
244
  source.value = buildSource();
245
245
  });
246
246
 
247
+ /**
248
+ * Центр контрол-бара: «N просмотров · 3 месяца назад» вместо названия текущей
249
+ * сцены (опция `controls.centerLabel`, плеер 0.11.0+). Больше этой меты на
250
+ * странице нигде нет — под плеером её не выводим.
251
+ *
252
+ * Отдаём СВОЙ элемент, а не строку: кастомный центр плеер рисует один раз при
253
+ * инициализации и больше не трогает, а ролик на странице меняется без пересоздания
254
+ * плеера (`externalAdvance` → `player.load()`), так что строка осталась бы от
255
+ * предыдущего ролика. Свой элемент мы просто переписываем сами.
256
+ */
257
+ const centerLabel = shallowRef<HTMLElement | null>(null);
258
+
259
+ if (import.meta.client) {
260
+ centerLabel.value = document.createElement('span');
261
+ }
262
+
263
+ watchEffect(() => {
264
+ if (!centerLabel.value) return;
265
+
266
+ const parts: string[] = [];
267
+ if (props.data.views != null) parts.push(`${n(props.data.views, 'compact')} ${t('views', props.data.views)}`);
268
+ if (props.data.created) parts.push(useFormatTimeAgo(props.data.created, locale.value));
269
+
270
+ centerLabel.value.textContent = parts.join(' · ');
271
+ });
272
+
247
273
  const options = computed<Omit<PlayerOptions, 'source'>>(() => ({
248
274
  playOnInit: true,
249
275
  muted: false,
@@ -265,6 +291,8 @@ const options = computed<Omit<PlayerOptions, 'source'>>(() => ({
265
291
  scenes: false,
266
292
  // Порядок правого кластера: sceneTypes → gear (настройки) → fullscreen (последней).
267
293
  order: ['sceneTypes', 'gear', 'fullscreen'],
294
+ // Центр бара — мета ролика вместо названия текущей сцены (см. centerLabel выше).
295
+ centerLabel: centerLabel.value || false,
268
296
  },
269
297
  icons: PLAYER_ICONS,
270
298
  // Заголовок дропдауна выбора типа сцен (дефолт «Scene type»).
@@ -300,4 +328,12 @@ async function loadAdConfig(): Promise<AdsOptions | undefined> {
300
328
  border-radius: 0;
301
329
  }
302
330
  }
331
+
332
+ // Мета ролика в центре бара: по макету белая с тенью и средней жирностью,
333
+ // у плеера по умолчанию — приглушённая и без тени.
334
+ .player-core .imp-controls__chapter {
335
+ font-weight: 500;
336
+ opacity: 1;
337
+ filter: drop-shadow(0 1px 2px $player-center-label-shadow);
338
+ }
303
339
  </style>