itube-specs 0.0.607 → 0.0.609

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.
@@ -12,7 +12,9 @@
12
12
  class="s-expand-row__wrapper"
13
13
  :class="[{ '--more-show': isElementsOverflow }]"
14
14
  >
15
- <slot name="prepend"/>
15
+ <slot name="prepend">
16
+ <span v-if="preText" class="s-expand-row__pre-text">{{ preText }}</span>
17
+ </slot>
16
18
  <slot>
17
19
  <SChips
18
20
  v-for="item in itemsResult"
@@ -54,6 +56,7 @@ const props = withDefaults(
54
56
  mini?: boolean;
55
57
  mobileExpand?: boolean;
56
58
  maximumButtons?: number;
59
+ preText?: string;
57
60
  }>(), {
58
61
  maximumButtons: 20, // вычислено на глаз, приблизительно
59
62
  }
@@ -13,7 +13,7 @@
13
13
  </span>
14
14
  <span
15
15
  v-if="count > 0"
16
- class="s-filter-button__mobile-count _to-sm"
16
+ class="s-filter-button__count"
17
17
  >{{ mobileCount }}</span>
18
18
  </SButton>
19
19
  </template>
@@ -9,8 +9,6 @@
9
9
  with-close
10
10
  :index="`s-filter-page-chips${index}`"
11
11
  :item="item"
12
- bright
13
- mini
14
12
  @click="onChipsClick(item)"
15
13
  />
16
14
  </SExpandRow>
@@ -32,6 +32,7 @@
32
32
  :model-value="getSelectValue(item.name)"
33
33
  :items="selectItems(item)"
34
34
  :count="getCount(item)"
35
+ size="s"
35
36
  :active="isActiveSelect(item.name)"
36
37
  :label="item.title"
37
38
  @update:model-value="val => updateSelectFilter(item.name, val)"
@@ -44,10 +45,9 @@
44
45
  </template>
45
46
 
46
47
  <script setup lang="ts">
47
- import type { IModelFilter, IModelFilterOptions, CssBreakpoints } from '../../types';
48
+ import type { IModelFilter } from '../../types';
48
49
 
49
50
  import { useRoute, useRouter } from 'vue-router';
50
- import { getMonth, isMobileWidth } from '../../runtime';
51
51
 
52
52
  const props = defineProps<{
53
53
  filters: IModelFilter[]
@@ -1,44 +1,63 @@
1
1
  <template>
2
2
  <div class="s-info-grid">
3
3
  <div
4
- v-for="(item, index) in card.parameters"
5
- :key="`s-info-grid-item-${index}`"
6
- class="s-info-grid__item"
4
+ v-for="(group, gIndex) in groups"
5
+ :key="`s-info-grid-group-${gIndex}`"
6
+ class="s-info-grid__group"
7
7
  >
8
- <span class="s-info-grid__item-title">{{ item.title }}</span>
9
- <p class="s-info-grid__item-values">
8
+ <h3 class="s-info-grid__group-title">{{ group.title }}</h3>
9
+ <div class="s-info-grid__group-items">
10
10
  <NuxtLink
11
- v-for="(value, subindex) in getValue(item.values)"
12
- :key="`model-value-${index}${subindex}`"
13
- class="s-info-grid__item-value"
14
- :to="generateLink(link(item, value.name))"
15
- >{{ value.title }}{{ subindex < getValue(item.values).length - 1 ? ', ' : ''}}</NuxtLink>
16
- </p>
11
+ v-for="(item, index) in group.items"
12
+ :key="`s-info-grid-item-${gIndex}-${index}`"
13
+ class="s-info-grid__item"
14
+ :to="generateLink(link(item, item.values[0]))"
15
+ >
16
+ <SIcon class="s-info-grid__item-icon" name="tattoos" size="12" />
17
+ <span class="s-info-grid__item-title">{{ item.title }}</span>
18
+ <p class="s-info-grid__item-values">
19
+ <span
20
+ v-for="(value, vIndex) in item.values"
21
+ :key="`model-value-${gIndex}-${index}-${vIndex}`"
22
+ class="s-info-grid__item-value"
23
+ :class="[
24
+ {'--success': isSuccess(value)},
25
+ {'--warning': isWarning(value)}
26
+ ]"
27
+ >{{ value }}{{ vIndex < item.values.length - 1 ? ', ' : '' }}</span>
28
+ </p>
29
+ </NuxtLink>
30
+ </div>
17
31
  </div>
18
32
  </div>
19
33
  </template>
20
34
 
21
35
  <script setup lang="ts">
22
- import type { ICardInfo, IParameterModel, IParameterModelValue } from '../../types';
36
+ import type { IGroupedParameter, IGroupedParameterItem } from 'itube-specs/types';
23
37
 
24
38
  defineProps<{
25
- card: ICardInfo
39
+ groups: IGroupedParameter[]
26
40
  }>();
27
41
 
28
- function getValue(value: IParameterModelValue[]) {
29
- return value.map(item => item);
30
- }
31
-
32
42
  const {generateLink} = useGenerateLink();
33
43
 
