first commit

This commit is contained in:
Victor
2023-04-26 17:51:05 +03:00
commit 9494762e86
93 changed files with 27719 additions and 0 deletions

View 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>

View 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>

View 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>

View 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>

View 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>

View 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>

View 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>

View 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>

View 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>

View 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>

View 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>