itube-specs 0.0.195 → 0.0.205
This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
- package/components/page-components/f-chips-panel.vue +1 -1
- package/components/page-components/f-filter-button.vue +68 -0
- package/components/page-components/f-filter-chips.vue +32 -0
- package/components/page-components/f-filter-model-chips.vue +89 -0
- package/components/page-components/f-filter-page.vue +229 -0
- package/components/page-components/f-filter-popup.vue +166 -0
- package/components/page-components/f-filter-videos-chips.vue +96 -0
- package/components/page-components/f-filter.vue +72 -0
- package/components/page-components/f-model-filters.vue +84 -0
- package/package.json +2 -2
|
@@ -0,0 +1,68 @@
|
|
|
1
|
+
<template>
|
|
2
|
+
<FButton
|
|
3
|
+
class="f-filter-button"
|
|
4
|
+
:size="size"
|
|
5
|
+
:theme="theme"
|
|
6
|
+
aria-label="Filter"
|
|
7
|
+
:title="t('filter')"
|
|
8
|
+
@click="emit('update:modelValue', true)"
|
|
9
|
+
>
|
|
10
|
+
<FIcon name="adjustments-horizontal" size="16"/>
|
|
11
|
+
<span class="_from-sm"
|
|
12
|
+
>{{ buttonName }}
|
|
13
|
+
</span>
|
|
14
|
+
<FIcon
|
|
15
|
+
class="f-filter-button__icon _to-sm"
|
|
16
|
+
name="chevron-down"
|
|
17
|
+
size="16"
|
|
18
|
+
:class="{'--open': modelValue}"
|
|
19
|
+
/>
|
|
20
|
+
<FIcon
|
|
21
|
+
v-if="count > 0"
|
|
22
|
+
class="f-filter-button__reset _from-sm"
|
|
23
|
+
name="close"
|
|
24
|
+
size="16"
|
|
25
|
+
aria-label="reset-filter"
|
|
26
|
+
role="button"
|
|
27
|
+
@click.stop="resetFilter"
|
|
28
|
+
/>
|
|
29
|
+
<span
|
|
30
|
+
v-if="count > 0"
|
|
31
|
+
class="f-filter-button__mobile-count _to-sm"
|
|
32
|
+
>{{ mobileCount }}</span>
|
|
33
|
+
</FButton>
|
|
34
|
+
</template>
|
|
35
|
+
|
|
36
|
+
<script setup lang="ts">
|
|
37
|
+
import type { ButtonSizes, ButtonThemes } from '~';
|
|
38
|
+
|
|
39
|
+
const props = defineProps<{
|
|
40
|
+
count: number
|
|
41
|
+
modelValue: boolean
|
|
42
|
+
size?: ButtonSizes
|
|
43
|
+
theme?: ButtonThemes
|
|
44
|
+
}>()
|
|
45
|
+
|
|
46
|
+
const emit = defineEmits<{
|
|
47
|
+
(eventName: 'update:modelValue', value: Boolean): void
|
|
48
|
+
}>()
|
|
49
|
+
|
|
50
|
+
const { t } = useI18n()
|
|
51
|
+
|
|
52
|
+
const buttonName = computed(() => {
|
|
53
|
+
return props.count > 0 ? t('plural.item', props.count) : t('filter');
|
|
54
|
+
})
|
|
55
|
+
|
|
56
|
+
function resetFilter() {
|
|
57
|
+
router.push({
|
|
58
|
+
query: {}
|
|
59
|
+
})
|
|
60
|
+
}
|
|
61
|
+
|
|
62
|
+
const mobileCount = computed(() => props.count > 9 ? '9+' : props.count);
|
|
63
|
+
|
|
64
|
+
const router = useRouter()
|
|
65
|
+
</script>
|
|
66
|
+
|
|
67
|
+
<style scoped lang="scss">
|
|
68
|
+
</style>
|
|
@@ -0,0 +1,32 @@
|
|
|
1
|
+
<template>
|
|
2
|
+
<FChipsPanel v-if="items.length > 0">
|
|
3
|
+
<FChips
|
|
4
|
+
v-for="(item, index) in items"
|
|
5
|
+
class="f-filter-chips"
|
|
6
|
+
with-close
|
|
7
|
+
:index="`f-filter-page-chips${index}`"
|
|
8
|
+
bright
|
|
9
|
+
:item="item"
|
|
10
|
+
@click="onChipsClick(item)"
|
|
11
|
+
></FChips>
|
|
12
|
+
</FChipsPanel>
|
|
13
|
+
</template>
|
|
14
|
+
|
|
15
|
+
<script setup lang="ts">
|
|
16
|
+
import type { IChipsItem } from '../../types';
|
|
17
|
+
|
|
18
|
+
defineProps<{
|
|
19
|
+
items: IChipsItem[]
|
|
20
|
+
}>();
|
|
21
|
+
|
|
22
|
+
const emit = defineEmits<{
|
|
23
|
+
(eventName: 'click', eventValue: IChipsItem): void
|
|
24
|
+
}>()
|
|
25
|
+
|
|
26
|
+
function onChipsClick(item: IChipsItem) {
|
|
27
|
+
emit('click', item)
|
|
28
|
+
}
|
|
29
|
+
</script>
|
|
30
|
+
|
|
31
|
+
<style scoped lang="scss">
|
|
32
|
+
</style>
|
|
@@ -0,0 +1,89 @@
|
|
|
1
|
+
<template>
|
|
2
|
+
<FFilterChips
|
|
3
|
+
:items="chipsItems"
|
|
4
|
+
@click="(e: IChipsItem)=> onChipsClick(e)"
|
|
5
|
+
/>
|
|
6
|
+
</template>
|
|
7
|
+
|
|
8
|
+
<script setup lang="ts">
|
|
9
|
+
import type { IChipsItem, IModelFilter } from '../../types';
|
|
10
|
+
import type { LocationQueryValue } from '#vue-router';
|
|
11
|
+
import { useRoute, useRouter } from '#vue-router';
|
|
12
|
+
import { getMonth } from '../../runtime';
|
|
13
|
+
|
|
14
|
+
const props = defineProps<{
|
|
15
|
+
filters: IModelFilter[]
|
|
16
|
+
}>();
|
|
17
|
+
|
|
18
|
+
const { t } = useI18n();
|
|
19
|
+
const route = useRoute();
|
|
20
|
+
const router = useRouter();
|
|
21
|
+
|
|
22
|
+
const chipsItems = computed(() => {
|
|
23
|
+
const queryItems = Object.keys(route.query)
|
|
24
|
+
.filter(item => item.startsWith('filter'))
|
|
25
|
+
.map(item => item.replace('filter_', '')
|
|
26
|
+
.replace(/_/g, ' '));
|
|
27
|
+
|
|
28
|
+
const groups = Object.values(
|
|
29
|
+
queryItems.reduce((acc: Record<string, string[]>, str: string) => {
|
|
30
|
+
const parts = str.split(' ');
|
|
31
|
+
const last = parts.at(-1);
|
|
32
|
+
const key = ['from', 'to'].includes(last)
|
|
33
|
+
? parts.slice(0, -1).join(' ')
|
|
34
|
+
: str;
|
|
35
|
+
|
|
36
|
+
acc[ key ] ??= [];
|
|
37
|
+
|
|
38
|
+
if (last === 'from') acc[ key ].unshift(str);
|
|
39
|
+
else acc[ key ].push(str);
|
|
40
|
+
|
|
41
|
+
return acc;
|
|
42
|
+
}, {})
|
|
43
|
+
)
|
|
44
|
+
|
|
45
|
+
const title = (item: string, index: number) => {
|
|
46
|
+
const filter = props.filters.find(filter => {
|
|
47
|
+
return filter.name === `${item.replace(/ /g, '_')
|
|
48
|
+
.replace('_from', '')
|
|
49
|
+
.replace('_to', '')
|
|
50
|
+
}`
|
|
51
|
+
});
|
|
52
|
+
const defaultValue = index === 0 ? filter?.options[ 0 ] : filter?.options[ filter.options.length - 1 ]
|
|
53
|
+
const value: LocationQueryValue | LocationQueryValue[] = route.query[ `filter_${item.replace(/ /g, '_')}` ];
|
|
54
|
+
if (item.split(' ').some(item => item === 'month')) {
|
|
55
|
+
return getMonth(t, Number(value) - 1 || Number(defaultValue) - 1)
|
|
56
|
+
}
|
|
57
|
+
return value || Number(defaultValue) - 1;
|
|
58
|
+
}
|
|
59
|
+
|
|
60
|
+
const text = (item: string[]) => [...new Set(item.map((subItem, index) => title(subItem, index)))].join(' - ');
|
|
61
|
+
|
|
62
|
+
const chipsTitle = ((item: string[]) => {
|
|
63
|
+
const key = Array.isArray(item) ? item[0].replace(' from', '') : item;
|
|
64
|
+
const convertedKey = props.filters.find(item => item.name.replace(/_/g, ' ') === key)?.title;
|
|
65
|
+
const textValue = `${convertedKey}: ${text(item)}`;
|
|
66
|
+
return textValue.length > 30 ? text(item) : textValue;
|
|
67
|
+
})
|
|
68
|
+
|
|
69
|
+
return groups.map((item) => ({
|
|
70
|
+
title: chipsTitle(item),
|
|
71
|
+
value: item.map(subItem => `filter_${subItem.replace(/ /g, '_')}`),
|
|
72
|
+
}));
|
|
73
|
+
})
|
|
74
|
+
|
|
75
|
+
function onChipsClick(item: IChipsItem) {
|
|
76
|
+
const query = { ...route.query };
|
|
77
|
+
if (Array.isArray(item.value) && item.value.length > 1) {
|
|
78
|
+
item.value.forEach((text: string) => {
|
|
79
|
+
delete query[ `${text}` ]
|
|
80
|
+
})
|
|
81
|
+
} else {
|
|
82
|
+
delete query[ `${item.value}` ]
|
|
83
|
+
}
|
|
84
|
+
router.replace({ query });
|
|
85
|
+
}
|
|
86
|
+
</script>
|
|
87
|
+
|
|
88
|
+
<style scoped lang="scss">
|
|
89
|
+
</style>
|
|
@@ -0,0 +1,229 @@
|
|
|
1
|
+
<template>
|
|
2
|
+
<div class="f-filter-page">
|
|
3
|
+
<div class="f-filter-page__items-wrapper">
|
|
4
|
+
<details
|
|
5
|
+
v-for="(group, index) in groups"
|
|
6
|
+
:key="`filter-group-${index}`"
|
|
7
|
+
class="f-filter-page__group"
|
|
8
|
+
:open="index === 0 || activeGroup === index + 1"
|
|
9
|
+
>
|
|
10
|
+
<summary
|
|
11
|
+
v-if="filters.length > 0"
|
|
12
|
+
class="f-filter-page__group-title"
|
|
13
|
+
>
|
|
14
|
+
{{ group?.title }}
|
|
15
|
+
<FIcon
|
|
16
|
+
class="f-filter-page__group-title-icon"
|
|
17
|
+
size="32"
|
|
18
|
+
name="chevron-down"
|
|
19
|
+
/>
|
|
20
|
+
</summary>
|
|
21
|
+
<div class="f-filter-page__items">
|
|
22
|
+
<template v-for="(item, subIndex) in filters">
|
|
23
|
+
<ClientOnly>
|
|
24
|
+
<FSlider
|
|
25
|
+
v-if="(item.kind === 'range') && (item.group.name === group?.name)"
|
|
26
|
+
:min="getRangeInfo(item.options)?.min"
|
|
27
|
+
:max="getRangeInfo(item.options)?.max"
|
|
28
|
+
:step="getRangeInfo(item.options)?.step"
|
|
29
|
+
:model-value="[
|
|
30
|
+
minRangeValue(item),
|
|
31
|
+
maxRangeValue(item)
|
|
32
|
+
]"
|
|
33
|
+
:title="item.title"
|
|
34
|
+
:key="`model-filter-range-${subIndex}`"
|
|
35
|
+
:active="isActiveSelect(item.name)"
|
|
36
|
+
:format="formatterSlider(item.options)"
|
|
37
|
+
:between="betweenText(item.options, getRangeInfo(item.options)?.min, getRangeInfo(item.options)?.max)"
|
|
38
|
+
@update:model-value="val => updateRangeFilter(item, val)"
|
|
39
|
+
/>
|
|
40
|
+
</ClientOnly>
|
|
41
|
+
<FSelect
|
|
42
|
+
v-if="(item.kind === 'select') && (item.group.name === group?.name)"
|
|
43
|
+
:name="item.name"
|
|
44
|
+
:model-value="getSelectValue(item.name)"
|
|
45
|
+
:items="selectItems(item)"
|
|
46
|
+
:count="getCount(item)"
|
|
47
|
+
:active="isActiveSelect(item.name)"
|
|
48
|
+
:key="`model-filter-select-${subIndex}`"
|
|
49
|
+
:label="item.title"
|
|
50
|
+
@update:model-value="val => updateSelectFilter(item.name, val)"
|
|
51
|
+
/>
|
|
52
|
+
</template>
|
|
53
|
+
</div>
|
|
54
|
+
</details>
|
|
55
|
+
</div>
|
|
56
|
+
<div class="f-filter-page__footer _from-sm">
|
|
57
|
+
<p class="f-filter-page__footer-results">
|
|
58
|
+
<FIcon name="filter" size="24"/>
|
|
59
|
+
<span class="f-filter-page__footer-text">{{ `${count} ${t('results_found')}` }}</span>
|
|
60
|
+
</p>
|
|
61
|
+
<div class="f-filter-page__footer-buttons">
|
|
62
|
+
<FButton
|
|
63
|
+
wide
|
|
64
|
+
class="f-filter-page__reset"
|
|
65
|
+
:disabled="!isFiltered"
|
|
66
|
+
@click="onResetClick"
|
|
67
|
+
theme="secondary"
|
|
68
|
+
>{{ t('reset_all') }}
|
|
69
|
+
</FButton>
|
|
70
|
+
</div>
|
|
71
|
+
</div>
|
|
72
|
+
</div>
|
|
73
|
+
</template>
|
|
74
|
+
|
|
75
|
+
<script setup lang="ts">
|
|
76
|
+
import type { IModelFilter, IModelFilterOptions } from '../../types';
|
|
77
|
+
|
|
78
|
+
import { useRoute, useRouter } from 'vue-router';
|
|
79
|
+
import { getMonth } from '../../runtime';
|
|
80
|
+
|
|
81
|
+
const props = defineProps<{
|
|
82
|
+
filters: IModelFilter[]
|
|
83
|
+
count: number
|
|
84
|
+
}>();
|
|
85
|
+
|
|
86
|
+
const { t } = useI18n();
|
|
87
|
+
const route = useRoute();
|
|
88
|
+
const router = useRouter();
|
|
89
|
+
|
|
90
|
+
const activeGroup = computed(() => Number(route.query[ 'group' ]));
|
|
91
|
+
|
|
92
|
+
const groups = computed(() => {
|
|
93
|
+
const uniqueNames = [...new Set(props.filters.map(item => item.group.title))];
|
|
94
|
+
return uniqueNames.map(name => {
|
|
95
|
+
return props.filters.find(filter => filter.group.title === name)?.group;
|
|
96
|
+
}).sort((a, b) => a?.order - b?.order)
|
|
97
|
+
});
|
|
98
|
+
|
|
99
|
+
const isFiltered = computed(() => Object.keys(route.query).some(item => item.startsWith('filter')));
|
|
100
|
+
|
|
101
|
+
function selectItems(item: IModelFilter) {
|
|
102
|
+
return [
|
|
103
|
+
...item.options.map(item => ({
|
|
104
|
+
value: item.name,
|
|
105
|
+
title: `${item.title} (${item.quantity})`,
|
|
106
|
+
}))
|
|
107
|
+
]
|
|
108
|
+
}
|
|
109
|
+
|
|
110
|
+
function onResetClick() {
|
|
111
|
+
router.replace({ query: {} });
|
|
112
|
+
}
|
|
113
|
+
|
|
114
|
+
function getRangeInfo(options: { name: string; quantity: number; title: string }[]): {
|
|
115
|
+
min: number,
|
|
116
|
+
step: number,
|
|
117
|
+
max: number
|
|
118
|
+
} | null {
|
|
119
|
+
const numericValues = options
|
|
120
|
+
.map(opt => Number(opt.name))
|
|
121
|
+
.filter(v => !isNaN(v))
|
|
122
|
+
.sort((a, b) => a - b);
|
|
123
|
+
|
|
124
|
+
if (numericValues.length === 0) {
|
|
125
|
+
return null;
|
|
126
|
+
}
|
|
127
|
+
|
|
128
|
+
const min = numericValues[ 0 ] || 0;
|
|
129
|
+
const max = numericValues[ numericValues.length - 1 ] || 100;
|
|
130
|
+
const step = numericValues.length > 1 ? 1 : 0;
|
|
131
|
+
|
|
132
|
+
return { min, step, max };
|
|
133
|
+
}
|
|
134
|
+
|
|
135
|
+
function updateRangeFilter(item: IModelFilter, val: string[] | number[]) {
|
|
136
|
+
const rangeInfo = getRangeInfo(item.options);
|
|
137
|
+
if (!rangeInfo) return;
|
|
138
|
+
const [from, to] = val;
|
|
139
|
+
const query = { ...route.query };
|
|
140
|
+
|
|
141
|
+
query[ `filter_${item.name}_from` ] = from;
|
|
142
|
+
query[ `filter_${item.name}_to` ] = to;
|
|
143
|
+
|
|
144
|
+
if (from === rangeInfo.min && to === rangeInfo.max) {
|
|
145
|
+
delete query[ `filter_${item.name}_from` ];
|
|
146
|
+
delete query[ `filter_${item.name}_to` ];
|
|
147
|
+
}
|
|
148
|
+
|
|
149
|
+
router.replace({ query });
|
|
150
|
+
}
|
|
151
|
+
|
|
152
|
+
function updateSelectFilter(name: string, val: any) {
|
|
153
|
+
const query = { ...route.query };
|
|
154
|
+
|
|
155
|
+
if (!val || val === 'all') {
|
|
156
|
+
delete query[ `filter_${name}` ];
|
|
157
|
+
} else {
|
|
158
|
+
query[ `filter_${name}` ] = val;
|
|
159
|
+
}
|
|
160
|
+
|
|
161
|
+
router.replace({ query });
|
|
162
|
+
}
|
|
163
|
+
|
|
164
|
+
function isActiveSelect(name: string) {
|
|
165
|
+
const baseName = name.replace(/_(from|to)$/, '');
|
|
166
|
+
return Object.keys(route.query).some(key => key.startsWith(`filter_${baseName}`));
|
|
167
|
+
}
|
|
168
|
+
|
|
169
|
+
function getSelectValue(name: string) {
|
|
170
|
+
const value = route.query[ `filter_${name}` ];
|
|
171
|
+
if (value) {
|
|
172
|
+
return value;
|
|
173
|
+
}
|
|
174
|
+
const filter = props.filters.find(f => f.name === name);
|
|
175
|
+
if (filter && filter.options.length > 0) {
|
|
176
|
+
return filter.options[ 0 ].name;
|
|
177
|
+
}
|
|
178
|
+
return null;
|
|
179
|
+
}
|
|
180
|
+
|
|
181
|
+
function getRangeValue(name: string, defaultValue: number | undefined) {
|
|
182
|
+
const value = route.query[ `filter_${name}` ];
|
|
183
|
+
if (value !== undefined) {
|
|
184
|
+
return Number(value);
|
|
185
|
+
} else {
|
|
186
|
+
return defaultValue;
|
|
187
|
+
}
|
|
188
|
+
}
|
|
189
|
+
|
|
190
|
+
function getCount(item: IModelFilter) {
|
|
191
|
+
const selectedValue = getSelectValue(item.name);
|
|
192
|
+
const targetValue = selectedValue !== null && selectedValue !== undefined && selectedValue !== '' ? selectedValue : 'all';
|
|
193
|
+
return props.filters.find(subitem => subitem.name === item.name)?.options.find(subitem => subitem.name === targetValue)?.quantity;
|
|
194
|
+
}
|
|
195
|
+
|
|
196
|
+
function minRangeValue(item: IModelFilter) {
|
|
197
|
+
return getRangeValue(`${item.name}_from`, getRangeInfo(item.options)?.min)
|
|
198
|
+
}
|
|
199
|
+
|
|
200
|
+
function maxRangeValue(item: IModelFilter) {
|
|
201
|
+
return getRangeValue(`${item.name}_to`, getRangeInfo(item.options)?.max)
|
|
202
|
+
}
|
|
203
|
+
|
|
204
|
+
function isSliderHasTitle(options: IModelFilterOptions[] | undefined): boolean | undefined {
|
|
205
|
+
return options && options[ 0 ] && isNaN(Number(options[ 0 ].title));
|
|
206
|
+
}
|
|
207
|
+
|
|
208
|
+
function formatterSlider(options: IModelFilterOptions[]) {
|
|
209
|
+
return (event: number) => {
|
|
210
|
+
if (isSliderHasTitle(options)) {
|
|
211
|
+
return getMonth(t, Math.round(event - 1));
|
|
212
|
+
} else {
|
|
213
|
+
return Math.round(event);
|
|
214
|
+
}
|
|
215
|
+
}
|
|
216
|
+
}
|
|
217
|
+
|
|
218
|
+
function betweenText(options: IModelFilterOptions[], min: number | undefined, max: number | undefined): string[] | undefined {
|
|
219
|
+
if (isSliderHasTitle(options)) {
|
|
220
|
+
const getText = (number: number | undefined): string | undefined => {
|
|
221
|
+
return options.find((item: IModelFilterOptions) => Number(item.name) === number)?.title;
|
|
222
|
+
}
|
|
223
|
+
return [getText(min) || '', getText(max) || ''];
|
|
224
|
+
}
|
|
225
|
+
}
|
|
226
|
+
</script>
|
|
227
|
+
|
|
228
|
+
<style scoped lang="scss">
|
|
229
|
+
</style>
|
|
@@ -0,0 +1,166 @@
|
|
|
1
|
+
<template>
|
|
2
|
+
<FPopup
|
|
3
|
+
v-model="isOpen"
|
|
4
|
+
v-if="isOpen"
|
|
5
|
+
sheet
|
|
6
|
+
@close="closePopup"
|
|
7
|
+
>
|
|
8
|
+
<template #title>{{ filterTitle }}</template>
|
|
9
|
+
|
|
10
|
+
<template #fixedContent>
|
|
11
|
+
<div class="f-filter-popup__main-filters">
|
|
12
|
+
<FSelect
|
|
13
|
+
name="duration"
|
|
14
|
+
v-model="durationValue"
|
|
15
|
+
:items="durationItems"
|
|
16
|
+
:placeholder="t('duration')"
|
|
17
|
+
icon="tape"
|
|
18
|
+
/>
|
|
19
|
+
<FSelect
|
|
20
|
+
name="added"
|
|
21
|
+
v-model="addedValue"
|
|
22
|
+
:items="addedItems"
|
|
23
|
+
:placeholder="t('added')"
|
|
24
|
+
icon="clock"
|
|
25
|
+
/>
|
|
26
|
+
</div>
|
|
27
|
+
</template>
|
|
28
|
+
|
|
29
|
+
<div class="f-filter-popup__filters">
|
|
30
|
+
<template
|
|
31
|
+
v-for="(item, index) in scheme"
|
|
32
|
+
:key="`scheme-${index}`"
|
|
33
|
+
>
|
|
34
|
+
<FInput
|
|
35
|
+
v-if="item.type === 'input'"
|
|
36
|
+
v-model="filterValue[item.title]"
|
|
37
|
+
:label="item?.label"
|
|
38
|
+
/>
|
|
39
|
+
<FSelect
|
|
40
|
+
v-if="item.type === 'select'"
|
|
41
|
+
:name="item.title"
|
|
42
|
+
v-model="filterValue[item.title]"
|
|
43
|
+
:items="item.items"
|
|
44
|
+
:label="item?.label"
|
|
45
|
+
:placeholder="item.placeholder"
|
|
46
|
+
/>
|
|
47
|
+
</template>
|
|
48
|
+
</div>
|
|
49
|
+
|
|
50
|
+
<template #footer>
|
|
51
|
+
<div class="f-filter-popup__buttons">
|
|
52
|
+
<FButton
|
|
53
|
+
theme="secondary"
|
|
54
|
+
wide
|
|
55
|
+
:disabled="!isFiltered"
|
|
56
|
+
@click="reset"
|
|
57
|
+
>{{ t('reset_all') }}
|
|
58
|
+
</FButton>
|
|
59
|
+
<FButton
|
|
60
|
+
wide
|
|
61
|
+
@click="saveResult"
|
|
62
|
+
>{{ t('apply') }}
|
|
63
|
+
</FButton>
|
|
64
|
+
</div>
|
|
65
|
+
</template>
|
|
66
|
+
</FPopup>
|
|
67
|
+
</template>
|
|
68
|
+
|
|
69
|
+
<script setup lang="ts">
|
|
70
|
+
import type { FilterSchemeType, ISelectItem } from '../../types';
|
|
71
|
+
|
|
72
|
+
const { t } = useI18n()
|
|
73
|
+
|
|
74
|
+
const props = defineProps<{
|
|
75
|
+
addedItems: ISelectItem[]
|
|
76
|
+
durationItems: ISelectItem[]
|
|
77
|
+
modelValue: boolean
|
|
78
|
+
scheme: FilterSchemeType
|
|
79
|
+
count: number
|
|
80
|
+
}>()
|
|
81
|
+
|
|
82
|
+
const durationValue = ref('');
|
|
83
|
+
const addedValue = ref('');
|
|
84
|
+
const filterValue = ref({} as Record<string, string>);
|
|
85
|
+
|
|
86
|
+
const route = useRoute();
|
|
87
|
+
|
|
88
|
+
const categoriesFromQuery = route.query.categories
|
|
89
|
+
? (typeof route.query.categories === 'string'
|
|
90
|
+
? route.query.categories.split(',')
|
|
91
|
+
: route.query.categories.flatMap(cat => cat.split(',')))
|
|
92
|
+
: [];
|
|
93
|
+
|
|
94
|
+
categoriesFromQuery.forEach(str => {
|
|
95
|
+
const [key, ...rest] = str.split('_');
|
|
96
|
+
if (key && rest.length > 0) {
|
|
97
|
+
filterValue.value[key] = rest.join('_');
|
|
98
|
+
}
|
|
99
|
+
});
|
|
100
|
+
|
|
101
|
+
durationValue.value = route.query['duration'] as string || '';
|
|
102
|
+
addedValue.value = route.query['added'] as string || '';
|
|
103
|
+
|
|
104
|
+
const emit = defineEmits<{
|
|
105
|
+
(eventName: 'update:modelValue', value: boolean): void
|
|
106
|
+
}>()
|
|
107
|
+
|
|
108
|
+
const isOpen = ref(props.modelValue)
|
|
109
|
+
|
|
110
|
+
function closePopup() {
|
|
111
|
+
isOpen.value = false;
|
|
112
|
+
emit('update:modelValue', isOpen.value)
|
|
113
|
+
}
|
|
114
|
+
|
|
115
|
+
const router = useRouter();
|
|
116
|
+
|
|
117
|
+
function saveResult() {
|
|
118
|
+
const oldQuery = { ...route.query };
|
|
119
|
+
delete oldQuery['page'];
|
|
120
|
+
|
|
121
|
+
const mainFiltersValue = {
|
|
122
|
+
...(durationValue.value ? { duration: durationValue.value } : {}),
|
|
123
|
+
...(addedValue.value ? { added: addedValue.value } : {})
|
|
124
|
+
};
|
|
125
|
+
|
|
126
|
+
const preserved = {
|
|
127
|
+
...mainFiltersValue,
|
|
128
|
+
...(oldQuery.sort ? { sort: oldQuery.sort } : {}),
|
|
129
|
+
...(oldQuery.page ? { page: oldQuery.page } : {})
|
|
130
|
+
};
|
|
131
|
+
|
|
132
|
+
const categories = Object.entries(filterValue.value)
|
|
133
|
+
.filter(([, value]) => typeof value === 'string' && value.length > 0)
|
|
134
|
+
.map(([key, value]) => `${key}_${value}`);
|
|
135
|
+
|
|
136
|
+
const categoriesString = categories.join(',');
|
|
137
|
+
|
|
138
|
+
router.push({
|
|
139
|
+
query: {
|
|
140
|
+
...preserved,
|
|
141
|
+
categories: categories.length ? categoriesString : undefined,
|
|
142
|
+
}
|
|
143
|
+
});
|
|
144
|
+
|
|
145
|
+
closePopup();
|
|
146
|
+
}
|
|
147
|
+
|
|
148
|
+
function reset() {
|
|
149
|
+
closePopup();
|
|
150
|
+
router.push({
|
|
151
|
+
query: {
|
|
152
|
+
categories: null,
|
|
153
|
+
}
|
|
154
|
+
});
|
|
155
|
+
}
|
|
156
|
+
|
|
157
|
+
const filterTitle = computed(() => {
|
|
158
|
+
const hasDuration = durationValue.value ? 1 : 0;
|
|
159
|
+
const hasAdded = addedValue.value ? 1 : 0;
|
|
160
|
+
const count = Object.keys(filterValue.value).length + hasAdded + hasDuration;
|
|
161
|
+
const filterText = count > 1 ? t('filters') : t('filter');
|
|
162
|
+
return count > 0 ? `${filterText}: ${count}` : t('filter')
|
|
163
|
+
})
|
|
164
|
+
|
|
165
|
+
const isFiltered = computed(() => !!route.query['categories']);
|
|
166
|
+
</script>
|
|
@@ -0,0 +1,96 @@
|
|
|
1
|
+
<template>
|
|
2
|
+
<FFilterChips
|
|
3
|
+
:items="chipsItems"
|
|
4
|
+
@click="(e: IChipsItem) => onChipsClick(e)"
|
|
5
|
+
/>
|
|
6
|
+
</template>
|
|
7
|
+
|
|
8
|
+
<script setup lang="ts">
|
|
9
|
+
import type { IChipsItem, ISelectItem } from '../../types';
|
|
10
|
+
|
|
11
|
+
const route = useRoute();
|
|
12
|
+
const router = useRouter();
|
|
13
|
+
|
|
14
|
+
const props = defineProps<{
|
|
15
|
+
addedItems: ISelectItem[]
|
|
16
|
+
durationItems: ISelectItem[]
|
|
17
|
+
}>()
|
|
18
|
+
|
|
19
|
+
const chipsItems = computed(() => {
|
|
20
|
+
const items: IChipsItem[] = [];
|
|
21
|
+
|
|
22
|
+
// categories
|
|
23
|
+
const categoriesParam = route.query.categories;
|
|
24
|
+
if (categoriesParam) {
|
|
25
|
+
const categories = Array.isArray(categoriesParam)
|
|
26
|
+
? categoriesParam.flatMap(c => c.split(','))
|
|
27
|
+
: categoriesParam.split(',');
|
|
28
|
+
categories.forEach(cat => {
|
|
29
|
+
const title = cat.split('_')[1] ?? cat;
|
|
30
|
+
items.push({
|
|
31
|
+
title,
|
|
32
|
+
value: cat,
|
|
33
|
+
key: 'categories'
|
|
34
|
+
});
|
|
35
|
+
});
|
|
36
|
+
}
|
|
37
|
+
|
|
38
|
+
// duration
|
|
39
|
+
const durationParam = route.query.duration;
|
|
40
|
+
if (durationParam) {
|
|
41
|
+
const title = props.durationItems.find(item => item.value === durationParam)?.title;
|
|
42
|
+
items.push({
|
|
43
|
+
title: `${title}`,
|
|
44
|
+
value: String(durationParam),
|
|
45
|
+
key: 'duration'
|
|
46
|
+
});
|
|
47
|
+
}
|
|
48
|
+
|
|
49
|
+
// added
|
|
50
|
+
const addedParam = route.query.added;
|
|
51
|
+
if (addedParam) {
|
|
52
|
+
const title = props.addedItems.find(item => item.value === addedParam)?.title;
|
|
53
|
+
items.push({
|
|
54
|
+
title: `${title}`,
|
|
55
|
+
value: String(addedParam),
|
|
56
|
+
key: 'added'
|
|
57
|
+
});
|
|
58
|
+
}
|
|
59
|
+
|
|
60
|
+
return items;
|
|
61
|
+
});
|
|
62
|
+
|
|
63
|
+
function onChipsClick(item: IChipsItem & { key?: string }) {
|
|
64
|
+
const query = { ...route.query };
|
|
65
|
+
|
|
66
|
+
if (item.key === 'categories') {
|
|
67
|
+
const categoriesParam = route.query.categories;
|
|
68
|
+
if (!categoriesParam) return;
|
|
69
|
+
|
|
70
|
+
const categories = Array.isArray(categoriesParam)
|
|
71
|
+
? categoriesParam.flatMap(c => c.split(','))
|
|
72
|
+
: categoriesParam.split(',');
|
|
73
|
+
|
|
74
|
+
const updatedCategories = categories.filter(cat => cat !== item.value);
|
|
75
|
+
|
|
76
|
+
if (updatedCategories.length > 0) {
|
|
77
|
+
query.categories = updatedCategories.join(',');
|
|
78
|
+
} else {
|
|
79
|
+
delete query.categories;
|
|
80
|
+
}
|
|
81
|
+
}
|
|
82
|
+
|
|
83
|
+
if (item.key === 'duration') {
|
|
84
|
+
delete query.duration;
|
|
85
|
+
}
|
|
86
|
+
|
|
87
|
+
if (item.key === 'added') {
|
|
88
|
+
delete query.added;
|
|
89
|
+
}
|
|
90
|
+
|
|
91
|
+
router.replace({ query });
|
|
92
|
+
}
|
|
93
|
+
</script>
|
|
94
|
+
|
|
95
|
+
<style scoped lang="scss">
|
|
96
|
+
</style>
|
|
@@ -0,0 +1,72 @@
|
|
|
1
|
+
<template>
|
|
2
|
+
<div class="f-filter">
|
|
3
|
+
<FFilterButton
|
|
4
|
+
:count="filterCount"
|
|
5
|
+
v-model="filterOpen"
|
|
6
|
+
:theme="buttonTheme"
|
|
7
|
+
:size="buttonSize"
|
|
8
|
+
/>
|
|
9
|
+
|
|
10
|
+
<transition>
|
|
11
|
+
<FFilterPopup
|
|
12
|
+
v-if="filterOpen"
|
|
13
|
+
v-model="filterOpen"
|
|
14
|
+
:scheme="scheme"
|
|
15
|
+
:count="filterCount"
|
|
16
|
+
:duration-items="durationItems"
|
|
17
|
+
:added-items="addedItems"
|
|
18
|
+
/>
|
|
19
|
+
</transition>
|
|
20
|
+
</div>
|
|
21
|
+
</template>
|
|
22
|
+
|
|
23
|
+
<script setup lang="ts">
|
|
24
|
+
import type { ButtonSizes, ButtonThemes, FilterSchemeType } from '../../types';
|
|
25
|
+
import type { LocationQuery } from '#vue-router';
|
|
26
|
+
import type { ISelectItem } from '../../types';
|
|
27
|
+
|
|
28
|
+
const route = useRoute();
|
|
29
|
+
const filterOpen = ref(false);
|
|
30
|
+
|
|
31
|
+
defineProps<{
|
|
32
|
+
scheme: FilterSchemeType
|
|
33
|
+
addedItems: ISelectItem[]
|
|
34
|
+
durationItems: ISelectItem[]
|
|
35
|
+
buttonSize?: ButtonSizes
|
|
36
|
+
buttonTheme?: ButtonThemes
|
|
37
|
+
}>()
|
|
38
|
+
|
|
39
|
+
function getFilteredQuery(query: LocationQuery) {
|
|
40
|
+
const result: string[] = [];
|
|
41
|
+
|
|
42
|
+
const categoriesRaw = query[ 'categories' ];
|
|
43
|
+
if (categoriesRaw) {
|
|
44
|
+
if (Array.isArray(categoriesRaw)) {
|
|
45
|
+
categoriesRaw.forEach(item => {
|
|
46
|
+
if (typeof item === 'string') {
|
|
47
|
+
result.push(...item.split(',').filter(Boolean));
|
|
48
|
+
}
|
|
49
|
+
});
|
|
50
|
+
} else if (typeof categoriesRaw === 'string') {
|
|
51
|
+
result.push(...categoriesRaw.split(',').filter(Boolean));
|
|
52
|
+
}
|
|
53
|
+
}
|
|
54
|
+
|
|
55
|
+
['duration', 'added'].forEach(key => {
|
|
56
|
+
const val = query[ key ];
|
|
57
|
+
if (typeof val === 'string' && val.length > 0) {
|
|
58
|
+
result.push(val);
|
|
59
|
+
}
|
|
60
|
+
});
|
|
61
|
+
|
|
62
|
+
return result;
|
|
63
|
+
}
|
|
64
|
+
|
|
65
|
+
const filterCount = ref(0);
|
|
66
|
+
|
|
67
|
+
filterCount.value = getFilteredQuery(route.query).length
|
|
68
|
+
|
|
69
|
+
watch(() => route.query, (value) => {
|
|
70
|
+
filterCount.value = getFilteredQuery(value).length
|
|
71
|
+
})
|
|
72
|
+
</script>
|
|
@@ -0,0 +1,84 @@
|
|
|
1
|
+
<template>
|
|
2
|
+
<FFilterModelChips :filters="data.filters"/>
|
|
3
|
+
<FFilterPage
|
|
4
|
+
class="f-model-filters__filters _from-sm"
|
|
5
|
+
:filters="data.filters"
|
|
6
|
+
:count="data.total"
|
|
7
|
+
:class="{'_loading': status === 'pending'}"
|
|
8
|
+
/>
|
|
9
|
+
<transition>
|
|
10
|
+
<FPopup
|
|
11
|
+
v-if="model"
|
|
12
|
+
v-model="model"
|
|
13
|
+
sheet
|
|
14
|
+
class="f-model-filters__popup"
|
|
15
|
+
>
|
|
16
|
+
<template #title>{{ popupTitle }}</template>
|
|
17
|
+
<FFilterPage
|
|
18
|
+
class="f-model-filters__filters _to-sm"
|
|
19
|
+
:filters="data.filters"
|
|
20
|
+
:count="data.total"
|
|
21
|
+
:class="{'_loading': status === 'pending'}"
|
|
22
|
+
/>
|
|
23
|
+
<template #footer>
|
|
24
|
+
<div class="f-model-filters__footer-buttons _to-sm">
|
|
25
|
+
<FButton
|
|
26
|
+
wide
|
|
27
|
+
class="f-model-filters__reset"
|
|
28
|
+
:disabled="!isFiltered"
|
|
29
|
+
@click="onResetClick"
|
|
30
|
+
theme="secondary"
|
|
31
|
+
>{{ t('reset_all') }}
|
|
32
|
+
</FButton>
|
|
33
|
+
<FButton
|
|
34
|
+
wide
|
|
35
|
+
class="_to-sm"
|
|
36
|
+
@click="onShowClick"
|
|
37
|
+
>{{ `${t('show_results')} (${data.total})` }}
|
|
38
|
+
</FButton>
|
|
39
|
+
</div>
|
|
40
|
+
</template>
|
|
41
|
+
</FPopup>
|
|
42
|
+
</transition>
|
|
43
|
+
</template>
|
|
44
|
+
|
|
45
|
+
<script setup lang="ts">
|
|
46
|
+
import type { IModelCard, PaginatedResponse } from '../../types';
|
|
47
|
+
|
|
48
|
+
const props = defineProps<{
|
|
49
|
+
data: PaginatedResponse<IModelCard>,
|
|
50
|
+
status: string,
|
|
51
|
+
filterCount: number,
|
|
52
|
+
}>()
|
|
53
|
+
|
|
54
|
+
const model = defineModel<boolean>();
|
|
55
|
+
|
|
56
|
+
const { t } = useI18n();
|
|
57
|
+
|
|
58
|
+
const router = useRouter();
|
|
59
|
+
const route = useRoute();
|
|
60
|
+
|
|
61
|
+
function onResetClick() {
|
|
62
|
+
router.replace({ query: {} });
|
|
63
|
+
}
|
|
64
|
+
|
|
65
|
+
const emit = defineEmits<{
|
|
66
|
+
(eventName: 'update:modelValue', value: boolean): void
|
|
67
|
+
}>()
|
|
68
|
+
|
|
69
|
+
function onShowClick() {
|
|
70
|
+
emit('update:modelValue', false);
|
|
71
|
+
document.documentElement.querySelector('#anchor')?.scrollIntoView({ behavior: 'smooth' });
|
|
72
|
+
}
|
|
73
|
+
|
|
74
|
+
const isFiltered = computed(() => Object.keys(route.query).some(item => item.startsWith('filter')));
|
|
75
|
+
|
|
76
|
+
const popupTitle = computed(() => {
|
|
77
|
+
const countText = props.filterCount ? `: ${props.filterCount}` : '';
|
|
78
|
+
const filterText = props.filterCount > 1 ? t('filters') : t('filter');
|
|
79
|
+
return `${filterText}${countText}`;
|
|
80
|
+
})
|
|
81
|
+
</script>
|
|
82
|
+
|
|
83
|
+
<style scoped lang="scss">
|
|
84
|
+
</style>
|
package/package.json
CHANGED
|
@@ -1,11 +1,11 @@
|
|
|
1
1
|
{
|
|
2
2
|
"name": "itube-specs",
|
|
3
3
|
"type": "module",
|
|
4
|
-
"version": "0.0.
|
|
4
|
+
"version": "0.0.205",
|
|
5
5
|
"main": "./nuxt.config.ts",
|
|
6
6
|
"types": "./types/index.d.ts",
|
|
7
7
|
"scripts": {
|
|
8
|
-
"
|
|
8
|
+
"prepublish": "npm install && npx nuxi prepare"
|
|
9
9
|
},
|
|
10
10
|
"exports": {
|
|
11
11
|
".": {
|