34
- function link(item: IParameterModel, value: string) {
44
+ function link(item: IGroupedParameterItem, value: string) {
35
45
  const formattedValue = value.toLowerCase().replace(/\s+/g, '+');
36
- const groupNumber = `&group=${item.group.order}`;
37
46
 
38
47
  if (item.kind === 'range') {
39
- return `/models?filter_${item.name}_from=${formattedValue}&filter_${item.name}_to=${formattedValue}${groupNumber}`;
48
+ return `/models?filter_${item.name}_from=${formattedValue}&filter_${item.name}_to=${formattedValue}`;
40
49
  } else {
41
- return `/models?filter_${item.name}=${formattedValue}${groupNumber}`;
50
+ return `/models?filter_${item.name}=${formattedValue}`;
42
51
  }
43
52
  }
53
+
54
+ function isSuccess(value: string) {
55
+ const values = ['active'];
56
+ return values.includes(value)
57
+ }
58
+
59
+ function isWarning(value: string) {
60
+ const values = ['inactive'];
61
+ return values.includes(value)
62
+ }
44
63
  </script>
@@ -0,0 +1,117 @@
1
+ <template>
2
+ <div class="s-like">
3
+ <button
4
+ class="s-like__control"
5
+ :class="{'--active': isLiked}"
6
+ type="button"
7
+ :title="t('like')"
8
+ @click="onReactionClick('like')"
9
+ >
10
+ <span class="s-like__control-icon-wrapper">
11
+ <SIcon class="s-like__control-icon --like" name="thumbs-up" size="16"/>
12
+ </span>
13
+ <span class="s-like__control-text">{{ formatNumber(Number(resultLikes)) }}</span>
14
+ </button>
15
+ <div
16
+ v-if="bar"
17
+ class="s-like__bar"
18
+ >
19
+ <div
20
+ class="s-like__bar-likes"
21
+ :style="{ width: likePercent + '%' }"
22
+ />
23
+ <div
24
+ class="s-like__bar-dislikes"
25
+ :style="{ width: dislikePercent + '%' }"
26
+ />
27
+ </div>
28
+ <button
29
+ class="s-like__control"
30
+ :class="{'--active': isDisliked}"
31
+ type="button"
32
+ :title="t('dislike')"
33
+ @click="onReactionClick('dislike')"
34
+ >
35
+ <span class="s-like__control-icon-wrapper">
36
+ <SIcon class="s-like__control-icon --dislike" name="thumbs-down" size="16"/>
37
+ </span>
38
+ <span class="s-like__control-text">{{ formatNumber(Number(resultDislikes)) }}</span>
39
+ </button>
40
+ </div>
41
+ </template>
42
+
43
+ <script setup lang="ts">
44
+ import { formatNumber } from 'itube-specs/runtime';
45
+ import { VideosApiService } from '~/services/api/videos.service';
46
+ import { PlaylistsApiService } from '~/services/api/playlists.service';
47
+
48
+ const { t } = useI18n();
49
+
50
+ const props = defineProps<{
51
+ id?: string
52
+ likes: number
53
+ dislikes: number
54
+ bar?: boolean
55
+ videoMd5?: string
56
+ }>()
57
+
58
+ const { execute: executeVideoLike } = useApiAction(VideosApiService.videoLike);
59
+ const { execute: executeVideoDislike } = useApiAction(VideosApiService.videoDislike);
60
+ const { execute: executePlaylistLike } = useApiAction(PlaylistsApiService.playlistLike);
61
+ const { execute: executePlaylistDislike } = useApiAction(PlaylistsApiService.playlistDislike);
62
+
63
+ const storageKey = computed(() => props.videoMd5 || props.id || '');
64
+
65
+ const likeStatus = ref<'like' | 'dislike' | null>(null);
66
+ const isLiked = computed(() => likeStatus.value === 'like');
67
+ const isDisliked = computed(() => likeStatus.value === 'dislike');
68
+ const resultLikes = ref(props.likes);
69
+ const resultDislikes = ref(props.dislikes);
70
+
71
+ onMounted(() => {
72
+ const stored = localStorage.getItem(storageKey.value);
73
+ if (stored === 'like' || stored === 'dislike') {
74
+ likeStatus.value = stored;
75
+ if (stored === 'like') resultLikes.value += 1;
76
+ if (stored === 'dislike') resultDislikes.value += 1;
77
+ }
78
+ });
79
+
80
+ async function onReactionClick(type: 'like' | 'dislike') {
81
+ if (likeStatus.value !== type) {
82
+ likeStatus.value = type;
83
+ localStorage.setItem(storageKey.value, type);
84
+
85
+ const isLike = type === 'like';
86
+
87
+ if (isLike) {
88
+ if (resultLikes.value < 1000) resultLikes.value += 1;
89
+ if (resultDislikes.value > 0) resultDislikes.value -= 1;
90
+ } else {
91
+ if (resultDislikes.value < 1000) resultDislikes.value += 1;
92
+ if (resultLikes.value > 0) resultLikes.value -= 1;
93
+ }
94
+
95
+ if (props.videoMd5) {
96
+ if (isLike) {
97
+ await executeVideoLike([props.videoMd5]);
98
+ } else {
99
+ await executeVideoDislike([props.videoMd5]);
100
+ }
101
+ } else {
102
+ if (isLike) {
103
+ await executePlaylistLike([props.id!]);
104
+ } else {
105
+ await executePlaylistDislike([props.id!]);
106
+ }
107
+ }
108
+ }
109
+ }
110
+
111
+ const likes = computed(() => resultLikes.value || 0);
112
+ const dislikes = computed(() => resultDislikes.value || 0);
113
+ const total = computed(() => likes.value + dislikes.value || 1);
114
+
115
+ const likePercent = computed(() => (likes.value / total.value) * 100);
116
+ const dislikePercent = computed(() => (dislikes.value / total.value) * 100);
117
+ </script>
@@ -1,44 +1,45 @@
1
1
  <template>
2
- <SFilterChips
3
- :items="chipsItems"
4
- @click="(e: IChipsItem)=> onChipsClick(e)"
5
- />
6
- <transition>
7
- <SPopup
8
- v-if="model"
9
- v-model="model"
10
- sheet
11
- class="s-model-filters__popup"
12
- >
13
- <template #title>{{ popupTitle }}</template>
14
- <SFilterPage
15
- class="s-model-filters__filters"
16
- :filters="data.filters"
17
- :count="data.total"
18
- :class="{'_loading': status === 'pending'}"
19
- />
20
- <template #footer>
21
- <div class="s-model-filters__footer-buttons">
22
- <SButton
23
- wide
24
- class="s-model-filters__reset"
25
- :disabled="!isFiltered"
26
- size="l"
27
- theme="secondary"
28
- @click="onResetClick"
29
- >{{ t('reset_all') }}
30
- </SButton>
31
- <SButton
32
- wide
33
- size="l"
34
- theme="primary"
35
- @click="onShowClick"
36
- >{{ t('show_results') }}
37
- </SButton>
38
- </div>
39
- </template>
40
- </SPopup>
41
- </transition>
2
+ <div class="s-model-filters">
3
+ <SFilterChips
4
+ :items="chipsItems"
5
+ @click="(e: IChipsItem)=> onChipsClick(e)"
6
+ />
7
+ <transition>
8
+ <SPopup
9
+ v-if="model"
10
+ v-model="model"
11
+ class="s-model-filters__popup"
12
+ >
13
+ <template #title>{{ popupTitle }}</template>
14
+ <SFilterPage
15
+ class="s-model-filters__filters"
16
+ :filters="data.filters"
17
+ :count="data.total"
18
+ :class="{'_loading': status === 'pending'}"
19
+ />
20
+ <template #footer>
21
+ <div class="s-model-filters__footer-buttons">
22
+ <SButton
23
+ wide
24
+ class="s-model-filters__reset"
25
+ :disabled="!isFiltered"
26
+ size="l"
27
+ theme="secondary"
28
+ @click="onResetClick"
29
+ >{{ t('reset_all') }}
30
+ </SButton>
31
+ <SButton
32
+ wide
33
+ size="l"
34
+ theme="primary"
35
+ @click="onShowClick"
36
+ >{{ t('show_results') }}
37
+ </SButton>
38
+ </div>
39
+ </template>
40
+ </SPopup>
41
+ </transition>
42
+ </div>
42
43
  </template>
43
44
 
44
45
  <script setup lang="ts">
@@ -3,9 +3,40 @@
3
3
  <SPopup
4
4
  v-if="isReportPopupOpen"
5
5
  v-model="isReportPopupOpen"
6
+ sheet
7
+ back
8
+ class="s-report"
9
+ :class="{'--back-show': activeStep !== 'list'}"
10
+ @back="activeStep === 'list' ? handleBack() : goToList()"
6
11
  >
7
12
  <template #title>{{ t('report') }}</template>
13
+
14
+ <!-- Step 1: Category list -->
15
+ <div v-if="activeStep === 'list'" class="s-report__popup-wrapper">
16
+ <SVideoMiniCard
17
+ class="s-report__video-card"
18
+ :card="reportedVideoCard"
19
+ />
20
+ <div class="s-report__categories">
21
+ <button
22
+ v-for="(item, index) in reportFormsScheme"
23
+ :key="`report-category-${index}`"
24
+ class="s-report__category-button"
25
+ @click="selectCategory(item.subject)"
26
+ >
27
+ {{ t(`report_form.${item.title}`) }}
28
+ <SIcon
29
+ class="s-report__category-icon"
30
+ name="chevron-right"
31
+ size="20"
32
+ />
33
+ </button>
34
+ </div>
35
+ </div>
36
+
37
+ <!-- Step 2: Report form -->
8
38
  <div
39
+ v-else
9
40
  class="s-report__popup-wrapper"
10
41
  :class="{'_loading': loading}"
11
42
  >
@@ -13,107 +44,88 @@
13
44
  class="s-report__video-card"
14
45
  :card="reportedVideoCard"
15
46
  />
16
- <div class="s-report__forms">
17
- <details
18
- v-for="(item, index) in reportFormsScheme"
19
- :key="`report-form-${index}`"
20
- class="s-report__form"
21
- name="report-details"
22
- @toggle="(event) => onToggle(event, item.subject)"
47
+ <div class="s-report__wrapper" v-if="activeCategory">
48
+ <p
49
+ v-if="activeCategory.text"
50
+ class="s-report__form-text"
51
+ >{{ t(`report_form.${activeCategory.text}`) }}
52
+ </p>
53
+ <p
54
+ v-if="activeCategory.subtext"
55
+ class="s-report__form-text"
56
+ >{{ t(`report_form.${activeCategory.subtext}`) }}
57
+ </p>
58
+ <ul
59
+ v-if="activeCategory.list && activeCategory.list.length > 0"
60
+ class="s-report__form-menu"
23
61
  >
24
- <summary
25
- class="s-report__form-button"
62
+ <li
63
+ v-for="(subItem, subIndex) in activeCategory.list"
64
+ :key="`report-list-${subIndex}`"
65
+ class="s-report__form-list"
66
+ >{{ t(`report_form.${subItem.text}`) }}
67
+ </li>
68
+ </ul>
69
+ <form class="s-report__form-wrapper">
70
+ <div
71
+ v-for="(subItem, subIndex) in activeCategory.items"
72
+ :key="`${subItem.label}-${subIndex}`"
73
+ :class="[{'--wide': subItem.wide},subItem.marginClass]"
26
74
  >
27
- {{ t(`report_form.${item.title}`) }}
28
- <SIcon
29
- class="s-report__form-icon"
30
- name="chevron-down"
31
- size="24"
75
+ <SInput
76
+ v-if="['text', 'tel', 'textarea'].includes(subItem.type)"
77
+ v-model="form.data[subItem.value] as string"
78
+ :label="subItem.label ? t(`report_form.${subItem.label}`) : undefined"
79
+ :hide-label="subItem.hideLabel"
80
+ :type="subItem.type as InputTypes"
81
+ :placeholder="subItem.placeholder ? t(`report_form.${subItem.placeholder}`) : undefined"
82
+ :required="subItem.required"
83
+ :error="error[subItem.value]"
84
+ @update:error="(val: boolean) => error[subItem.value] = val"
32
85
  />
33
- </summary>
34
- <p
35
- v-if="item.text"
36
- class="s-report__form-text"
37
- >{{ t(`report_form.${item.text}`) }}
38
- </p>
39
- <p
40
- v-if="item.subtext"
41
- class="s-report__form-text"
42
- >{{ t(`report_form.${item.subtext}`) }}
43
- </p>
44
- <ul
45
- v-if="item.list && item.list.length > 0"
46
- >
47
- <li
48
- v-for="(subItem, subIndex) in item.list"
49
- :key="`report-list-${subIndex}`"
50
- class="s-report__form-list"
51
- >{{ t(`report_form.${subItem.text}`) }}
52
- </li>
53
- </ul>
54
- <form class="s-report__form-wrapper">
55
- <template
56
- v-for="(subItem, subIndex) in item.items"
57
- :key="`${subItem.label}-${subIndex}`"
58
- >
59
- <SInput
60
- v-if="['text', 'tel', 'textarea'].includes(subItem.type)"
61
- :key="`report-form-${index}`"
62
- v-model="form.data[subItem.value] as string"
63
- :label=" t(`report_form.${subItem.label}`)"
64
- :type="subItem.type as InputTypes"
65
- :class="{'--wide': subItem.wide}"
66
- :required="subItem.required"
67
- :error="error[subItem.value]"
68
- @update:error="(val: boolean) => error[subItem.value] = val"
69
- />
70
- <SCheckbox
71
- v-if="subItem.type === 'checkbox'"
72
- v-model="form.data[subItem.value] as boolean"
73
- class="--wide"
74
- :required="subItem.required"
75
- :error="error[subItem.value]"
76
- @update:error="(val: boolean) => error[subItem.value] = val"
77
- >{{ t(`report_form.${subItem.text}`) }}
78
- </SCheckbox>
79
- <SRadio
80
- v-if="subItem.type === 'radio' "
81
- v-model="reasonValue"
82
- name="report-radio-value"
83
- :value="subItem.value"
84
- class="--wide"
85
- :label=" t(`report_form.${subItem.text}`)"
86
- :required="subItem.required"
87
- :error="errorRadio"
88
- @update:error="(val: boolean) => errorRadio = val"
89
- />
90
- </template>
91
- </form>
92
- <SButton
93
- :disabled="loading"
94
- wide
95
- size="l"
96
- theme="primary"
97
- @click="submit"
98
- >{{ t('report_form.send_report') }}
99
- </SButton>
100
- </details>
86
+ <SCheckbox
87
+ v-if="subItem.type === 'checkbox'"
88
+ v-model="form.data[subItem.value] as boolean"
89
+ :required="subItem.required"
90
+ :error="error[subItem.value]"
91
+ @update:error="(val: boolean) => error[subItem.value] = val"
92
+ >{{ t(`report_form.${subItem.text}`) }}
93
+ </SCheckbox>
94
+ <SRadio
95
+ v-if="subItem.type === 'radio'"
96
+ v-model="reasonValue"
97
+ name="report-radio-value"
98
+ :value="subItem.value"
99
+ :label="t(`report_form.${subItem.text}`)"
100
+ :required="subItem.required"
101
+ :error="errorRadio"
102
+ @update:error="(val: boolean) => errorRadio = val"
103
+ />
104
+ </div>
105
+ </form>
101
106
  </div>
102
- <SButton
103
- wide
104
- size="l"
105
- theme="secondary"
106
- @click="closeReportPopup"
107
- >{{ t('cancel') }}
108
- </SButton>
109
107
  </div>
108
+
109
+ <template v-if="activeStep !== 'list'" #footer>
110
+ <div class="s-report__send">
111
+ <SButton
112
+ class="s-report__send-button"
113
+ :disabled="loading"
114
+ wide
115
+ size="l"
116
+ theme="primary"
117
+ @click="submit"
118
+ >{{ t('report_form.send_report') }}
119
+ </SButton>
120
+ </div>
121
+ </template>
110
122
  </SPopup>
111
123
  </transition>
112
124
  </template>
113
125
 
114
126
  <script setup lang="ts">
115
- import { reportFormsScheme } from '../../lib/report-forms-scheme';
116
- import { EReportFormsSubjects, validateEmail, validatePhone } from '../../runtime';
127
+ import { reportFormsScheme } from 'itube-specs/lib/report-forms-scheme';
128
+ import { EReportFormsSubjects, validateEmail, validatePhone } from 'itube-specs/runtime';
117
129
  import type { InputTypes, IReportForm, IReportRequest } from '../../types';
118
130
 
119
131
  const { initRecaptcha, getRecaptchaToken } = useRecaptcha();
@@ -124,6 +136,9 @@ const error = ref<Record<string, boolean>>({});
124
136
 
125
137
  const { t } = useI18n();
126
138
 
139
+ type ReportStep = 'list' | EReportFormsSubjects;
140
+ const activeStep = ref<ReportStep>('list');
141
+
127
142
  const form = ref<IReportForm>({
128
143
  categoryName: EReportFormsSubjects.DMCA,
129
144
  reason: '',
@@ -132,13 +147,38 @@ const form = ref<IReportForm>({
132
147
  data: {},
133
148
  })
134
149
 
135
- const { isReportPopupOpen, closeReportPopup, reportedVideoCard } = useReportPopup();
150
+ const { isReportPopupOpen, closeReportPopup, reportedVideoCard, onBack } = useReportPopup();
151
+
152
+ function handleBack() {
153
+ const callback = onBack.value;
154
+ closeReportPopup();
155
+ callback?.();
156
+ }
157
+
158
+ function selectCategory(subject: EReportFormsSubjects) {
159
+ resetForm();
160
+ form.value.categoryName = subject;
161
+ activeStep.value = subject;
162
+ }
163
+
164
+ function goToList() {
165
+ resetForm();
166
+ activeStep.value = 'list';
167
+ }
168
+
169
+ watch(isReportPopupOpen, (val) => {
170
+ if (val) {
171
+ resetForm();
172
+ } else {
173
+ activeStep.value = 'list';
174
+ }
175
+ });
136
176
 
137
177
  const videoGuid = computed(() => reportedVideoCard.value.guid);
138
178
 
139
179
  const loading = ref(false);
140
180
 
141
- const activeCategory = computed(() => reportFormsScheme.find((subItem) => subItem.subject === form.value.categoryName))
181
+ const activeCategory = computed(() => reportFormsScheme.find((subItem) => subItem.subject === activeStep.value))
142
182
  const requiredFields = computed(() => activeCategory.value?.items?.filter(item => item.required).map(item => item.value));
143
183
 
144
184
  const { snackbarText, snackbarTheme, showErrorSnack, resetSnackbar } = useSnackbar();
@@ -228,13 +268,4 @@ async function submit() {
228
268
  loading.value = false
229
269
  }
230
270
  }
231
-
232
- function onToggle(event: Event, subject: EReportFormsSubjects) {
233
- const el = event.target as HTMLDetailsElement;
234
- if (el.open) {
235
- form.value.categoryName = subject;
236
- } else {
237
- resetForm();
238
- }
239
- }
240
271
  </script>
@@ -21,35 +21,49 @@
21
21
  :href="item.link"
22
22
  >
23
23
  <SImg
24
- sizes="44px"
25
- width="44"
26
- height="44"
24
+ class="s-share__button-icon"
25
+ sizes="28px"
26
+ width="28"
27
+ height="28"
27
28
  :src="`/img/socials/${item.img}`"
28
29
  />
29
30
  </a>
30
31
  </div>
31
- <SInput
32
- class="s-share__copy"
33
- :label="inputText"
34
- :title="fullUrl"
35
- readonly
36
- internal-label
37
- :icon="copyIcon"
38
- :model-value="fullUrl"
39
- @click="copyUrl"
40
- />
41
- <SButton
42
- wide
43
- theme="secondary"
44
- @click="closeSharePopup"
45
- >{{ $t('cancel') }}</SButton>
32
+ <div class="s-share__wrapper">
33
+ <div class="s-share__copy">
34
+ <span class="s-share__copy-label _from-sm">{{ inputText }}</span>
35
+ <span class="s-share__copy-url">{{ fullUrl }}</span>
36
+ <button
37
+ class="s-share__copy-button"
38
+ :title="$t('copy')"
39
+ @click="copyUrl"
40
+ type="button"
41
+ >
42
+ <SIcon
43
+ class="s-share__copy-icon"
44
+ :class="{'--accent': isCopied}"
45
+ :name="copyIcon"
46
+ size="20"
47
+ />
48
+ </button>
49
+ </div>
50
+ <div class="s-share__cancel-wrapper">
51
+ <SButton
52
+ class="s-share__cancel"
53
+ wide
54
+ theme="secondary"
55
+ @click="closeSharePopup"
56
+ >{{ $t('cancel') }}
57
+ </SButton>
58
+ </div>
59
+ </div>
46
60
  </div>
47
61
  </SPopup>
48
62
  </transition>
49
63
  </template>
50
64
 
51
65
  <script setup lang="ts">
52
- const {isSharePopupOpen, closeSharePopup, sharedVideoCard} = useSharePopup();
66
+ const { isSharePopupOpen, closeSharePopup, sharedVideoCard } = useSharePopup();
53
67
 
54
68
  const route = useRoute();
55
69
 
@@ -95,9 +109,9 @@ const buttons = computed(() => {
95
109
  text: 'Whatsapp',
96
110
  },
97
111
  {
98
- img: 'twitter.svg',
99
- link: `https://twitter.com/intent/tweet?url=${encodeURIComponent(fullUrl.value)}`,
100
- text: 'Twitter',
112
+ img: 'x.svg',
113
+ link: `https://x.com/intent/tweet?url=${encodeURIComponent(fullUrl.value)}`,
114
+ text: 'X',
101
115
  },
102
116
  ]
103
117
  })
