first commit
This commit is contained in:
16
src/components/NewsList/List/FixedList.vue
Normal file
16
src/components/NewsList/List/FixedList.vue
Normal file
@@ -0,0 +1,16 @@
|
||||
<template>
|
||||
<div>
|
||||
<FixedListItem v-for="item in props.list" :key="item.id" v-bind="item" />
|
||||
</div>
|
||||
</template>
|
||||
|
||||
<script setup>
|
||||
// components
|
||||
import FixedListItem from "@/components/NewsList/List/FixedListItem.vue";
|
||||
|
||||
import { defineProps } from "vue";
|
||||
|
||||
const props = defineProps(["list"]);
|
||||
</script>
|
||||
|
||||
<style lang="scss"></style>
|
114
src/components/NewsList/List/FixedListItem.vue
Normal file
114
src/components/NewsList/List/FixedListItem.vue
Normal file
@@ -0,0 +1,114 @@
|
||||
<template>
|
||||
<article class="fl-item">
|
||||
<ImageComp class="fl-item__image" :src="photo" :alt="'image'" />
|
||||
<div class="fl-item__container">
|
||||
<HeaderComp class="fl-item__title">
|
||||
<LinkComp :to="`/post/${id}`">{{ title }}</LinkComp>
|
||||
</HeaderComp>
|
||||
|
||||
<MetaComp class="fl-item__meta" :id="id" :category="category" :tags="tags" :published_date="published_date" />
|
||||
|
||||
<SocialComp class="fl-item__social" :scope="'news'" :id="id" :like="like" :comments_count="comments_count" :views="views" />
|
||||
|
||||
<div class="fl-item__content" v-html="contentSubstring"></div>
|
||||
|
||||
<LinkComp :to="`/post/${id}`">read more...</LinkComp>
|
||||
</div>
|
||||
</article>
|
||||
</template>
|
||||
|
||||
<script setup>
|
||||
import { computed, defineProps } from "vue";
|
||||
// components
|
||||
import MetaComp from "@/components/Common/MetaComp.vue";
|
||||
import SocialComp from "@/components/Common/SocialComp.vue";
|
||||
|
||||
const props = defineProps([
|
||||
"id",
|
||||
"title",
|
||||
"news_body",
|
||||
"photo",
|
||||
"category",
|
||||
"tags",
|
||||
"comments_count",
|
||||
"scrollPosition",
|
||||
"like",
|
||||
"published_date",
|
||||
"views",
|
||||
]);
|
||||
|
||||
const content = computed(() => {
|
||||
if (props.news_body) {
|
||||
let val = "<p>";
|
||||
val += props.news_body.replaceAll("\n", "</p><p>");
|
||||
val += "</p>";
|
||||
return val;
|
||||
}
|
||||
return "";
|
||||
});
|
||||
|
||||
const contentSubstring = computed(() => {
|
||||
let val = "";
|
||||
if (content.value.includes("<br>")) {
|
||||
val = content.value.substring(0, content.value.indexOf("<br>"));
|
||||
}
|
||||
if (val.length > 50) {
|
||||
val = val.substring(0, 50) + "...";
|
||||
}
|
||||
return val;
|
||||
});
|
||||
</script>
|
||||
|
||||
<style lang="scss">
|
||||
.fl-item {
|
||||
position: relative;
|
||||
padding: 20px 0;
|
||||
border-bottom: 1px solid color("border");
|
||||
|
||||
@media (min-width: $mobileWidth) {
|
||||
display: flex;
|
||||
height: 250px;
|
||||
width: 100%;
|
||||
justify-content: space-between;
|
||||
}
|
||||
|
||||
&__container {
|
||||
@media (min-width: $mobileWidth) {
|
||||
display: flex;
|
||||
flex-direction: column;
|
||||
align-items: flex-start;
|
||||
max-width: calc(100% - 310px);
|
||||
max-height: 100%;
|
||||
}
|
||||
}
|
||||
|
||||
&__title {
|
||||
}
|
||||
|
||||
&__meta {
|
||||
margin-bottom: 5px;
|
||||
}
|
||||
|
||||
&__social {
|
||||
margin-bottom: 5px;
|
||||
}
|
||||
|
||||
&__content {
|
||||
}
|
||||
|
||||
&__expand {
|
||||
cursor: pointer;
|
||||
color: color("link");
|
||||
}
|
||||
|
||||
&__image {
|
||||
height: 160px;
|
||||
|
||||
@media (min-width: $mobileWidth) {
|
||||
order: 1;
|
||||
width: 300px;
|
||||
height: 100%;
|
||||
}
|
||||
}
|
||||
}
|
||||
</style>
|
82
src/components/NewsList/List/InfiniteScroll.vue
Normal file
82
src/components/NewsList/List/InfiniteScroll.vue
Normal file
@@ -0,0 +1,82 @@
|
||||
<template>
|
||||
<div class="infinite-scroll">
|
||||
<InfiniteScrollItem v-for="item in localList" :key="item.id" v-bind="item" />
|
||||
</div>
|
||||
</template>
|
||||
|
||||
<script setup>
|
||||
import { computed, nextTick, onMounted, ref, watch, defineProps } from "vue";
|
||||
// components
|
||||
import InfiniteScrollItem from "@/components/NewsList/List/InfiniteScrollItem.vue";
|
||||
// stores
|
||||
import { useNews } from "@/stores";
|
||||
const newsStore = useNews();
|
||||
|
||||
const props = defineProps(["list"]);
|
||||
|
||||
const localList = ref([]);
|
||||
|
||||
const allPostsFetched = computed(() => localList.value.length > 0 && localList.value.every((x) => x.showed));
|
||||
|
||||
async function showPostIfNeeded() {
|
||||
let getNextIndexIfNeeded = () => {
|
||||
let lastOnscreenItemId = newsStore.onScreenPostsIds[newsStore.onScreenPostsIds.length - 1];
|
||||
let lastOnscreenItem = localList.value.find((x) => x.id === lastOnscreenItemId);
|
||||
if (lastOnscreenItem && lastOnscreenItem.showed && lastOnscreenItem.index < localList.value.length - 1) {
|
||||
let nextItemIndex = lastOnscreenItem.index + 1;
|
||||
|
||||
if (!localList.value[nextItemIndex].showed) {
|
||||
return nextItemIndex;
|
||||
}
|
||||
}
|
||||
return -1;
|
||||
};
|
||||
|
||||
for (;;) {
|
||||
if (allPostsFetched.value || localList.value.length === 0) break;
|
||||
|
||||
let index = -1;
|
||||
|
||||
if (newsStore.onScreenPostsIds.length === 0 && localList.value.every((x) => !x.showed)) {
|
||||
index = 0;
|
||||
} else {
|
||||
index = getNextIndexIfNeeded();
|
||||
}
|
||||
|
||||
if (index > -1) {
|
||||
if (!localList.value[index]) break;
|
||||
|
||||
localList.value[index].showed = true;
|
||||
} else break;
|
||||
await nextTick();
|
||||
}
|
||||
}
|
||||
|
||||
onMounted(() => {
|
||||
localList.value = props.list;
|
||||
});
|
||||
|
||||
watch(
|
||||
() => props.list,
|
||||
async (to) => {
|
||||
if (to.length > 20) {
|
||||
localList.value.push(...to.slice(localList.value.length).map((x) => ({ ...x, showed: false })));
|
||||
} else {
|
||||
localList.value = to.map((x) => ({ ...x, showed: false }));
|
||||
}
|
||||
await nextTick();
|
||||
showPostIfNeeded();
|
||||
},
|
||||
{ deep: true }
|
||||
);
|
||||
|
||||
watch(
|
||||
() => newsStore.onScreenPostsIds,
|
||||
() => {
|
||||
showPostIfNeeded();
|
||||
},
|
||||
{ deep: true }
|
||||
);
|
||||
</script>
|
||||
|
||||
<style lang="scss"></style>
|
194
src/components/NewsList/List/InfiniteScrollItem.vue
Normal file
194
src/components/NewsList/List/InfiniteScrollItem.vue
Normal file
@@ -0,0 +1,194 @@
|
||||
<template>
|
||||
<article class="is-item" ref="el" v-if="showed">
|
||||
<ImageComp class="is-item__image" :src="photo" :alt="'image'" />
|
||||
<div class="is-item__container">
|
||||
<HeaderComp class="is-item__title">
|
||||
<LinkComp :to="`/post/${id}`">{{ title }}</LinkComp>
|
||||
</HeaderComp>
|
||||
|
||||
<MetaComp class="is-item__meta" :id="id" :category="category" :tags="tags" :published_date="published_date" />
|
||||
|
||||
<SocialComp class="is-item__social" :scope="'news'" :id="id" :like="like" :comments_count="comments_count" :views="views" />
|
||||
|
||||
<div class="is-item__content" v-html="expanded ? content : contentSubstring"></div>
|
||||
|
||||
<div class="is-item__expand" v-if="!expanded" @click="expanded = true">read more...</div>
|
||||
</div>
|
||||
<!-- <div class="is-item__filler" :style="`background-color: rgba(0,0,0,${opacity});`"></div> -->
|
||||
</article>
|
||||
</template>
|
||||
|
||||
<script setup>
|
||||
import { ref, defineProps, computed, watch, onBeforeUnmount, nextTick } from "vue";
|
||||
// components
|
||||
import MetaComp from "@/components/Common/MetaComp.vue";
|
||||
import SocialComp from "@/components/Common/SocialComp.vue";
|
||||
// store
|
||||
import { useNews } from "@/stores";
|
||||
const newsStore = useNews();
|
||||
|
||||
const props = defineProps([
|
||||
"fillerNeeded",
|
||||
"showed",
|
||||
"index",
|
||||
"id",
|
||||
"title",
|
||||
"news_body",
|
||||
"photo",
|
||||
"category",
|
||||
"tags",
|
||||
"comments_count",
|
||||
"like",
|
||||
"published_date",
|
||||
"views",
|
||||
]);
|
||||
|
||||
const onScreen = ref(false);
|
||||
// let windowHeight = 0;
|
||||
const el = ref(null);
|
||||
const expanded = ref(false);
|
||||
|
||||
const content = computed(() => {
|
||||
if (props.news_body) {
|
||||
let val = "<p>";
|
||||
val += props.news_body.replaceAll("\n", "</p><p>");
|
||||
val += "</p>";
|
||||
return val;
|
||||
}
|
||||
return "";
|
||||
});
|
||||
|
||||
const contentSubstring = computed(() => {
|
||||
if (content.value.length > 70) {
|
||||
return content.value.substring(0, 70) + "...";
|
||||
}
|
||||
return content.value;
|
||||
});
|
||||
|
||||
// const scrollPosition = ref(0);
|
||||
// function scrollListener() {
|
||||
// scrollPosition.value = window.scrollY;
|
||||
// }
|
||||
// watch(
|
||||
// () => onScreen.value,
|
||||
// (to) => {
|
||||
// if (props.fillerNeeded) {
|
||||
// if (to) {
|
||||
// window.addEventListener("scroll", scrollListener);
|
||||
// } else {
|
||||
// window.removeEventListener("scroll", scrollListener);
|
||||
// }
|
||||
// }
|
||||
// }
|
||||
// );
|
||||
|
||||
// const opacity = computed(() => {
|
||||
// if (props.index === 0 || !props.fillerNeeded) return 0;
|
||||
// let scroll = scrollPosition.value + 48;
|
||||
// let elementPosition = el.value?.offsetTop || 0;
|
||||
// if (elementPosition - windowHeight * 0.5 < scroll) return 0;
|
||||
// let value = (elementPosition - scroll - windowHeight * 0.5) / (windowHeight * 0.5 - 50);
|
||||
// return value;
|
||||
// });
|
||||
|
||||
const callback = (entries) => {
|
||||
if (entries[0].isIntersecting) {
|
||||
onScreen.value = true;
|
||||
newsStore.addOnScreenPostId(props.id);
|
||||
} else {
|
||||
onScreen.value = false;
|
||||
newsStore.removeOnScreenPostId(props.id);
|
||||
}
|
||||
};
|
||||
const observer = new IntersectionObserver(callback, {});
|
||||
|
||||
// onMounted(() => {
|
||||
// windowHeight = window.innerHeight;
|
||||
// });
|
||||
|
||||
onBeforeUnmount(() => {
|
||||
onScreen.value = false;
|
||||
newsStore.removeOnScreenPostId(props.id);
|
||||
if (el.value) {
|
||||
observer.unobserve(el.value);
|
||||
}
|
||||
});
|
||||
|
||||
watch(
|
||||
() => props.showed,
|
||||
async (to) => {
|
||||
if (to) {
|
||||
nextTick(() => {
|
||||
observer.observe(el.value);
|
||||
});
|
||||
}
|
||||
}
|
||||
);
|
||||
</script>
|
||||
|
||||
<style lang="scss">
|
||||
.is-item {
|
||||
position: relative;
|
||||
padding: 20px 0;
|
||||
border-bottom: 1px solid color("border");
|
||||
|
||||
@media (min-width: $mobileWidth) {
|
||||
display: flex;
|
||||
min-height: 250px;
|
||||
width: 100%;
|
||||
justify-content: space-between;
|
||||
}
|
||||
|
||||
&__container {
|
||||
@media (min-width: $mobileWidth) {
|
||||
display: flex;
|
||||
flex-direction: column;
|
||||
align-items: flex-start;
|
||||
max-width: calc(100% - 310px);
|
||||
max-height: 100%;
|
||||
}
|
||||
}
|
||||
|
||||
&__title {
|
||||
}
|
||||
|
||||
&__meta {
|
||||
margin-bottom: 5px;
|
||||
}
|
||||
|
||||
&__social {
|
||||
margin-bottom: 5px;
|
||||
}
|
||||
|
||||
&__content {
|
||||
}
|
||||
|
||||
&__expand {
|
||||
cursor: pointer;
|
||||
color: color("link");
|
||||
}
|
||||
|
||||
&__image {
|
||||
height: 160px;
|
||||
|
||||
@media (min-width: $mobileWidth) {
|
||||
order: 1;
|
||||
width: 300px;
|
||||
height: 210px;
|
||||
}
|
||||
}
|
||||
|
||||
&__filler {
|
||||
position: absolute;
|
||||
margin: 0 calc((100% - 100vw) / 2);
|
||||
top: 0;
|
||||
bottom: 0;
|
||||
left: 0;
|
||||
right: 0;
|
||||
z-index: 10;
|
||||
pointer-events: none;
|
||||
background-color: rgba(0, 0, 0, 0.6);
|
||||
//box-shadow: 0 -5px 5px #000;
|
||||
}
|
||||
}
|
||||
</style>
|
273
src/components/NewsList/NewsListContainer.vue
Normal file
273
src/components/NewsList/NewsListContainer.vue
Normal file
@@ -0,0 +1,273 @@
|
||||
<template>
|
||||
<div class="news-page">
|
||||
<div class="news-page__info-filters" v-if="newsStore.selectedCategories.length > 0">
|
||||
<div class="news-page__info-categories">
|
||||
<template v-for="category in newsStore.selectedCategories" :key="category">
|
||||
<div class="news-page__info-categories-item">
|
||||
{{ category.title }}
|
||||
<font-awesome-icon class="news-page__info-categories-item-icon" icon="xmark" @click="removeCategory(category.id)" />
|
||||
</div>
|
||||
</template>
|
||||
</div>
|
||||
<div class="news-page__info-tags">
|
||||
<template v-for="tag in newsStore.selectedTags" :key="tag">
|
||||
<div class="news-page__info-tags-item">
|
||||
{{ tag.title }}
|
||||
<font-awesome-icon class="news-page__info-tags-item-icon" icon="xmark" @click="removeTag(tag.id)" />
|
||||
</div>
|
||||
</template>
|
||||
</div>
|
||||
</div>
|
||||
<InfiniteScroll :list="list" />
|
||||
<LoadingComp class="news-page__loading" v-if="loading" />
|
||||
<div class="news-page__loading-failed" v-if="loadingFailed">К сожалению, ничего не найдено</div>
|
||||
<SidebarContainer class="news-page__sidebar" />
|
||||
<NewsSearch />
|
||||
</div>
|
||||
</template>
|
||||
|
||||
<script>
|
||||
export default { name: "NewsListContainer" };
|
||||
</script>
|
||||
<script setup>
|
||||
import { computed, nextTick, onActivated, onMounted, ref, watch } from "vue";
|
||||
// components
|
||||
import InfiniteScroll from "@/components/NewsList/List/InfiniteScroll.vue";
|
||||
import SidebarContainer from "@/components/NewsList/SideBar/SidebarContainer.vue";
|
||||
import NewsSearch from "@/components/NewsList/Search/NewsSearch.vue";
|
||||
// router
|
||||
import { useRoute, useRouter } from "vue-router";
|
||||
const route = useRoute();
|
||||
const router = useRouter();
|
||||
// stores
|
||||
import { useNews } from "@/stores";
|
||||
const newsStore = useNews();
|
||||
// composables
|
||||
import { useNewsApi } from "@/composables/api/news";
|
||||
const newsApi = useNewsApi();
|
||||
|
||||
const loading = computed(() => list.value.length === 0 && !loadingFailed.value);
|
||||
const loadingFailed = ref(false);
|
||||
const pageLoaded = ref(0);
|
||||
|
||||
const list = ref([]);
|
||||
|
||||
onMounted(() => {
|
||||
if (!newsStore.pageReseted) {
|
||||
init();
|
||||
}
|
||||
});
|
||||
onActivated(() => {
|
||||
if (newsStore.pageReseted) {
|
||||
filterHandler();
|
||||
nextTick(() => (newsStore.pageReseted = false));
|
||||
}
|
||||
});
|
||||
|
||||
watch(
|
||||
() => newsStore.pageReseted,
|
||||
(to) => {
|
||||
if (to) {
|
||||
filterHandler();
|
||||
nextTick(() => (newsStore.pageReseted = false));
|
||||
}
|
||||
}
|
||||
);
|
||||
|
||||
async function init() {
|
||||
list.value = [];
|
||||
getFilterDataFromQuery();
|
||||
|
||||
await newsStore.init(); // load categories and tags list
|
||||
await nextTick();
|
||||
await filterHandler();
|
||||
}
|
||||
|
||||
// #region handling filtration
|
||||
function removeCategory(id) {
|
||||
newsStore.selectedCategoriesIds = newsStore.selectedCategoriesIds.filter((x) => x !== id);
|
||||
newsStore.pageReseted = true;
|
||||
}
|
||||
|
||||
function removeTag(id) {
|
||||
newsStore.selectedTagsIds = newsStore.selectedTagsIds.filter((x) => x !== id);
|
||||
newsStore.pageReseted = true;
|
||||
}
|
||||
|
||||
function getFilterDataFromQuery() {
|
||||
if (route.query.categories) {
|
||||
newsStore.selectedCategoriesIds = route.query.categories.split(",").map(Number);
|
||||
} else {
|
||||
newsStore.selectedCategoriesIds = [];
|
||||
}
|
||||
|
||||
if (route.query.tags) {
|
||||
newsStore.selectedTagsIds = route.query.tags.split(",").map(Number);
|
||||
} else {
|
||||
newsStore.selectedTagsIds = [];
|
||||
}
|
||||
}
|
||||
|
||||
async function filterHandler() {
|
||||
list.value = [];
|
||||
|
||||
removeWrongFilterIds();
|
||||
setFilterDataToQuery();
|
||||
|
||||
newsStore.filterNeeded = false;
|
||||
await loadMorePosts(1);
|
||||
}
|
||||
|
||||
function setFilterDataToQuery() {
|
||||
let query = Object.assign({}, route.query);
|
||||
["categories", "tags"].forEach((x) => {
|
||||
let value = x === "categories" ? newsStore.selectedCategoriesIds.join(",") : newsStore.selectedTagsIds.join(",");
|
||||
|
||||
if (value === "") {
|
||||
delete query[x];
|
||||
} else {
|
||||
query[x] = value;
|
||||
}
|
||||
});
|
||||
|
||||
router.replace({ query });
|
||||
}
|
||||
|
||||
function removeWrongFilterIds() {
|
||||
let remove = (target, possible) => {
|
||||
let possibleIds = possible.map((x) => x.id);
|
||||
return target.filter((x) => possibleIds.includes(x));
|
||||
};
|
||||
|
||||
let target = newsStore.selectedCategoriesIds;
|
||||
let edited = remove([...new Set(target.sort((a, b) => a - b))], newsStore.categories);
|
||||
if (JSON.stringify(target) !== JSON.stringify(edited)) {
|
||||
newsStore.selectedCategoriesIds = edited;
|
||||
}
|
||||
|
||||
target = newsStore.selectedTagsIds;
|
||||
edited = remove([...new Set(target.sort((a, b) => a - b))], newsStore.possibleTags);
|
||||
if (JSON.stringify(target) !== JSON.stringify(edited)) {
|
||||
newsStore.selectedTagsIds = edited;
|
||||
}
|
||||
}
|
||||
|
||||
watch(
|
||||
() => newsStore.filterNeeded,
|
||||
(to) => {
|
||||
if (to) {
|
||||
filterHandler();
|
||||
}
|
||||
}
|
||||
);
|
||||
// #endregion
|
||||
|
||||
// #region handling fetching posts
|
||||
const meta = ref({});
|
||||
|
||||
async function loadMorePosts(page) {
|
||||
loadingFailed.value = false;
|
||||
let posts = await newsApi.fetchPostsIdsByFilters(newsStore, page);
|
||||
|
||||
if (posts == null) {
|
||||
return;
|
||||
} else if (posts.news == null || posts.news.length === 0) {
|
||||
loadingFailed.value = true;
|
||||
} else {
|
||||
list.value.push(...posts.news.map((x, i) => ({ ...x, index: i + list.value.length })));
|
||||
meta.value = posts._meta;
|
||||
}
|
||||
}
|
||||
|
||||
function loadMorePostsIfNeeded() {
|
||||
if (newsStore.onScreenPostsIds.length === 0 || list.value.length < 20) return;
|
||||
|
||||
let lastOnscreenId = newsStore.onScreenPostsIds[newsStore.onScreenPostsIds.length - 1];
|
||||
let lastOnscreenIdEqualsLastListId = lastOnscreenId === list.value[list.value.length - 1].id;
|
||||
let morePostsCanBeFetched = list.value.length < meta.value.totalCount;
|
||||
|
||||
if (lastOnscreenIdEqualsLastListId && morePostsCanBeFetched) {
|
||||
let nextPageNumber = Math.ceil(list.value.length / meta.value.perPage) + 1;
|
||||
if (nextPageNumber !== pageLoaded.value) {
|
||||
loadMorePosts(nextPageNumber);
|
||||
pageLoaded.value = nextPageNumber;
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
watch(
|
||||
() => newsStore.onScreenPostsIds,
|
||||
() => {
|
||||
loadMorePostsIfNeeded();
|
||||
},
|
||||
{ deep: true }
|
||||
);
|
||||
// #endregion
|
||||
</script>
|
||||
|
||||
<style lang="scss">
|
||||
.news-page {
|
||||
&__info-filters {
|
||||
}
|
||||
|
||||
&__info-categories {
|
||||
display: flex;
|
||||
flex-wrap: wrap;
|
||||
font-size: 20px;
|
||||
padding-top: 20px;
|
||||
|
||||
&-item {
|
||||
display: flex;
|
||||
align-items: center;
|
||||
gap: 5px;
|
||||
margin-right: 10px;
|
||||
|
||||
&-icon {
|
||||
height: 15px;
|
||||
cursor: pointer;
|
||||
color: color("second");
|
||||
transition: color 0.18s ease;
|
||||
|
||||
&:hover {
|
||||
color: color("second-hover");
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
&__info-tags {
|
||||
display: flex;
|
||||
flex-wrap: wrap;
|
||||
gap: 7px;
|
||||
padding-top: 10px;
|
||||
|
||||
&-item {
|
||||
display: flex;
|
||||
align-items: center;
|
||||
gap: 3px;
|
||||
|
||||
&-icon {
|
||||
height: 13px;
|
||||
cursor: pointer;
|
||||
color: color("second");
|
||||
transition: color 0.18s ease;
|
||||
|
||||
&:hover {
|
||||
color: color("second-hover");
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
&__loading {
|
||||
margin: 50px auto;
|
||||
}
|
||||
|
||||
&__loading-failed {
|
||||
margin: 50px 0;
|
||||
display: flex;
|
||||
justify-content: center;
|
||||
text-align: center;
|
||||
}
|
||||
}
|
||||
</style>
|
165
src/components/NewsList/Search/NewsSearch.vue
Normal file
165
src/components/NewsList/Search/NewsSearch.vue
Normal file
@@ -0,0 +1,165 @@
|
||||
<template>
|
||||
<ModalComp ref="modal">
|
||||
<div class="news-search">
|
||||
<div class="news-search__input">
|
||||
<font-awesome-icon class="news-search__input-close" icon="xmark" @click="closeSearch()" />
|
||||
<input
|
||||
class="news-search__input-field"
|
||||
type="text"
|
||||
placeholder="Поиск"
|
||||
v-model="searchText"
|
||||
ref="input"
|
||||
@keydown="waitForEnter"
|
||||
/>
|
||||
<font-awesome-icon class="news-search__input-search" icon="magnifying-glass" @click="openSearchPage()" />
|
||||
</div>
|
||||
|
||||
<LoadingComp class="news-search__loading" v-if="loading" />
|
||||
<div class="news-search__list wrap" v-else-if="list.length > 0">
|
||||
<FixedList :list="list" />
|
||||
<ButtonComp
|
||||
class="news-search__more"
|
||||
v-if="listLength > 4"
|
||||
:value="`Show all ${listLength} results`"
|
||||
@click="openSearchPage()"
|
||||
/>
|
||||
</div>
|
||||
</div>
|
||||
</ModalComp>
|
||||
</template>
|
||||
|
||||
<script setup>
|
||||
import { nextTick, ref, watch } from "vue";
|
||||
// components
|
||||
import FixedList from "@/components/NewsList/List/FixedList.vue";
|
||||
// stores
|
||||
import { useUi, useNews } from "@/stores";
|
||||
const uiStore = useUi();
|
||||
const newsStore = useNews();
|
||||
// router
|
||||
import { useRouter } from "vue-router";
|
||||
const router = useRouter();
|
||||
// composables
|
||||
import { useNewsApi } from "@/composables/api/news";
|
||||
const api = useNewsApi();
|
||||
|
||||
const searchText = ref("");
|
||||
const list = ref([]);
|
||||
const loading = ref(false);
|
||||
|
||||
const modal = ref(null);
|
||||
const input = ref(null);
|
||||
|
||||
let listLength = ref(0);
|
||||
|
||||
async function search() {
|
||||
if (searchText.value.trim() === "") return;
|
||||
|
||||
loading.value = true;
|
||||
let resp = await api.findPostsByString(searchText.value);
|
||||
loading.value = false;
|
||||
|
||||
if (resp != null && !resp.hasErrors) {
|
||||
listLength.value = resp.news.length;
|
||||
if (listLength.value > 4) {
|
||||
list.value = resp.news.slice(0, 4);
|
||||
} else {
|
||||
list.value = resp.news;
|
||||
}
|
||||
return;
|
||||
}
|
||||
list.value = [];
|
||||
listLength.value = 0;
|
||||
}
|
||||
|
||||
function waitForEnter(event) {
|
||||
if (event.key === "Enter") {
|
||||
openSearchPage();
|
||||
}
|
||||
}
|
||||
|
||||
function closeSearch() {
|
||||
modal.value.close();
|
||||
}
|
||||
|
||||
function openSearchPage() {
|
||||
newsStore.searchList = list.value;
|
||||
router.push(`/search?query=${searchText.value}`);
|
||||
closeSearch();
|
||||
}
|
||||
|
||||
// #region handling delayed search
|
||||
let timeoutedSearch;
|
||||
watch(searchText, () => {
|
||||
if (timeoutedSearch) {
|
||||
clearTimeout(timeoutedSearch);
|
||||
}
|
||||
timeoutedSearch = setTimeout(() => {
|
||||
search();
|
||||
}, 1000);
|
||||
});
|
||||
|
||||
watch(
|
||||
() => uiStore.searchWindowOpened,
|
||||
(to) => {
|
||||
if (to) {
|
||||
list.value = [];
|
||||
listLength.value = 0;
|
||||
searchText.value = "";
|
||||
clearTimeout(timeoutedSearch);
|
||||
modal.value.open({ position: "top" });
|
||||
uiStore.searchWindowOpened = false;
|
||||
if (window.innerWidth > 768) {
|
||||
nextTick(() => {
|
||||
input.value.focus();
|
||||
});
|
||||
}
|
||||
}
|
||||
}
|
||||
);
|
||||
// #endregion
|
||||
</script>
|
||||
|
||||
<style lang="scss">
|
||||
.news-search {
|
||||
display: flex;
|
||||
flex-direction: column;
|
||||
align-items: center;
|
||||
width: 100%;
|
||||
|
||||
&__input {
|
||||
display: flex;
|
||||
align-items: center;
|
||||
gap: 10px;
|
||||
width: calc(min(100vw - 55px, 600px));
|
||||
}
|
||||
|
||||
&__input-close,
|
||||
&__input-search {
|
||||
height: 30px;
|
||||
width: auto;
|
||||
cursor: pointer;
|
||||
color: color("second");
|
||||
}
|
||||
|
||||
&__input-field {
|
||||
@include grey-input;
|
||||
width: 100%;
|
||||
}
|
||||
|
||||
&__loading {
|
||||
margin: 10px auto;
|
||||
}
|
||||
|
||||
&__list {
|
||||
display: flex;
|
||||
justify-content: center;
|
||||
flex-direction: column;
|
||||
width: 100%;
|
||||
}
|
||||
|
||||
&__more {
|
||||
margin: 10px auto;
|
||||
}
|
||||
}
|
||||
</style>
|
197
src/components/NewsList/SideBar/CategoriesSelector.vue
Normal file
197
src/components/NewsList/SideBar/CategoriesSelector.vue
Normal file
@@ -0,0 +1,197 @@
|
||||
<template>
|
||||
<div class="categories-selector" :class="{ 'categories-selector--active': active }" v-click-outside="deactivate">
|
||||
<div class="categories-selector__input-panel" @click.self="activate">
|
||||
<span class="categories-selector__label" @click.self="activate">{{ label }}</span>
|
||||
<input class="categories-selector__input" :placeholder="placeholder" ref="refInput" v-model="filter" />
|
||||
<div class="categories-selector__toggle" @click="toggleActivation">
|
||||
<font-awesome-icon class="categories-selector__toggle-icon" icon="chevron-down" />
|
||||
</div>
|
||||
</div>
|
||||
<Transition v-show="active" name="categories-selector__content--transition-content">
|
||||
<ul class="categories-selector__content" :style="styleHeight">
|
||||
<li
|
||||
v-for="category in categoriesFiltered"
|
||||
class="categories-selector__item"
|
||||
:class="newsStore.selectedCategoriesIds.includes(category.id) ? 'categories-selector__item--selected' : ''"
|
||||
:key="category.id"
|
||||
@click="toggleSelection(category.id)"
|
||||
>
|
||||
{{ category.title }}
|
||||
</li>
|
||||
<li class="categories-selector__no-items" v-show="categoriesFiltered.length === 0">Категорий не найдено</li>
|
||||
</ul>
|
||||
</Transition>
|
||||
</div>
|
||||
</template>
|
||||
<script setup>
|
||||
import { computed, nextTick, ref } from "vue";
|
||||
// directives
|
||||
import vClickOutside from "@/directives/ClickOutside";
|
||||
// stores
|
||||
import { useNews } from "@/stores";
|
||||
const newsStore = useNews();
|
||||
|
||||
const active = ref(false);
|
||||
const placeholder = ref("Выберите категории");
|
||||
const filter = ref("");
|
||||
|
||||
const styleHeight = computed(() => ({
|
||||
"--height": (newsStore.categories.length > 5 ? 200 : 34.4 * newsStore.categories.length + 4) + "px",
|
||||
}));
|
||||
|
||||
const refInput = ref(null);
|
||||
|
||||
const categoriesFiltered = computed(() => newsStore.categories.filter((x) => x.title.includes(filter.value)));
|
||||
const label = computed(() => {
|
||||
const length = newsStore.selectedCategoriesIds.length;
|
||||
if (length === 1) {
|
||||
return `1 категория выбрана`;
|
||||
} else if (length > 1 && length <= 4) {
|
||||
return `${length} категории выбрано`;
|
||||
} else if (length > 4) {
|
||||
return `${length} категорий выбрано`;
|
||||
} else {
|
||||
return placeholder.value;
|
||||
}
|
||||
});
|
||||
|
||||
function activate() {
|
||||
if (!active.value) {
|
||||
active.value = true;
|
||||
|
||||
if (window.innerWidth > 768) {
|
||||
nextTick(() => {
|
||||
refInput.value.focus();
|
||||
});
|
||||
}
|
||||
}
|
||||
}
|
||||
function deactivate() {
|
||||
active.value = false;
|
||||
}
|
||||
function toggleActivation() {
|
||||
active.value ? deactivate() : activate();
|
||||
}
|
||||
|
||||
function toggleSelection(id) {
|
||||
if (newsStore.selectedCategoriesIds.includes(id)) {
|
||||
newsStore.selectedCategoriesIds = newsStore.selectedCategoriesIds.filter((x) => x !== id);
|
||||
} else {
|
||||
newsStore.selectedCategoriesIds = [...newsStore.selectedCategoriesIds, id];
|
||||
}
|
||||
newsStore.filterNeeded = true;
|
||||
}
|
||||
</script>
|
||||
<style lang="scss">
|
||||
.categories-selector {
|
||||
$p: &;
|
||||
position: relative;
|
||||
|
||||
&__input-panel {
|
||||
display: flex;
|
||||
position: relative;
|
||||
justify-content: space-between;
|
||||
align-items: center;
|
||||
width: 100%;
|
||||
height: 40px;
|
||||
padding: 0 10px;
|
||||
color: color("main");
|
||||
border: 2px solid color("border");
|
||||
font-family: "Montserrat", sans-serif;
|
||||
font-size: 14px;
|
||||
}
|
||||
|
||||
&__input {
|
||||
display: none;
|
||||
width: 100%;
|
||||
height: 100%;
|
||||
background: color("background");
|
||||
color: color("main");
|
||||
outline: none;
|
||||
border: none;
|
||||
font-size: 14px;
|
||||
|
||||
&::placeholder {
|
||||
color: color("second");
|
||||
font-family: "Montserrat", sans-serif;
|
||||
}
|
||||
}
|
||||
|
||||
&__toggle {
|
||||
position: absolute;
|
||||
width: 20px;
|
||||
height: 20px;
|
||||
right: 7px;
|
||||
top: 7px;
|
||||
transition: transform 0.2s ease;
|
||||
|
||||
&-icon {
|
||||
width: 100%;
|
||||
height: 100%;
|
||||
}
|
||||
}
|
||||
|
||||
&__content {
|
||||
position: relative;
|
||||
width: 100%;
|
||||
height: var(--height);
|
||||
background: color("background");
|
||||
list-style: none;
|
||||
border: 2px solid color("border");
|
||||
overflow: auto;
|
||||
|
||||
&--transition-content,
|
||||
&--transition-list {
|
||||
&-enter-active,
|
||||
&-leave-active {
|
||||
transition: height 0.18s;
|
||||
overflow-y: hidden;
|
||||
}
|
||||
&-enter-from,
|
||||
&-leave-to {
|
||||
height: 0;
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
&__item {
|
||||
padding: 5px 10px;
|
||||
cursor: pointer;
|
||||
color: color("main");
|
||||
|
||||
&--selected {
|
||||
background: color("background-hover");
|
||||
}
|
||||
|
||||
@media (min-width: $mobileWidth) {
|
||||
&--selected {
|
||||
&:hover {
|
||||
background: color("background-hover", $saturation: 20%);
|
||||
}
|
||||
}
|
||||
|
||||
&:not(&--selected) {
|
||||
&:hover {
|
||||
background: color("background-hover", $hue: 120deg, $saturation: 20%);
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
&__no-items {
|
||||
padding: 5px 10px;
|
||||
}
|
||||
|
||||
&--active {
|
||||
#{$p}__input {
|
||||
display: block;
|
||||
}
|
||||
#{$p}__label {
|
||||
display: none;
|
||||
}
|
||||
#{$p}__toggle {
|
||||
transform: rotate(180deg);
|
||||
}
|
||||
}
|
||||
}
|
||||
</style>
|
268
src/components/NewsList/SideBar/DateSelector.vue
Normal file
268
src/components/NewsList/SideBar/DateSelector.vue
Normal file
@@ -0,0 +1,268 @@
|
||||
<template>
|
||||
<div class="date-selector" :class="{ 'date-selector--showed': mainShowed }" v-click-outside="closeAll">
|
||||
<div class="date-selector__input-panel" @click.self="toggleMain">
|
||||
<span class="date-selector__label" @click.self="toggleMain">{{ displayedValue }}</span>
|
||||
<div class="date-selector__toggle" @click="toggleMain">
|
||||
<font-awesome-icon class="date-selector__toggle-icon" icon="chevron-down" />
|
||||
</div>
|
||||
</div>
|
||||
<Transition v-show="mainShowed" name="date-selector--transition">
|
||||
<ul class="date-selector__content">
|
||||
<li v-for="variant in variants" class="date-selector__item" :key="variant" @click="variant.callback()">
|
||||
{{ variant.title }}
|
||||
</li>
|
||||
</ul>
|
||||
</Transition>
|
||||
<Transition v-show="dateCompWindowShowed" name="date-selector--transition">
|
||||
<DateCompWindow
|
||||
class="date-selector__calendar"
|
||||
v-model:firstDate="firstDate"
|
||||
v-model:secondDate="secondDate"
|
||||
ref="refDateCompWindow"
|
||||
/>
|
||||
</Transition>
|
||||
</div>
|
||||
</template>
|
||||
|
||||
<script setup>
|
||||
import { computed, ref, watch } from "vue";
|
||||
// directives
|
||||
import vClickOutside from "@/directives/ClickOutside";
|
||||
// components
|
||||
import DateCompWindow from "@/components/Ui/DateComp/DateCompWindow.vue";
|
||||
// stores
|
||||
import { useNews } from "@/stores";
|
||||
const newsStore = useNews();
|
||||
|
||||
const firstDate = computed({
|
||||
get: () => new Date(newsStore.selectedDatesStart),
|
||||
set: (to) => {
|
||||
newsStore.selectedDatesStart = to.getTime();
|
||||
},
|
||||
});
|
||||
watch(
|
||||
() => firstDate.value,
|
||||
(to) => {
|
||||
if (to.getTime() === 0) {
|
||||
selectedVariant.value = "";
|
||||
closeDateCompWindow();
|
||||
}
|
||||
}
|
||||
);
|
||||
|
||||
const secondDate = computed({
|
||||
get: () => new Date(newsStore.selectedDatesEnd),
|
||||
set: (to) => {
|
||||
newsStore.selectedDatesEnd = to.getTime();
|
||||
},
|
||||
});
|
||||
|
||||
watch(
|
||||
() => secondDate.value,
|
||||
(to) => {
|
||||
if (to.getTime() !== 0 && selectedVariant.value === "select") {
|
||||
selectedVariant.value = "selected";
|
||||
closeDateCompWindow();
|
||||
emitFiltration();
|
||||
}
|
||||
}
|
||||
);
|
||||
|
||||
const variants = [
|
||||
{ title: "Всё время", name: "all", callback: () => setDates("all") },
|
||||
{ title: "Неделя", name: "week", callback: () => setDates("week") },
|
||||
{ title: "Месяц", name: "month", callback: () => setDates("month") },
|
||||
{ title: "Год", name: "year", callback: () => setDates("year") },
|
||||
{ title: "Выбрать период", name: "select", callback: () => setDates("select") },
|
||||
];
|
||||
|
||||
function setDates(type) {
|
||||
let date = new Date();
|
||||
|
||||
switch (type) {
|
||||
case "all":
|
||||
firstDate.value = new Date(0);
|
||||
secondDate.value = new Date(0);
|
||||
selectedVariant.value = "";
|
||||
emitFiltration();
|
||||
closeMain();
|
||||
break;
|
||||
case "week":
|
||||
date.setDate(date.getDate() - 7);
|
||||
firstDate.value = date;
|
||||
secondDate.value = new Date();
|
||||
selectedVariant.value = "week";
|
||||
emitFiltration();
|
||||
closeMain();
|
||||
break;
|
||||
case "month":
|
||||
date.setMonth(date.getMonth() - 1);
|
||||
firstDate.value = date;
|
||||
secondDate.value = new Date();
|
||||
selectedVariant.value = "month";
|
||||
emitFiltration();
|
||||
closeMain();
|
||||
break;
|
||||
case "year":
|
||||
date.setFullYear(date.getFullYear() - 1);
|
||||
firstDate.value = date;
|
||||
secondDate.value = new Date();
|
||||
selectedVariant.value = "year";
|
||||
emitFiltration();
|
||||
closeMain();
|
||||
break;
|
||||
case "select":
|
||||
firstDate.value = new Date();
|
||||
secondDate.value = new Date(0);
|
||||
selectedVariant.value = "select";
|
||||
openDateCompWindow();
|
||||
break;
|
||||
}
|
||||
}
|
||||
|
||||
function dateToString(date) {
|
||||
let day = date.getDate();
|
||||
day = day < 10 ? "0" + day : day;
|
||||
let month = date.getMonth() + 1;
|
||||
month = month < 10 ? "0" + month : month;
|
||||
let year = date.getFullYear();
|
||||
return `${day}.${month}.${year}`;
|
||||
}
|
||||
|
||||
function emitFiltration() {
|
||||
newsStore.filterNeeded = true;
|
||||
}
|
||||
|
||||
// #region handling show states and showed values
|
||||
const mainShowed = ref(false);
|
||||
const dateCompWindowShowed = ref(false);
|
||||
const placeholder = ref("Выберите период времени");
|
||||
const selectedVariant = ref("");
|
||||
|
||||
function closeAll() {
|
||||
closeMain();
|
||||
closeDateCompWindow();
|
||||
}
|
||||
|
||||
function toggleMain() {
|
||||
mainShowed.value ? closeMain() : openMain();
|
||||
}
|
||||
|
||||
function openMain() {
|
||||
if (!mainShowed.value) {
|
||||
mainShowed.value = true;
|
||||
closeDateCompWindow();
|
||||
}
|
||||
}
|
||||
|
||||
function closeMain() {
|
||||
mainShowed.value = false;
|
||||
}
|
||||
|
||||
function openDateCompWindow() {
|
||||
closeMain();
|
||||
dateCompWindowShowed.value = true;
|
||||
}
|
||||
function closeDateCompWindow() {
|
||||
dateCompWindowShowed.value = false;
|
||||
if (selectedVariant.value === "Select") {
|
||||
selectedVariant.value = "";
|
||||
}
|
||||
}
|
||||
|
||||
const displayedValue = computed(() => {
|
||||
if (selectedVariant.value === "selected") {
|
||||
return `${dateToString(firstDate.value)} - ${dateToString(secondDate.value)}`;
|
||||
} else if (selectedVariant.value !== "") {
|
||||
return variants.find((x) => x.name === selectedVariant.value).title;
|
||||
} else {
|
||||
return placeholder.value;
|
||||
}
|
||||
});
|
||||
// #endregion
|
||||
</script>
|
||||
|
||||
<style lang="scss">
|
||||
.date-selector {
|
||||
$p: &;
|
||||
position: relative;
|
||||
|
||||
&__input-panel {
|
||||
display: flex;
|
||||
position: relative;
|
||||
justify-content: space-between;
|
||||
align-items: center;
|
||||
width: 100%;
|
||||
height: 40px;
|
||||
padding: 0 10px;
|
||||
color: color("main");
|
||||
border: 2px solid color("border");
|
||||
font-family: "Montserrat", sans-serif;
|
||||
font-size: 14px;
|
||||
cursor: pointer;
|
||||
}
|
||||
|
||||
&__toggle {
|
||||
position: absolute;
|
||||
width: 20px;
|
||||
height: 20px;
|
||||
right: 7px;
|
||||
top: 7px;
|
||||
transition: transform 0.2s ease;
|
||||
|
||||
&-icon {
|
||||
width: 100%;
|
||||
height: 100%;
|
||||
}
|
||||
}
|
||||
|
||||
&__content {
|
||||
position: relative;
|
||||
width: 100%;
|
||||
height: 176px;
|
||||
background: color("background");
|
||||
list-style: none;
|
||||
border: 2px solid color("border");
|
||||
overflow: auto;
|
||||
}
|
||||
|
||||
&__item {
|
||||
padding: 5px 10px;
|
||||
cursor: pointer;
|
||||
color: color("main");
|
||||
|
||||
&--selected {
|
||||
background: color("background-hover");
|
||||
}
|
||||
|
||||
@media (min-width: $mobileWidth) {
|
||||
&:hover {
|
||||
background: color("background-hover");
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
&__calendar {
|
||||
margin: 0 auto;
|
||||
}
|
||||
|
||||
&--showed {
|
||||
#{$p}__toggle {
|
||||
transform: rotate(180deg);
|
||||
}
|
||||
}
|
||||
|
||||
&--transition,
|
||||
&--transition-list {
|
||||
&-enter-active,
|
||||
&-leave-active {
|
||||
transition: height 0.18s;
|
||||
overflow-y: hidden;
|
||||
}
|
||||
&-enter-from,
|
||||
&-leave-to {
|
||||
height: 0;
|
||||
}
|
||||
}
|
||||
}
|
||||
</style>
|
94
src/components/NewsList/SideBar/SidebarContainer.vue
Normal file
94
src/components/NewsList/SideBar/SidebarContainer.vue
Normal file
@@ -0,0 +1,94 @@
|
||||
<template>
|
||||
<aside class="sidebar wrap" :class="uiStore.sidebarOpened ? 'sidebar--opened' : ''">
|
||||
<Transition name="sidebar__closer--transition">
|
||||
<div class="sidebar__closer" v-show="uiStore.sidebarOpened" @click="uiStore.closeSidebar"></div>
|
||||
</Transition>
|
||||
<Transition name="sidebar__container--transition">
|
||||
<div class="sidebar__container" v-show="uiStore.sidebarOpened">
|
||||
<SidebarContent />
|
||||
<font-awesome-icon class="sidebar__close-icon" icon="xmark" @click="uiStore.closeSidebar" />
|
||||
</div>
|
||||
</Transition>
|
||||
</aside>
|
||||
</template>
|
||||
<script setup>
|
||||
import SidebarContent from "@/components/NewsList/SideBar/SidebarContent.vue";
|
||||
// stores
|
||||
import { useUi } from "@/stores";
|
||||
const uiStore = useUi();
|
||||
</script>
|
||||
<style lang="scss">
|
||||
.sidebar {
|
||||
$p: &;
|
||||
position: fixed;
|
||||
top: 48px;
|
||||
max-height: calc(100vh - 53px);
|
||||
display: flex;
|
||||
justify-content: flex-end;
|
||||
pointer-events: none;
|
||||
z-index: 70;
|
||||
|
||||
&__container {
|
||||
position: relative;
|
||||
width: 350px;
|
||||
right: 0;
|
||||
padding: 11px 10px;
|
||||
pointer-events: all;
|
||||
background: color("background");
|
||||
overflow: auto;
|
||||
border: 2px solid color("border");
|
||||
|
||||
@media (max-width: $mobileWidth) {
|
||||
width: 100%;
|
||||
}
|
||||
|
||||
&--transition {
|
||||
&-enter-active,
|
||||
&-leave-active {
|
||||
transition: 0.2s;
|
||||
}
|
||||
&-enter-from,
|
||||
&-leave-to {
|
||||
right: -106%;
|
||||
opacity: 0;
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
&__closer {
|
||||
position: fixed;
|
||||
top: 0;
|
||||
right: 0;
|
||||
bottom: 0;
|
||||
left: 0;
|
||||
background: rgba(0, 0, 0, 0.5);
|
||||
pointer-events: all;
|
||||
|
||||
&--transition {
|
||||
&-enter-active,
|
||||
&-leave-active {
|
||||
transition: 0.2s;
|
||||
}
|
||||
&-enter-from,
|
||||
&-leave-to {
|
||||
opacity: 0;
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
&__close-icon {
|
||||
position: absolute;
|
||||
top: 8px;
|
||||
right: 10px;
|
||||
width: 25px;
|
||||
height: 25px;
|
||||
color: color("second");
|
||||
cursor: pointer;
|
||||
transition: color 0.18s ease;
|
||||
|
||||
&:hover {
|
||||
color: color("second-hover");
|
||||
}
|
||||
}
|
||||
}
|
||||
</style>
|
61
src/components/NewsList/SideBar/SidebarContent.vue
Normal file
61
src/components/NewsList/SideBar/SidebarContent.vue
Normal file
@@ -0,0 +1,61 @@
|
||||
<template>
|
||||
<div class="sidebar-content">
|
||||
<div class="sidebar-content__header">
|
||||
<div class="sidebar-content__header-text">Фильтры:</div>
|
||||
<div class="sidebar-content__reset" v-show="resetShowed" @click="resetFilters()">сбросить фильтры</div>
|
||||
</div>
|
||||
|
||||
<DateSelector />
|
||||
<CategoriesSelector />
|
||||
<TagsSelector />
|
||||
</div>
|
||||
</template>
|
||||
<script setup>
|
||||
import { computed } from "vue";
|
||||
// components
|
||||
import CategoriesSelector from "@/components/NewsList/SideBar/CategoriesSelector.vue";
|
||||
import TagsSelector from "@/components/NewsList/SideBar/TagsSelector.vue";
|
||||
import DateSelector from "@/components/NewsList/SideBar/DateSelector.vue";
|
||||
// stores
|
||||
import { useNews } from "@/stores";
|
||||
const newsStore = useNews();
|
||||
|
||||
const resetShowed = computed(
|
||||
() => newsStore.selectedCategoriesIds.length > 0 || newsStore.selectedTagsIds.length > 0 || newsStore.selectedDatesEnd > 0
|
||||
);
|
||||
|
||||
function resetFilters() {
|
||||
newsStore.selectedCategoriesIds = [];
|
||||
newsStore.selectedTagsIds = [];
|
||||
newsStore.selectedDatesStart = 0;
|
||||
newsStore.selectedDatesEnd = 0;
|
||||
newsStore.filterNeeded = true;
|
||||
}
|
||||
</script>
|
||||
<style lang="scss">
|
||||
.sidebar-content {
|
||||
display: flex;
|
||||
flex-direction: column;
|
||||
gap: 10px;
|
||||
|
||||
&__header {
|
||||
display: flex;
|
||||
gap: 20px;
|
||||
line-height: normal;
|
||||
width: 85%;
|
||||
}
|
||||
|
||||
&__header-text {
|
||||
flex: 1;
|
||||
}
|
||||
|
||||
&__reset {
|
||||
cursor: pointer;
|
||||
transition: color 0.18s ease;
|
||||
|
||||
&:hover {
|
||||
color: color("second");
|
||||
}
|
||||
}
|
||||
}
|
||||
</style>
|
243
src/components/NewsList/SideBar/TagsSelector.vue
Normal file
243
src/components/NewsList/SideBar/TagsSelector.vue
Normal file
@@ -0,0 +1,243 @@
|
||||
<template>
|
||||
<Transition name="tags-selector--transition">
|
||||
<div
|
||||
class="tags-selector"
|
||||
v-if="newsStore.possibleTags.length > 0"
|
||||
:class="{ 'tags-selector--active': active }"
|
||||
v-click-outside="deactivate"
|
||||
>
|
||||
<div class="tags-selector__input-panel" @click.self="activate">
|
||||
<ul class="tags-selector__selected-list" v-show="tagsSelected.length > 0" @click.self="activate">
|
||||
<li v-for="tag in tagsSelected" class="tags-selector__selected-item" :key="tag.id">
|
||||
<div class="tags-selector__selected-item-text">{{ tag.title }}</div>
|
||||
<font-awesome-icon class="tags-selector__selected-item-icon" icon="xmark" @click="toggleSelection(tag.id)" />
|
||||
</li>
|
||||
</ul>
|
||||
|
||||
<div class="tags-selector__input-container" v-show="tagsSelected.length === 0 || active" @click.self="activate">
|
||||
<div class="tags-selector__label" @click.self="activate">{{ label }}</div>
|
||||
<input class="tags-selector__input" :placeholder="placeholder" ref="refInput" v-model="filter" />
|
||||
</div>
|
||||
<div class="tags-selector__toggle" @click="toggleActivation">
|
||||
<font-awesome-icon class="tags-selector__toggle-icon" icon="chevron-down" />
|
||||
</div>
|
||||
</div>
|
||||
|
||||
<Transition v-show="active" name="tags-selector__content--transition-content">
|
||||
<ul class="tags-selector__content" :style="styleHeight">
|
||||
<li
|
||||
v-for="tag in tagsFiltered"
|
||||
class="tags-selector__item"
|
||||
:class="newsStore.selectedTagsIds.includes(tag.id) ? 'tags-selector__item--selected' : ''"
|
||||
:key="tag.id"
|
||||
@click="toggleSelection(tag.id)"
|
||||
>
|
||||
{{ tag.title }}
|
||||
</li>
|
||||
<li class="tags-selector__no-items" v-show="tagsFiltered.length === 0">Тегов не найдено</li>
|
||||
</ul>
|
||||
</Transition>
|
||||
</div>
|
||||
</Transition>
|
||||
</template>
|
||||
<script setup>
|
||||
import { computed, nextTick, ref } from "vue";
|
||||
// directives
|
||||
import vClickOutside from "@/directives/ClickOutside";
|
||||
// stores
|
||||
import { useNews } from "@/stores";
|
||||
const newsStore = useNews();
|
||||
|
||||
const active = ref(false);
|
||||
const placeholder = ref("Выберите теги");
|
||||
const filter = ref("");
|
||||
|
||||
const styleHeight = computed(() => ({
|
||||
"--height": (newsStore.possibleTags.length > 5 ? 200 : 34.4 * newsStore.possibleTags.length + 4) + "px",
|
||||
}));
|
||||
const tagsFiltered = computed(() => newsStore.possibleTags.filter((x) => x.title.includes(filter.value)));
|
||||
const tagsSelected = computed(() => newsStore.possibleTags.filter((x) => newsStore.selectedTagsIds.includes(x.id)));
|
||||
|
||||
const refInput = ref(null);
|
||||
|
||||
const label = computed(() => {
|
||||
if (newsStore.selectedTagsIds.length > 0) {
|
||||
return "";
|
||||
} else {
|
||||
return placeholder.value;
|
||||
}
|
||||
});
|
||||
|
||||
function activate() {
|
||||
if (!active.value) {
|
||||
active.value = true;
|
||||
|
||||
if (window.innerWidth > 768) {
|
||||
nextTick(() => {
|
||||
refInput.value.focus();
|
||||
});
|
||||
}
|
||||
}
|
||||
}
|
||||
function deactivate() {
|
||||
active.value = false;
|
||||
}
|
||||
function toggleActivation() {
|
||||
active.value ? deactivate() : activate();
|
||||
}
|
||||
|
||||
function toggleSelection(id) {
|
||||
if (newsStore.selectedTagsIds.includes(id)) {
|
||||
newsStore.selectedTagsIds = newsStore.selectedTagsIds.filter((x) => x !== id);
|
||||
} else {
|
||||
newsStore.selectedTagsIds = [...newsStore.selectedTagsIds, id];
|
||||
}
|
||||
newsStore.filterNeeded = true;
|
||||
}
|
||||
</script>
|
||||
<style lang="scss">
|
||||
.tags-selector {
|
||||
$p: &;
|
||||
position: relative;
|
||||
|
||||
&__selected-list {
|
||||
display: flex;
|
||||
gap: 4px;
|
||||
flex-wrap: wrap;
|
||||
margin: 5.62px 0;
|
||||
list-style: none;
|
||||
}
|
||||
|
||||
&__selected-item {
|
||||
display: inline-flex;
|
||||
align-items: center;
|
||||
gap: 5px;
|
||||
padding: 0 4px;
|
||||
border: 1px solid color("border");
|
||||
|
||||
&-icon {
|
||||
cursor: pointer;
|
||||
|
||||
&:hover {
|
||||
color: color("second-hover");
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
&__input-panel {
|
||||
position: relative;
|
||||
width: 100%;
|
||||
min-height: 40px;
|
||||
padding: 0 10px;
|
||||
color: color("main");
|
||||
border: 2px solid color("border");
|
||||
font-family: "Montserrat", sans-serif;
|
||||
font-size: 14px;
|
||||
}
|
||||
|
||||
&__input-container {
|
||||
display: flex;
|
||||
justify-content: space-between;
|
||||
align-items: center;
|
||||
|
||||
height: 36px;
|
||||
}
|
||||
|
||||
&__label {
|
||||
}
|
||||
|
||||
&__input {
|
||||
display: none;
|
||||
width: 100%;
|
||||
height: 100%;
|
||||
margin: 9px 0;
|
||||
background: color("background");
|
||||
color: color("main");
|
||||
outline: none;
|
||||
border: none;
|
||||
font-size: 14px;
|
||||
|
||||
&::placeholder {
|
||||
color: color("second");
|
||||
font-family: "Montserrat", sans-serif;
|
||||
}
|
||||
}
|
||||
|
||||
&__toggle {
|
||||
position: absolute;
|
||||
width: 20px;
|
||||
height: 20px;
|
||||
right: 7px;
|
||||
top: 7px;
|
||||
transition: transform 0.2s ease;
|
||||
|
||||
&-icon {
|
||||
width: 100%;
|
||||
height: 100%;
|
||||
}
|
||||
}
|
||||
|
||||
&__content {
|
||||
position: relative;
|
||||
width: 100%;
|
||||
height: var(--height);
|
||||
background: color("background");
|
||||
list-style: none;
|
||||
border: 2px solid color("border");
|
||||
overflow: auto;
|
||||
|
||||
&--transition-content,
|
||||
&--transition-list {
|
||||
&-enter-active,
|
||||
&-leave-active {
|
||||
transition: height 0.18s;
|
||||
overflow-y: hidden;
|
||||
}
|
||||
&-enter-from,
|
||||
&-leave-to {
|
||||
height: 0;
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
&__item {
|
||||
padding: 5px 10px;
|
||||
cursor: pointer;
|
||||
color: color("main");
|
||||
|
||||
&--selected {
|
||||
background: color("background-hover");
|
||||
}
|
||||
|
||||
@media (min-width: $mobileWidth) {
|
||||
&--selected {
|
||||
&:hover {
|
||||
background: color("background-hover", $saturation: 20%);
|
||||
}
|
||||
}
|
||||
|
||||
&:not(&--selected) {
|
||||
&:hover {
|
||||
background: color("background-hover", $hue: 120deg, $saturation: 20%);
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
&__no-items {
|
||||
padding: 5px 10px;
|
||||
}
|
||||
|
||||
&--active {
|
||||
#{$p}__input {
|
||||
display: block;
|
||||
}
|
||||
#{$p}__label {
|
||||
display: none;
|
||||
}
|
||||
#{$p}__toggle {
|
||||
transform: rotate(180deg);
|
||||
}
|
||||
}
|
||||
}
|
||||
</style>
|
Reference in New Issue
Block a user