@@ -1,48 +1,91 @@
1
1
  <template>
2
- <div v-if="isPlaylistAdd" class="s-playlist-add__decorate _to-sm"/>
3
2
  <!-- Add-->
4
3
  <transition mode="out-in">
5
4
  <SPopup
6
- v-if="isPlaylistAdd && step === 'add'"
5
+ class="s-playlist-add"
6
+ v-if="isPlaylistAdd"
7
7
  v-model="isPlaylistAdd"
8
8
  sheet
9
- transparent-backdrop
10
9
  @close="closePopup"
11
10
  >
12
11
  <template #title>{{ $t('playlist.add') }}</template>
13
12
 
14
- <div
15
- class="s-playlist-add__wrapper"
16
- :class="{'_loading': loadingUserPlaylists || loadingPostVideo}"
17
- >
13
+ <template #fixedContent>
18
14
  <SVideoMiniCard
19
15
  v-if="videoCard"
20
16
  class="s-playlist-add__mini-card"
21
17
  :card="videoCard"
22
18
  />
19
+ </template>
20
+ <div
21
+ class="s-playlist-add__wrapper"
22
+ :class="{'_loading': loadingUserPlaylists || loadingPostVideo}"
23
+ >
23
24
  <template
24
25
  v-if="playlistsItems && playlistsItems.length"
25
26
  >
26
- <SSelect
27
- v-model="selectValue"
28
- name="add-playlist"
29
- :items="playlistsItems"
30
- :placeholder="$t('choose_playlist')"
31
- />
32
- <p class="s-playlist-add__text">{{ $t('playlist.create_new') }}</p>
27
+ <SCheckbox
28
+ right
29
+ class="s-playlist-add__item"
30
+ :model-value="selectValue.includes('favorites')"
31
+ @update:model-value="togglePlaylist('favorites')"
32
+ >
33
+ <div class="s-playlist-add__item-wrapper --favorites">
34
+ <SIcon name="heart" size="20" />
35
+ {{ $t('favorites')}}
36
+ </div>
37
+ </SCheckbox>
38
+ <SCheckbox
39
+ v-for="(item, index) in playlistsItems"
40
+ class="s-playlist-add__item"
41
+ right
42
+ :key="`playlist-${index}`"
43
+ :model-value="selectValue.includes(item.value)"
44
+ @update:model-value="togglePlaylist(item.value)"
45
+ >
46
+ <div class="s-playlist-add__item-wrapper">
47
+ <SIcon
48
+ class="s-playlist-add__item-icon"
49
+ name="list"
50
+ size="20"
51
+ />
52
+ <span class="s-playlist-add__item-text">{{ item.title }}</span>
53
+ <div class="s-playlist-add__item-icon">
54
+ <SIcon name="video-camera" size="16" />
55
+ {{ item.videosCount }}
56
+ </div>
57
+ </div>
58
+ </SCheckbox>
33
59
  </template>
34
60
  <button
61
+ v-if="step !== 'new'"
35
62
  type="button"
36
- class="s-playlist-add__new"
63
+ class="s-playlist-add__item --new"
37
64
  @click="step = 'new'"
38
65
  >
39
66
  <SIcon
40
- class="s-playlist-add__new-icon"
41
- name="follow"
42
- size="24"
67
+ class="s-playlist-add__item-icon --new"
68
+ name="plus"
69
+ size="20"
43
70
  />
44
71
  {{ $t('playlist.new_playlist') }}
45
72
  </button>
73
+ <div v-else class="s-playlist-add__new">
74
+ <SInput
75
+ v-model="newPlaylistName"
76
+ :placeholder="$t('playlist.new_playlist')"
77
+ :icon="newPlaylistName ? 'close' : undefined"
78
+ @icon-click="clearNewPlaylist"
79
+ />
80
+ <SButton
81
+ size="s"
82
+ theme="primary"
83
+ :disabled="!newPlaylistName.trim()"
84
+ @click="createPlaylist"
85
+ >
86
+ {{ $t('create') }}
87
+ </SButton>
88
+ </div>
46
89
  </div>
47
90
 
48
91
  <template #footer>
@@ -51,7 +94,7 @@
51
94
  wide
52
95
  size="l"
53
96
  :disabled="loadingUserPlaylists || loadingPostVideo"
54
- theme="secondary"
97
+ theme="muted"
55
98
  @click="closePopup"
56
99
  >{{ $t('cancel') }}
57
100
  </SButton>
@@ -69,31 +112,11 @@
69
112
  </transition>
70
113
  <!-- Add-->
71
114
 
72
- <!-- New-->
73
- <transition mode="out-in">
74
- <SPopup
75
- v-if="isPlaylistAdd && step === 'new'"
76
- v-model="isPlaylistAdd"
77
- sheet
78
- transparent-backdrop
79
- back
80
- @close="closePopup"
81
- @back="backPopup"
82
- >
83
- <template #title>{{ $t('playlist.new_playlist') }}</template>
84
- <SPlaylistNew
85
- :loading-post-playlist="loadingPostPlaylist"
86
- @close="closePopup"
87
- @post="postPlaylist"
88
- />
89
- <slot/>
90
- </SPopup>
91
- </transition>
92
- <!-- New-->
93
115
  </template>
94
116
 
95
117
  <script setup lang="ts">
96
- import type { IPlaylistVideoFormType, ISelectItem, IPlaylistShort, IPlaylistCard, PaginatedResponse } from '../../types';
118
+ import type { IPlaylistVideoFormType, IPlaylistShort, IPlaylistCard, PaginatedResponse } from '../../types';
119
+ import { EPlaylistType } from 'itube-specs/runtime';
97
120
 
98
121
  const props = defineProps<{
99
122
  loadingPostPlaylist: boolean
@@ -102,11 +125,8 @@ const props = defineProps<{
102
125
  userPlaylists: PaginatedResponse<IPlaylistCard> | null
103
126
  }>()
104
127
 
105
- const route = useRoute();
106
-
107
128
  const emit = defineEmits<{
108
129
  (eventName: 'post-playlist', eventValue: IPlaylistShort): void
109
- (eventName: 'back-to-add'): void
110
130
  (eventName: 'execute-user-playlists'): void
111
131
  (eventName: 'save-video-to-playlist', eventValue: {
112
132
  form: IPlaylistVideoFormType
@@ -116,18 +136,31 @@ const emit = defineEmits<{
116
136
 
117
137
  const { videoCard, closePlaylistAdd, isPlaylistAdd, selectValue, step } = usePlaylistAdd();
118
138
 
119
- const playlistsItems = computed<ISelectItem[] | undefined>(() => {
120
- return props.userPlaylists?.items?.map((item) => ({
121
- title: item.name,
122
- value: item.id,
123
- }))
139
+ const { FAVORITES_KEY } = useFavorites();
140
+
141
+ const playlistsItems = computed(() => {
142
+ return props.userPlaylists?.items
143
+ ?.filter((item: IPlaylistCard) => item.name !== FAVORITES_KEY)
144
+ .map((item: IPlaylistCard) => ({
145
+ title: item.name,
146
+ value: item.id,
147
+ videosCount: item.videosCount,
148
+ }))
124
149
  })
125
150
 
126
- const id = computed(() => String(route.params['playlistId']));
127
- const activePlaylist = computed(() => playlistsItems.value?.find(item => item.value === selectValue.value)?.value);
128
- const playlistId = computed(() => activePlaylist.value ? [activePlaylist.value] : id.value);
129
151
 
130
- emit('execute-user-playlists');
152
+ function togglePlaylist(value: string) {
153
+ const idx = selectValue.value.indexOf(value);
154
+ if (idx === -1) {
155
+ selectValue.value.push(value);
156
+ } else {
157
+ selectValue.value.splice(idx, 1);
158
+ }
159
+ }
160
+
161
+ watch(isPlaylistAdd, (val) => {
162
+ if (val) emit('execute-user-playlists');
163
+ }, { immediate: true });
131
164
 
132
165
  async function closePopup() {
133
166
  step.value = 'add';
@@ -135,16 +168,38 @@ async function closePopup() {
135
168
  closePlaylistAdd();
136
169
  }
137
170
 
138
- async function postPlaylist(event: IPlaylistShort) {
139
- emit('post-playlist', event);
171
+ const newPlaylistName = ref('');
172
+ const isPrivate = ref(false);
173
+
174
+ function clearNewPlaylist() {
175
+ newPlaylistName.value = '';
140
176
  }
141
177
 
142
- async function backPopup() {
178
+ function createPlaylist() {
179
+ const name = newPlaylistName.value.trim();
180
+ if (!name) return;
181
+
182
+ const nameExists = props.userPlaylists?.items?.some(
183
+ (item: IPlaylistCard) => item.name.toLowerCase() === name.toLowerCase()
184
+ );
185
+
186
+ if (nameExists) {
187
+ snackbarTheme.value = 'error';
188
+ snackbarText.value = 'playlist_name_exists';
189
+ return;
190
+ }
191
+
192
+ emit('post-playlist', {
193
+ name,
194
+ type: isPrivate.value ? EPlaylistType.Private : EPlaylistType.Public,
195
+ });
196
+ newPlaylistName.value = '';
197
+ isPrivate.value = false;
143
198
  step.value = 'add';
144
199
  }
145
200
 
146
201
  const postVideoForm = computed(() => ({
147
- playlistId: activePlaylist.value ? [activePlaylist.value] : undefined,
202
+ playlistId: selectValue.value.length ? selectValue.value : undefined,
148
203
  videoId: videoCard.value?.id ?? '',
149
204
  VideoGUID: videoCard.value?.guid ?? '',
150
205
  videoMd5: videoCard.value?.md5 ?? '',
@@ -159,7 +214,7 @@ async function onSaveClick() {
159
214
  try {
160
215
  emit('save-video-to-playlist', {
161
216
  form: postVideoForm.value as IPlaylistVideoFormType,
162
- id: String(playlistId.value)
217
+ id: String(selectValue.value)
163
218
  })
164
219
  closePopup();
165
220
  snackbarTheme.value = 'success';
@@ -174,15 +229,9 @@ watch(
174
229
  playlistsItems,
175
230
  (items) => {
176
231
  if (!items?.length) return;
232
+ if (selectValue.value.length) return;
177
233
 
178
- const routeId = String(route.params['playlistId'] ?? '');
179
- const fromRoute = items.find(item => item.value === routeId);
180
- if (fromRoute) {
181
- selectValue.value = fromRoute.value;
182
- return;
183
- }
184
-
185
- selectValue.value = items[0].value;
234
+ selectValue.value = ['favorites'];
186
235
  },
187
236
  { immediate: true }
188
237
  );
@@ -19,7 +19,7 @@
19
19
  <SIcon
20
20
  class="s-checkbox__check-icon"
21
21
  name="check"
22
- size="20"
22
+ size="12"
23
23
  />
24
24
  </span>
25
25
  <span class="s-checkbox__label">
@@ -10,14 +10,15 @@
10
10
  {{ title }}
11
11
  </span>
12
12
  <p class="s-slider__subtitle">
13
- {{ t('between') }}:&nbsp;
14
- <span class="s-slider__range">{{ (between && between[0]) || $attrs.min }}&#8209;{{ (between && between[1]) || $attrs.max}}</span>
13
+ <span class="s-slider__range">{{ Array.isArray(displayValue) ? `${displayValue[0]}–${displayValue[1]}` : displayValue }}</span>
15
14
  </p>
16
15
  </div>
17
16
  <div class="s-slider__wrapper">
18
17
  <Slider
19
18
  v-model="model"
20
19
  v-bind="$attrs"
20
+ :tooltips="false"
21
+ @slide="(val: number | number[]) => displayValue = val"
21
22
  />
22
23
  </div>
23
24
  </div>
@@ -26,8 +27,10 @@
26
27
  <script setup lang="ts">
27
28
  import Slider from '@vueform/slider';
28
29
 
29
- const {t} = useI18n();
30
30
  const model = defineModel<number | number[]>();
31
+ const displayValue = ref<number | number[]>(model.value ?? 0)
32
+
33
+ watch(model, (val) => { if (val !== undefined) displayValue.value = val })
31
34
 
32
35
  defineProps<{
33
36
  title: string
@@ -3,23 +3,26 @@
3
3
  class="s-snackbar"
4
4
  :class="`--${snackbarTheme}`"
5
5
  >
6
- <SIcon class="s-snackbar__icon" :name="snackbarIcon" size="24"/>
6
+ <div class="s-snackbar__icon-wrapper">
7
+ <SIcon class="s-snackbar__icon" :name="snackbarIcon" size="16"/>
8
+ </div>
7
9
  {{
8
10
  te(`snackbar.${convertSnackKey}`)
9
11
  ? t(`snackbar.${convertSnackKey}`)
10
12
  : t('snackbar.something_went_wrong')
11
13
  }}
12
- <SButton
13
- theme="secondary"
14
- size="s"
14
+ <button
15
+ class="s-snackbar__close"
16
+ type="button"
15
17
  @click="close"
16
- >{{ t('close') }}
17
- </SButton>
18
+ >
19
+ <SIcon name="close" size="16" />
20
+ </button>
18
21
  </div>
19
22
  </template>
20
23
 
21
24
  <script setup lang="ts">
22
- import { convertString } from '../../runtime';
25
+ import { convertString } from 'itube-specs/runtime';
23
26
 
24
27
  const { snackbarText, snackbarIcon, snackbarTheme, snackbarTimer } = useSnackbar();
25
28
 
@@ -13,20 +13,26 @@
13
13
 
14
14
  <slot/>
15
15
 
16
+ <span class="s-timestamp__time _from-sm">
17
+ {{ time }}
18
+ </span>
19
+
16
20
  <SIcon
17
21
  class="s-timestamp__icon"
18
22
  name="forward-step"
19
- size="16"
23
+ size="14"
20
24
  />
21
25
  </button>
22
26
  </template>
23
27
 
24
28
  <script setup lang="ts">
25
- import type { IVttItem } from "../../runtime/utils/vtt-helper";
29
+ import type { IVttItem } from 'itube-specs/runtime/utils/vtt-helper';
30
+ import { formatTime } from 'itube-specs/runtime';
26
31
 
27
32
  const props = defineProps<{
28
33
  pictureUrl: string,
29
34
  coords: IVttItem['coords'];
35
+ duration: number;
30
36
  }>();
31
37
 
32
38
  const picture = `url(${props.pictureUrl})`;
@@ -38,6 +44,8 @@ const emit = defineEmits<{
38
44
  (eventName: 'click'): void
39
45
  }>();
40
46
 
47
+ const time = computed(() => formatTime(props.duration))
48
+
41
49
  function onButtonClick() {
42
50
  emit('click')
43
51
  }
@@ -4,19 +4,8 @@ export function useApiFetcher<T>(
4
4
  ...apiArgs: any[]
5
5
  ) {
6
6
  return async function fetchData() {
7
- const hasKey = key?.trim();
8
- const state = hasKey ? useState<T | null>(key!, () => null) : null;
9
-
10
- if (state && state.value !== null && state.value !== undefined) {
11
- return state.value;
12
- }
13
-
14
7
  try {
15
- const result = await apiMethod(...apiArgs);
16
-
17
- if (state) state.value = result;
18
-
19
- return result;
8
+ return await apiMethod(...apiArgs);
20
9
  } catch (err) {
21
10
  throw createError({
22
11
  statusCode: 500,
@@ -0,0 +1,19 @@
1
+ export const FAVORITES_KEY = 'favorites';
2
+
3
+ export const useFavorites = () => {
4
+ const favoritesId = useState<string | null>('favorites-playlist-id', () => null);
5
+ const favoritesFirstVideoId = useState<string | null>('favorites-first-video-id', () => null);
6
+
7
+ const favoritesLink = computed(() =>
8
+ favoritesId.value && favoritesFirstVideoId.value
9
+ ? `/playlists/${favoritesId.value}/${favoritesFirstVideoId.value}`
10
+ : null
11
+ );
12
+
13
+ return {
14
+ FAVORITES_KEY,
15
+ favoritesId,
16
+ favoritesFirstVideoId,
17
+ favoritesLink,
18
+ };
19
+ };
@@ -0,0 +1,84 @@
1
+ import { FeatureFlags } from '~/config/featureFlags.config';
2
+
3
+ export function useNavigationItems() {
4
+ const { favoritesLink } = useFavorites();
5
+
6
+ const navigationItems = computed(() => {
7
+ const favLink = favoritesLink.value ?? '';
8
+
9
+ return [
10
+ {
11
+ title: 'videos',
12
+ link: '/',
13
+ icon: 'video-camera',
14
+ },
15
+ {
16
+ title: 'categories',
17
+ link: '/categories',
18
+ icon: 'grid',
19
+ },
20
+ {
21
+ title: 'channels',
22
+ link: '/channels',
23
+ icon: 'play-circle',
24
+ },
25
+ {
26
+ title: 'gay_models',
27
+ link: '/models',
28
+ icon: 'user',
29
+ },
30
+ {
31
+ title: 'playlists',
32
+ link: '/playlists',
33
+ icon: 'list',
34
+ },
35
+ ...(FeatureFlags.FeatureLiveEnabled ? [{
36
+ title: 'live_sex',
37
+ link: '/2257',
38
+ icon: 'photo-cam',
39
+ accent: true,
40
+ }] : []),
41
+ ...(FeatureFlags.FeatureHistoryEnabled ? [{
42
+ title: 'history',
43
+ link: '/2257',
44
+ icon: 'time',
45
+ top: true,
46
+ }] : []),
47
+ ...(FeatureFlags.FeatureFavoritesEnabled ? [{
48
+ title: 'favorites',
49
+ link: favLink,
50
+ icon: 'heart',
51
+ top: true,
52
+ childs: [
53
+ {
54
+ title: 'videos',
55
+ icon: 'video-camera',
56
+ link: favLink,
57
+ },
58
+ {
59
+ title: 'watch_later',
60
+ icon: 'time',
61
+ link: '/2257',
62
+ },
63
+ {
64
+ title: 'playlists',
65
+ icon: 'list',
66
+ link: '/2257',
67
+ },
68
+ {
69
+ title: 'models',
70
+ icon: 'user',
71
+ link: '/2257',
72
+ },
73
+ {
74
+ title: 'channels',
75
+ icon: 'play-circle',
76
+ link: '/2257',
77
+ },
78
+ ],
79
+ }] : []),
80
+ ];
81
+ });
82
+
83
+ return { navigationItems };
84
+ }
package/package.json CHANGED
@@ -1,7 +1,7 @@
1
1
  {
2
2
  "name": "itube-specs",
3
3
  "type": "module",
4
- "version": "0.0.607",
4
+ "version": "0.0.609",
5
5
  "main": "./nuxt.config.ts",
6
6
  "types": "./types/index.d.ts",
7
7
  "scripts": {