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

25
.gitignore vendored Normal file
View File

@ -0,0 +1,25 @@
.DS_Store
node_modules
/dist
/build
/android
/ios
# local env files
.env.local
.env.*.local
# Log files
npm-debug.log*
yarn-debug.log*
yarn-error.log*
pnpm-debug.log*
# Editor directories and files
.idea
.vscode
*.suo
*.ntvs*
*.njsproj
*.sln
*.sw?

1
README.md Normal file
View File

@ -0,0 +1 @@
# dnr.one

3
babel.config.js Normal file
View File

@ -0,0 +1,3 @@
module.exports = {
presets: ["@vue/cli-plugin-babel/preset"],
};

6
capacitor.config.json Normal file
View File

@ -0,0 +1,6 @@
{
"appId": "com.craftgroup.dnrone",
"appName": "dnr.one",
"webDir": "dist",
"bundledWebRuntime": false
}

205
db.json Normal file
View File

@ -0,0 +1,205 @@
{
"postsInfo": {
"all": [1, 2, 3, 4, 5, 6, 7, 8, 9, 10, 11, 12, 13, 14, 15, 16],
"categories": [
{
"id": 1,
"title": "category1",
"posts": [1, 5, 9, 13],
"tags": [
{
"id": 1,
"title": "tag1",
"posts": [1, 5, 9]
},
{
"id": 2,
"title": "tag2",
"posts": [5, 9, 13]
}
]
},
{
"id": 2,
"title": "category2",
"posts": [2, 6, 10, 14],
"tags": [
{
"id": 3,
"title": "tag3",
"posts": [2, 6, 10]
},
{
"id": 4,
"title": "tag4",
"posts": [6, 10, 14]
}
]
},
{
"id": 3,
"title": "category3",
"posts": [3, 7, 11, 15],
"tags": [
{
"id": 5,
"title": "tag5",
"posts": [3, 7, 11]
},
{
"id": 6,
"title": "tag6",
"posts": [7, 11, 15]
}
]
},
{
"id": 4,
"title": "category4",
"posts": [4, 8, 12, 16],
"tags": [
{
"id": 7,
"title": "tag7",
"posts": [4, 8, 12]
},
{
"id": 8,
"title": "tag8",
"posts": [8, 12, 16]
}
]
}
]
},
"posts": [
{
"id": 1,
"category": 1,
"tags": [1],
"title": "sunt aut facere repellat provident occaecati excepturi optio reprehenderit",
"body": "quia et suscipit\nsuscipit recusandae consequuntur expedita et cum\nreprehenderit molestiae ut ut quas totam\nnostrum rerum est autem sunt rem eveniet architecto",
"imageUrl": "https://via.placeholder.com/600/92c951"
},
{
"id": 2,
"category": 2,
"tags": [3],
"title": "qui est esse",
"body": "est rerum tempore vitae\nsequi sint nihil reprehenderit dolor beatae ea dolores neque\nfugiat blanditiis voluptate porro vel nihil molestiae ut reiciendis",
"imageUrl": "https://via.placeholder.com/600/771796"
},
{
"id": 3,
"category": 3,
"tags": [5],
"title": "ea molestias quasi exercitationem repellat qui ipsa sit aut",
"body": "et iusto sed quo iure\nvoluptatem occaecati omnis eligendi aut ad\nvoluptatem doloribus vel accusantium quis pariatur\nmolestiae porro eius odio et labore et velit aut",
"imageUrl": "https://via.placeholder.com/600/24f355"
},
{
"id": 4,
"category": 4,
"tags": [7],
"title": "eum et est occaecati",
"body": "ullam et saepe reiciendis voluptatem adipisci\nsit amet autem assumenda provident rerum culpa\nquis hic commodi nesciunt rem tenetur doloremque ipsam iure",
"imageUrl": "https://via.placeholder.com/600/d32776"
},
{
"id": 5,
"category": 1,
"tags": [1, 2],
"title": "nesciunt quas odio",
"body": "repudiandae veniam quaerat sunt sed\nalias aut fugiat sit autem sed est\nvoluptatem omnis possimus esse voluptatibus quis\nest aut tenetur dolor neque",
"imageUrl": "https://via.placeholder.com/600/f66b97"
},
{
"id": 6,
"category": 2,
"tags": [3, 4],
"title": "dolorem eum magni eos aperiam quia",
"body": "ut aspernatur corporis harum nihil quis provident sequi\nmollitia nobis aliquid molestiae\nperspiciatis et ea nemo ab reprehenderit accusantium quas",
"imageUrl": "https://via.placeholder.com/600/56a8c2"
},
{
"id": 7,
"category": 3,
"tags": [5, 6],
"title": "magnam facilis autem",
"body": "dolore placeat quibusdam ea quo vitae\nmagni quis enim qui quis quo nemo aut saepe\nquidem repellat excepturi ut quia\nsunt ut sequi eos ea sed quas",
"imageUrl": "https://via.placeholder.com/600/b0f7cc"
},
{
"id": 8,
"category": 4,
"tags": [7, 8],
"title": "dolorem dolore est ipsam",
"body": "dignissimos aperiam dolorem qui eum\nfacilis quibusdam animi sint suscipit qui sint possimus cum\nquaerat magni maiores excepturi",
"imageUrl": "https://via.placeholder.com/600/54176f"
},
{
"id": 9,
"category": 1,
"tags": [1, 2],
"title": "nesciunt iure omnis dolorem tempora et accusantium",
"body": "consectetur animi nesciunt iure dolore\nenim quia ad\nveniam autem ut quam aut nobis\net est aut quod aut provident voluptas autem voluptas",
"imageUrl": "https://via.placeholder.com/600/51aa97"
},
{
"id": 10,
"category": 2,
"tags": [3, 4],
"title": "optio molestias id quia eum",
"body": "quo et expedita modi cum officia vel magni\ndoloribus qui repudiandae\nvero nisi sit\nquos veniam quod sed accusamus veritatis error",
"imageUrl": "https://via.placeholder.com/600/810b14"
},
{
"id": 11,
"category": 3,
"tags": [5, 6],
"title": "et ea vero quia laudantium autem",
"body": "delectus reiciendis molestiae occaecati non minima eveniet qui voluptatibus\naccusamus in eum beatae sit",
"imageUrl": "https://via.placeholder.com/600/1ee8a4"
},
{
"id": 12,
"category": 4,
"tags": [7, 8],
"title": "in quibusdam tempore odit est dolorem",
"body": "itaque id aut magnam\npraesentium quia et ea odit et ea voluptas et\nsapiente quia nihil amet occaecati quia id voluptatem\nincidunt ea est distinctio odio",
"imageUrl": "https://via.placeholder.com/600/66b7d2"
},
{
"id": 13,
"category": 1,
"tags": [2],
"title": "dolorum ut in voluptas mollitia et saepe quo animi",
"body": "aut dicta possimus sint mollitia voluptas commodi quo doloremque\niste corrupti reiciendis voluptatem eius rerum",
"imageUrl": "https://via.placeholder.com/600/197d29"
},
{
"id": 14,
"category": 2,
"tags": [4],
"title": "voluptatem eligendi optio",
"body": "fuga et accusamus dolorum perferendis illo voluptas\nnon doloremque neque facere\nad qui dolorum molestiae beatae\nsed aut voluptas totam sit illum",
"imageUrl": "https://via.placeholder.com/600/61a65"
},
{
"id": 15,
"category": 3,
"tags": [6],
"title": "eveniet quod temporibus",
"body": "reprehenderit quos placeat\nvelit minima officia dolores impedit repudiandae molestiae nam\nvoluptas recusandae quis delectus",
"imageUrl": "https://via.placeholder.com/600/f9cee5"
},
{
"id": 16,
"category": 4,
"tags": [8],
"title": "sint suscipit perspiciatis velit dolorum rerum ipsa laboriosam odio",
"body": "suscipit nam nisi quo aperiam aut\nasperiores eos fugit maiores voluptatibus quia\nvoluptatem quis ullam qui in alias quia est",
"imageUrl": "https://via.placeholder.com/600/fdf73e"
}
]
}

7
ionic.config.json Normal file
View File

@ -0,0 +1,7 @@
{
"name": "dnr.one",
"integrations": {
"capacitor": {}
},
"type": "custom"
}

19
jsconfig.json Normal file
View File

@ -0,0 +1,19 @@
{
"compilerOptions": {
"target": "es5",
"module": "esnext",
"baseUrl": "./",
"moduleResolution": "node",
"paths": {
"@/*": [
"src/*"
]
},
"lib": [
"esnext",
"dom",
"dom.iterable",
"scripthost"
]
}
}

21135
package-lock.json generated Normal file

File diff suppressed because it is too large Load Diff

68
package.json Normal file
View File

@ -0,0 +1,68 @@
{
"name": "dnr.one",
"version": "0.1.0",
"private": true,
"scripts": {
"serve": "vue-cli-service serve",
"build": "vue-cli-service build",
"lint": "vue-cli-service lint",
"deploy": "gh-pages -d build",
"cors-proxy": "lcp --proxyUrl https://front.dnr.one",
"ionic:build": "npm run build",
"ionic:serve": "npm run serve"
},
"dependencies": {
"@capacitor/android": "^2.2.1",
"@capacitor/cli": "^2.2.1",
"@capacitor/core": "^2.2.1",
"@capacitor/ios": "^2.2.1",
"@fortawesome/fontawesome-svg-core": "^6.1.1",
"@fortawesome/free-brands-svg-icons": "^6.1.1",
"@fortawesome/free-regular-svg-icons": "^6.1.1",
"@fortawesome/free-solid-svg-icons": "^6.1.1",
"@fortawesome/vue-fontawesome": "^3.0.0-5",
"axios": "^0.26.1",
"core-js": "^3.8.3",
"pinia": "^2.0.12",
"vue": "^3.2.31",
"vue-router": "^4.0.3"
},
"devDependencies": {
"@babel/core": "^7.12.16",
"@babel/eslint-parser": "^7.12.16",
"@vue/cli-plugin-babel": "~5.0.0",
"@vue/cli-plugin-eslint": "~5.0.0",
"@vue/cli-plugin-router": "~5.0.0",
"@vue/cli-plugin-vuex": "~5.0.0",
"@vue/cli-service": "~5.0.0",
"eslint": "^7.32.0",
"eslint-plugin-vue": "^8.0.3",
"sass": "^1.49.9",
"sass-loader": "^12.6.0",
"vue-cli-plugin-pinia": "^0.1.2"
},
"eslintConfig": {
"root": true,
"env": {
"node": true
},
"extends": [
"plugin:vue/essential",
"eslint:recommended"
],
"parserOptions": {
"parser": "@babel/eslint-parser"
},
"rules": {
"vue/no-v-for-template-key": "off",
"vue/no-multiple-template-root": "off",
"vue/no-v-model-argument": "off"
}
},
"browserslist": [
"> 1%",
"last 2 versions",
"not dead",
"not ie 11"
]
}

BIN
public/favicon-16x16.png Normal file

Binary file not shown.

After

Width:  |  Height:  |  Size: 472 B

BIN
public/favicon-32x32.png Normal file

Binary file not shown.

After

Width:  |  Height:  |  Size: 479 B

26
public/index.html Normal file
View File

@ -0,0 +1,26 @@
<!DOCTYPE html>
<html lang="">
<head>
<meta charset="utf-8" />
<meta http-equiv="X-UA-Compatible" content="IE=edge" />
<meta
name="viewport"
content="height=device-height, width=device-width, initial-scale=1.0, minimum-scale=1.0, maximum-scale=1.0, user-scalable=no, target-densitydpi=device-dpi"
/>
<% if (process.env.NODE_ENV !== 'production') { %>
<meta
http-equiv="Content-Security-Policy"
content="connect-src 'self' front.dnr.one api.openweathermap.org 109.254.92.90:8010 ws:;"
/>
<% } %>
<link rel="icon" type="image/png" sizes="32x32" href="<%= BASE_URL %>favicon-32x32.png" />
<link rel="icon" type="image/png" sizes="16x16" href="<%= BASE_URL %>favicon-16x16.png" />
<title>DNR.ONE</title>
</head>
<body>
<noscript>
<strong>We're sorry but this site doesn't work properly without JavaScript enabled. Please enable it to continue.</strong>
</noscript>
<div id="app"></div>
</body>
</html>

140
src/App.vue Normal file
View File

@ -0,0 +1,140 @@
<template>
<div class="app">
<MobileMenuContainer />
<div class="page" :class="{ 'page--mobile-menu-opened': mmOpened }">
<HeaderContainer class="page__header" />
<div class="page__container wrap">
<router-view v-slot="{ Component }">
<keep-alive include="NewsListContainer">
<component :is="Component" />
</keep-alive>
</router-view>
</div>
</div>
</div>
</template>
<script setup>
import HeaderContainer from "@/components/Header/HeaderContainer.vue";
import MobileMenuContainer from "@/components/MobileMenu/MobileMenuContainer.vue";
import { useUi, useUser } from "@/stores";
import { storeToRefs } from "pinia";
import { watch } from "vue";
const uiStore = useUi();
const userStore = useUser();
const { mobileMenuOpened: mmOpened, sidebarOpened: sbOpened } = storeToRefs(uiStore);
userStore.loadUserData();
watch(mmOpened, (to) => {
document.body.classList.toggle("body--mm-opened", to);
});
watch(sbOpened, (to) => {
if (to) {
document.body.classList.toggle("body--sb-opened", to);
} else {
setTimeout(() => {
document.body.classList.toggle("body--sb-opened", to);
}, 180);
}
});
// window.addEventListener("error", (msg, url, linenumber) => {
// alert("Error message: " + msg + "\nURL: " + url + "\nLine Number: " + linenumber);
// return true;
// });
// #region handling scrollbar toggle
function getScrollbarWidth() {
const outer = document.createElement("div");
outer.style.visibility = "hidden";
outer.style.overflow = "scroll";
outer.style.msOverflowStyle = "scrollbar";
document.body.appendChild(outer);
const inner = document.createElement("div");
outer.appendChild(inner);
const scrollbarWidth = outer.offsetWidth - inner.offsetWidth;
outer.parentNode.removeChild(outer);
return scrollbarWidth;
}
document.querySelector(":root").style.setProperty("--scrollbar-width", getScrollbarWidth() + "px");
// #endregion
</script>
<style lang="scss">
@import "@/styles/themes.scss";
* {
margin: 0;
padding: 0;
box-sizing: border-box;
}
body {
font-family: "Roboto", sans-serif;
line-height: 1.625;
font-size: 15px;
background: color("background");
color: color("main");
overflow-x: hidden;
overflow-y: scroll;
&.body--mm-opened {
position: relative;
overflow-y: hidden;
}
&.body--sb-opened,
&.body--modal-opened {
position: relative;
overflow-y: hidden;
padding-right: var(--scrollbar-width);
}
}
p {
margin-top: 10px;
}
.wrap {
width: 945px;
max-width: 97%;
margin-left: auto;
margin-right: auto;
@media (max-width: $tabletWidth) {
width: 750px;
}
}
.page {
position: relative;
display: flex;
top: 0;
left: 0;
min-height: 100vh;
flex-direction: column;
background: color("background");
transition: left 0.4s ease;
&__container {
display: flex;
flex-direction: column;
flex: 1;
padding-top: 48px;
}
&__content {
flex: 1;
}
&--mobile-menu-opened {
left: 240px;
overflow: hidden;
@media (max-width: 550px) {
left: 80%;
}
}
}
</style>

Binary file not shown.

Binary file not shown.

Binary file not shown.

Binary file not shown.

Binary file not shown.

Binary file not shown.

Binary file not shown.

Binary file not shown.

Binary file not shown.

Binary file not shown.

Binary file not shown.

Binary file not shown.

BIN
src/assets/gif/loader.gif Normal file

Binary file not shown.

After

Width:  |  Height:  |  Size: 885 B

Binary file not shown.

After

Width:  |  Height:  |  Size: 1.1 KiB

BIN
src/assets/images/logo.png Normal file

Binary file not shown.

After

Width:  |  Height:  |  Size: 13 KiB

Binary file not shown.

After

Width:  |  Height:  |  Size: 6.0 KiB

View File

@ -0,0 +1,175 @@
<template>
<div class="likes-comp">
<div class="likes-comp__group" @click="toggle('like')">
<font-awesome-icon
class="likes-comp__button"
:class="{ 'likes-comp__button--user-liked': state.status.like }"
icon="thumbs-up"
/>
<div class="likes-comp__count">{{ state.count.like }}</div>
</div>
<div class="likes-comp__group" v-if="dislike != null" @click="toggle('dislike')">
<font-awesome-icon
class="likes-comp__button"
:class="{ 'likes-comp__button--user-disliked': state.status.dislike }"
icon="thumbs-down"
/>
<div class="likes-comp__count">{{ state.count.dislike }}</div>
</div>
<ModalComp ref="refModal"></ModalComp>
</div>
</template>
<script setup>
import { defineProps, onMounted, ref, watch } from "vue";
// composables
import { useLikesApi } from "@/composables/api/likes.js";
const api = useLikesApi();
// stores
import { useUser } from "@/stores";
const userStore = useUser();
const props = defineProps(["scope", "id", "like", "dislike", "user_like", "user_dislike"]);
const state = ref({
count: {
like: 0,
dislike: 0,
},
status: {
like: false,
dislike: false,
},
});
const refModal = ref(null);
function toggle(type) {
if (state.value.status[type]) {
remove(type);
} else {
set(type);
}
}
async function set(type) {
if (!userStore.loggedIn) {
refModal.value.open({ type: "authorization_required" });
return;
}
let success = await api.set(props.scope, type, props.id);
if (success && !state.value.status[type]) {
state.value.count[type]++;
state.value.status[type] = true;
oppositeActionIfNeeded(type);
}
}
async function remove(type) {
if (!userStore.loggedIn) {
refModal.value.open({ type: "authorization_required" });
return;
}
let success = await api.remove(props.scope, type, props.id);
if (success && state.value.status[type]) {
state.value.count[type]--;
state.value.status[type] = false;
}
}
async function checkStatusForUser(type) {
if (props.scope === "news") {
state.value.status[type] = await api.checkStatusForUser(props.scope, type, props.id);
} else if (props.scope === "comment") {
state.value.status[type] = type === "like" ? props.user_like : props.user_dislike;
}
}
function oppositeActionIfNeeded(type) {
if (props.scope === "comment") {
let oppositeType = type === "like" ? "dislike" : "like";
if (state.value.status[oppositeType]) {
state.value.count[oppositeType]--;
state.value.status[oppositeType] = false;
}
}
return true;
}
watch(
() => props.like,
(newVal) => {
state.value.count.like = Number(newVal);
}
);
watch(
() => props.dislike,
(newVal) => {
state.value.count.dislike = Number(newVal);
}
);
onMounted(() => {
state.value.count.like = Number(props.like);
state.value.count.dislike = Number(props.dislike);
if (props.like != null) {
checkStatusForUser("like");
}
if (props.dislike != null) {
checkStatusForUser("dislike");
}
});
</script>
<style lang="scss">
.likes-comp {
$p: &;
display: flex;
gap: 10px;
&__group {
display: flex;
align-items: center;
cursor: pointer;
&:hover {
#{$p}__button {
color: color("second-hover");
&--user-liked {
color: color("link-hover");
}
&--user-disliked {
color: color("warn-hover");
}
}
#{$p}__count {
color: color("second-hover");
}
}
}
&__button {
margin-right: 5px;
color: color("second");
transition: color 0.18s ease;
&--user-liked {
color: color("link");
}
&--user-disliked {
color: color("warn");
}
}
&__count {
color: color("second");
}
}
</style>

View File

@ -0,0 +1,141 @@
<template>
<div class="post-meta">
<div class="post-meta__date" v-if="published_date">{{ date }}</div>
<div class="post-meta__categories" v-if="category && category.length > 0">
<div class="post-meta__categories-item">
<LinkComp :to="`/?categories=${category?.[0]?.id}`" class="post-meta__categories-item-link" @click.prevent="setCategory">
{{ category?.[0]?.title }}
</LinkComp>
</div>
</div>
<div class="post-meta__tags" v-if="tags && tags.length > 0">
<template v-for="tag in tags" :key="tag">
<div class="post-meta__tags-item" :class="{ 'post-meta__tags-item--active': newsStore.selectedTagsIds.includes(tag.id) }">
<LinkComp
class="post-meta__tags-item-link"
:to="`/?categories=${category?.[0]?.id}&tags=${tag.id}`"
@click.prevent="setTag(tag.id)"
>
{{ tag.title }}
</LinkComp>
<font-awesome-icon
class="post-meta__tags-item-icon"
icon="plus"
v-if="!newsStore.selectedTagsIds.includes(tag.id)"
@click.prevent="addTag(tag.id)"
/>
<font-awesome-icon class="post-meta__tags-item-icon" icon="xmark" v-else @click.prevent="removeTag(tag.id)" />
</div>
</template>
</div>
</div>
</template>
<script setup>
import { computed, defineProps, nextTick } from "vue";
// stores
import { useNews } from "@/stores";
const newsStore = useNews();
// router
import { useRouter } from "vue-router";
const router = useRouter();
const props = defineProps(["id", "category", "tags", "published_date"]);
const date = computed(() => {
if (props.published_date) {
let date = new Date(props.published_date * 1000);
return date.toISOString().replace(/(\d{4})-(\d{2})-(\d{2})T(\d{2}):(\d{2}):(\d{2}).*/, "$4:$5 $3.$2.$1");
}
return "";
});
function setCategory() {
newsStore.resetFilters({ selectedCategoriesIds: [props.category[0].id] });
nextTick(() => router.push(`/?categories=${props.category[0].id}`));
}
function setTag(tagId) {
newsStore.resetFilters({ selectedCategoriesIds: [props.category[0].id], selectedTagsIds: [tagId] });
nextTick(() => router.push(`/?categories=${props.category[0].id}&tags=${tagId}`));
}
function addTag(tagId) {
if (newsStore.selectedCategoriesIds.length === 0) {
setTag(tagId);
} else {
newsStore.selectedTagsIds.push(tagId);
newsStore.pageReseted = true;
nextTick(() =>
router.push(`/?categories=${newsStore.selectedCategoriesIds.join(",")}&tags=${newsStore.selectedTagsIds.join(",")}`)
);
}
}
function removeTag(tagId) {
newsStore.selectedTagsIds = newsStore.selectedTagsIds.filter((x) => x !== tagId);
newsStore.pageReseted = true;
nextTick(() => router.push(`/?categories=${newsStore.selectedCategoriesIds.join(",")}&tags=${newsStore.selectedTagsIds.join(",")}`));
}
</script>
<style lang="scss">
.post-meta {
$p: &;
display: flex;
flex-direction: column;
gap: 3px;
color: color("second");
font-size: 12px;
&__tags {
display: flex;
gap: 6px;
}
&__categories-item {
display: inline-flex;
align-items: center;
gap: 5px;
font-size: 14px;
//padding: 2px 6px;
//border: 1px solid color("link");
//border-radius: 3px;
// &:hover {
// border: 1px solid color("link-hover");
// }
}
&__tags-item {
display: inline-flex;
align-items: center;
gap: 3px;
&:not(&--active) {
#{$p}__tags-item-link {
color: color("main");
}
}
}
&__tags-item-icon {
color: color("second");
cursor: pointer;
transition: color 0.18s ease;
&:hover {
color: color("second-hover");
}
}
&__tags-item-link {
&:hover {
color: color("second") !important;
}
}
}
</style>

View File

@ -0,0 +1,162 @@
<template>
<div class="social-comp">
<LikesComp class="social-comp__likes" :scope="'news'" :id="id" :like="like"></LikesComp>
<router-link class="social-comp__link" :to="{ path: `/post/${id}`, hash: '#comments' }" v-if="comments_count != null">
<font-awesome-icon class="social-comp__icon" icon="comment" />
{{ comments_count }}
</router-link>
<div class="social-comp__views" v-if="views != null">
<font-awesome-icon class="social-comp__icon" icon="eye" />
{{ views }}
</div>
<div class="social-comp__share" @click="openShare()">
<font-awesome-icon class="social-comp__icon" icon="share-from-square" />
share
</div>
<ModalComp ref="refModal">
<div class="social-comp__modal">
Share:
<a class="social-comp__modal-link" :href="telegramLink" target="popup" @click.prevent="openPopup(telegramLink)">
<span class="social-comp__modal-text">Telegram</span>
<font-awesome-icon class="social-comp__modal-icon" :icon="['fab', 'telegram']" />
</a>
<a class="social-comp__modal-link" :href="vkLink" target="popup" @click.prevent="openPopup(vkLink)">
<span class="social-comp__modal-text">Vk</span>
<font-awesome-icon class="social-comp__modal-icon" :icon="['fab', 'vk']" />
</a>
<a class="social-comp__modal-link" :href="facebookLink" target="popup" @click.prevent="openPopup(facebookLink)">
<span class="social-comp__modal-text">Facebook</span>
<font-awesome-icon class="social-comp__modal-icon" :icon="['fab', 'facebook']" />
</a>
<div class="social-comp__modal-link" @click="copyLink()">
<span class="social-comp__modal-text">Copy URL</span>
<font-awesome-icon class="social-comp__modal-icon" icon="link" />
</div>
</div>
</ModalComp>
</div>
</template>
<script setup>
import { computed, defineProps, ref } from "vue";
import LikesComp from "@/components/Common/LikesComp.vue";
const props = defineProps(["id", "like", "comments_count", "views"]);
const refModal = ref(null);
const currentLink = computed(() => encodeURIComponent(location.origin + "/post/" + props.id));
const telegramLink = computed(() => `https://telegram.me/share/url?url=${currentLink.value}&text=DNR.ONE`);
const facebookLink = computed(() => `https://www.facebook.com/sharer.php?u=${currentLink.value}`);
const vkLink = computed(() => `https://vk.com/share.php?url=${currentLink.value}`);
function openShare() {
if (navigator.share) {
navigator.share({
url: window.location.href,
});
} else {
refModal.value.open();
}
}
function openPopup(link) {
window.open(link, "name", "width=600,height=500");
refModal.value.close();
}
function copyLink() {
copyToClipboard(location.origin + "/post/" + props.id);
refModal.value.close();
}
function copyToClipboard(textToCopy) {
// navigator clipboard api needs a secure context (https)
if (navigator.clipboard && window.isSecureContext) {
// navigator clipboard api method'
return navigator.clipboard.writeText(textToCopy);
} else {
// text area method
let textArea = document.createElement("textarea");
textArea.value = textToCopy;
// make the textarea out of viewport
textArea.style.position = "fixed";
textArea.style.left = "-999999px";
textArea.style.top = "-999999px";
document.body.appendChild(textArea);
textArea.focus();
textArea.select();
return new Promise((res, rej) => {
// here the magic happens
document.execCommand("copy") ? res() : rej();
textArea.remove();
});
}
}
</script>
<style lang="scss">
.social-comp {
display: flex;
gap: 15px;
color: color("second");
font-size: 14px;
align-items: center;
&__link {
color: inherit;
text-decoration: none;
transition: color 0.18s ease;
&:hover {
color: color("second-hover");
}
}
&__share {
margin-left: 10px;
cursor: pointer;
font-size: 16px;
transition: color 0.18s ease;
&:hover {
color: color("second-hover");
}
}
&__modal {
width: 100px;
font-size: 15px;
display: flex;
flex-direction: column;
gap: 5px;
&-link {
display: flex;
align-items: center;
color: inherit;
text-decoration: none;
transition: color 0.18s ease;
cursor: pointer;
&:hover {
color: color("second-hover");
}
}
&-text {
flex: 1;
}
&-icon {
width: 20px;
height: 20px;
}
}
}
</style>

View File

@ -0,0 +1,114 @@
<template>
<div class="theme-comp" @click="toggleTheme()">
<div
class="theme-comp__filler"
:class="{ 'theme-comp__filler--border': spreadingBorder, 'theme-comp__filler--center': spreadingCenter }"
:style="{ '--border-color': borderColor }"
></div>
<font-awesome-icon class="theme-comp__button" icon="moon" v-if="currentTheme === 'light'" />
<font-awesome-icon class="theme-comp__button" icon="sun" v-else />
</div>
</template>
<script setup>
import { onMounted, ref } from "vue";
const currentTheme = ref("light");
const spreadingBorder = ref(false);
const spreadingCenter = ref(false);
const borderColor = ref("");
onMounted(() => {
if (localStorage.getItem("theme")) {
setTheme(localStorage.getItem("theme"));
} else {
setTheme("light");
}
});
async function toggleTheme() {
let nextTheme = currentTheme.value === "light" ? "dark" : "light";
borderColor.value = nextTheme === "light" ? "hsl(0, 0%, 100%)" : "hsl(0, 0%, 10%)";
spreadingBorder.value = true;
await new Promise((r) => setTimeout(r, 200));
setTheme(nextTheme);
//spreadingCenter.value = true;
//await new Promise((r) => setTimeout(r, 200));
spreadingBorder.value = false;
//spreadingCenter.value = false;
}
function setTheme(theme) {
document.documentElement.setAttribute("theme", theme);
localStorage.setItem("theme", theme);
currentTheme.value = theme;
}
</script>
<style lang="scss">
.theme-comp {
position: relative;
cursor: pointer;
z-index: 200;
&__button {
height: 30px;
}
&__filler {
--border-width: 0vh;
--border-offset: 0vh;
--opacity: 0;
position: fixed;
width: 30px;
height: 30px;
//border-radius: 15px;
//outline-offset: var(--border-offset);
//outline-style: solid;
//outline-width: var(--border-width);
//opacity: 0;
//border: 1px solid red;
//transition: outline-offset 0.2s linear, outline-width 0.2s linear;
//transition: 2s linear;
z-index: 200;
//box-shadow: 0 0 0 30px green, 0 0 0 40px blue;
&:after {
content: "";
position: absolute;
// top: calc((var(--border-width) + var(--border-offset)) * -1 + 15px);
// left: calc((var(--border-width) + var(--border-offset)) * -1 + 15px);
// width: calc(var(--border-offset) * 2);
// height: calc(var(--border-offset) * 2);
// border-radius: 50%;
// border: var(--border-width) solid var(--border-color);
// opacity: var(--opacity);
// pointer-events: none;
// transition: 0.2s linear;
// transition-property: border, top, left, width, height;
top: 0;
left: 0;
width: 30px;
height: 30px;
border-radius: 50%;
box-shadow: 0 0 0 var(--border-width) var(--border-color);
transition: box-shadow 0.2s linear;
z-index: 200;
}
&--border {
--border-width: max(150vh, 150vw);
--opacity: 1;
}
&--center {
--border-offset: max(150vh, 150vw);
--opacity: 1;
}
}
}
</style>

View File

@ -0,0 +1,39 @@
<template>
<div class="weather" v-if="weather.name">
<img class="weather__icon" :src="icon" />
<div class="weather__info">
<div class="weather__data">{{ city }}</div>
<div class="weather__data">{{ temperature }}°C</div>
</div>
</div>
</template>
<script setup>
import useWeather from "@/composables/weather";
import { computed } from "vue";
const { weather } = useWeather;
const city = computed(() => weather.value.name);
const icon = computed(() => (weather.value.weather ? `https://openweathermap.org/img/wn/${weather.value.weather[0].icon}.png` : ""));
const temperature = computed(() => weather.value.main?.temp | 0);
</script>
<style lang="scss">
.weather {
display: flex;
height: 100%;
align-items: center;
&__icon {
height: 100%;
}
&__info {
display: flex;
}
&__data {
margin-left: 5px;
}
}
</style>

View File

@ -0,0 +1,54 @@
<template>
<footer class="footer">
<div class="footer__social">
<a href="http://web.telegram.org" target="_blank">
<font-awesome-icon class="footer__social-item" :icon="['fab', 'telegram']" />
</a>
<a href="http://vk.com" target="_blank">
<font-awesome-icon class="footer__social-item" :icon="['fab', 'vk']" />
</a>
<a href="http://instagram.com" target="_blank">
<font-awesome-icon class="footer__social-item" :icon="['fab', 'instagram-square']" />
</a>
</div>
<div class="footer__text">
Lorem ipsum dolor sit amet consectetur adipisicing elit. Enim dolorem quibusdam similique. Iure temporibus veritatis et, sequi
labore similique, laboriosam recusandae adipisci tempore esse molestias at. Perferendis excepturi deserunt magnam.
</div>
</footer>
</template>
<script setup></script>
<style lang="scss">
.footer {
display: flex;
border-top: 2px solid color("border");
padding: 15px 0;
color: color("second");
&__social {
flex: 1;
border-right: 2px solid color("border");
}
&__text {
flex: 2;
padding: 0 10px;
}
&__social-item {
color: color("second");
height: 30px;
width: 30px;
margin-right: 5px;
transition: color 0.18s ease;
&:hover {
color: color("link-hover");
}
}
}
</style>

View File

@ -0,0 +1,39 @@
<template>
<header class="header">
<div class="header__fixed">
<MobileHeader class="wrap" />
<!-- <PreHeader class="wrap" /> -->
</div>
<!-- <MainHeader class="wrap" /> -->
</header>
</template>
<script setup>
import MobileHeader from "@/components/Header/MobileHeader.vue";
//import PreHeader from "@/components/Header/PreHeader.vue";
//import MainHeader from "@/components/Header/MainHeader.vue";
</script>
<style lang="scss">
.header {
$p: &;
font-family: "Montserrat", sans-serif;
&__fixed {
position: fixed;
width: 100%;
top: 0;
z-index: 20;
background: color("background");
}
@at-root {
body.body--sb-opened,
body.body--modal-opened {
#{$p}__fixed {
padding-right: var(--scrollbar-width);
}
}
}
}
</style>

View File

@ -0,0 +1,38 @@
<template>
<svg class="header-logo" viewbox="0 0 153 30" v-if="false">
<text class="header-logo__text" y="25">D</text>
<text class="header-logo__text header-logo__text--2" x="25" y="25">N</text>
<text class="header-logo__text header-logo__text--3" x="50" y="25">R</text>
<text class="header-logo__text" x="75" y="25">.ONE</text>
</svg>
<img class="header-logo" v-else src="@/assets/images/logo.png" />
</template>
<script setup></script>
<style lang="scss">
.header-logo {
// width: 153px;
// font-family: "Montserrat", sans-serif;
// font-size: 30px;
// font-weight: 700;
// height: 30px;
// &__text {
// fill: #1e1e1e;
// stroke: hsla(0, 0%, 100%, 0.675);
// stroke-width: 2.5px;
// stroke-linejoin: round;
// paint-order: stroke fill;
// &--2 {
// fill: #1e1eff;
// }
// &--3 {
// fill: #ff1e1e;
// }
// }
height: 23px;
}
</style>

View File

@ -0,0 +1,63 @@
<template>
<div class="main-header">
<nav class="main-header__container">
<ul class="main-header__links">
<li><router-link class="main-header__link" to="/">link1</router-link></li>
<li><router-link class="main-header__link" to="/">link2</router-link></li>
<li><router-link class="main-header__link" to="/">link3</router-link></li>
<li><router-link class="main-header__link" to="/">link4</router-link></li>
</ul>
<div class="main-header__logo">logo</div>
<ul class="main-header__links">
<li><router-link class="main-header__link" to="/">link5</router-link></li>
<li><router-link class="main-header__link" to="/">link8</router-link></li>
<li><router-link class="main-header__link" to="/">link6</router-link></li>
<li><router-link class="main-header__link" to="/">link7</router-link></li>
</ul>
</nav>
</div>
</template>
<script setup></script>
<style lang="scss">
.main-header {
&__container {
display: flex;
justify-content: center;
margin: 10px 0;
@media (max-width: $tabletWidth) {
display: none;
}
}
&__links {
list-style-type: none;
flex: 1;
display: flex;
justify-content: space-between;
align-items: center;
padding: 0;
}
&__link {
color: #6c6c6c;
font-size: 13px;
text-transform: uppercase;
white-space: nowrap;
text-decoration: none;
}
&__logo {
display: flex;
width: 100px;
height: 100px;
margin: 0 50px;
justify-content: center;
align-items: center;
border: 2px dotted lightgray;
border-radius: 50px;
}
}
</style>

View File

@ -0,0 +1,263 @@
<template>
<div class="mobile-header">
<div class="mobile-header__container">
<div class="mobile-header__part mobile-header__part--left">
<div class="mobile-header__button mobile-header__button--menu" @click="openMobileMenu()">
<font-awesome-icon class="mobile-header__button-icon" icon="bars" />
</div>
<div class="mobile-header__weather">
<WeatherComp />
</div>
</div>
<HeaderLogo class="mobile-header__logo-image" @click="resetMainPage()" />
<div class="mobile-header__part mobile-header__part--right">
<div class="mobile-header__user" :class="{ 'mobile-header__user--opened': userMenuOpened }" v-click-outside="closeUserMenu">
<div class="mobile-header__user-main" @click="toggleUserMenu()">
<font-awesome-icon class="mobile-header__user-caret" icon="caret-down" v-show="userStore.loggedIn" />
<div class="mobile-header__user-name">{{ userText }}</div>
<font-awesome-icon class="mobile-header__user-icon" icon="user" />
</div>
<Transition name="mobile-header__user-dropdown--transition">
<div class="mobile-header__user-dropdown" v-if="userMenuOpened">
<div
class="mobile-header__user-link"
v-for="button in userButtons"
:key="button"
@click="
button.action();
closeUserMenu();
"
>
{{ button.text }}
</div>
</div>
</Transition>
</div>
<div class="mobile-header__button mobile-header__button--search" v-if="$route.name === 'News'" @click="openSearchWindow()">
<font-awesome-icon class="mobile-header__button-icon" icon="magnifying-glass" />
</div>
<div class="mobile-header__button mobile-header__button--sidebar" v-if="$route.name === 'News'" @click="openSidebar()">
<font-awesome-icon class="mobile-header__button-icon" icon="filter" />
</div>
</div>
</div>
</div>
</template>
<script setup>
import { nextTick, ref } from "vue";
// components
import WeatherComp from "@/components/Common/WeatherComp.vue";
import HeaderLogo from "@/components/Header/HeaderLogo.vue";
// directives
import vClickOutside from "@/directives/ClickOutside";
// composables
import { useUserVariables } from "@/composables/user";
const { userText, userButtons } = useUserVariables();
// stores
import { useUi, useNews, useUser } from "@/stores";
const uiStore = useUi();
const newsStore = useNews();
const userStore = useUser();
// router
import { useRouter } from "vue-router";
const router = useRouter();
const userMenuOpened = ref(false);
function toggleUserMenu() {
if (userMenuOpened.value) {
closeUserMenu();
} else {
openUserMenu();
}
}
function openUserMenu() {
if (userStore.loggedIn) {
userMenuOpened.value = true;
} else {
router.push("/user/login");
}
}
function closeUserMenu() {
userMenuOpened.value = false;
}
function openSidebar() {
uiStore.openSidebar();
}
function openMobileMenu() {
uiStore.openMobileMenu();
}
function openSearchWindow() {
uiStore.openSearchWindow();
}
function resetMainPage() {
newsStore.resetFilters();
nextTick(() => router.push("/"));
}
</script>
<style lang="scss">
.mobile-header {
width: 100%;
height: 48px;
border-bottom: 1px solid color("border");
&__container {
display: flex;
position: relative;
height: 100%;
padding: 0 10px;
justify-content: center;
align-items: center;
color: color("second");
font-size: 11px;
text-transform: uppercase;
}
&__part {
flex: 1;
display: flex;
align-items: center;
gap: 10px;
&--right {
justify-content: flex-end;
}
}
&__weather {
@media (max-width: $mobileWidth) {
display: none;
}
}
&__logo {
cursor: pointer;
}
&__logo-image {
cursor: pointer;
display: block;
}
&__user {
$user: &;
position: relative;
transition: color 0.18s ease;
@media (max-width: $mobileWidth) {
display: none;
}
&-main {
display: flex;
align-items: center;
cursor: pointer;
}
&-caret {
height: 15px;
width: 15px;
margin-right: 3px;
transition: transform 0.18s ease;
}
&-dropdown {
position: absolute;
display: flex;
flex-direction: column;
top: calc(100% + (48px - 100%) / 2);
right: 0;
border: 1px solid color("second");
background: color("background", $alpha: -0.1);
&--transition {
&-enter-active,
&-leave-active {
transition: opacity 0.1s ease;
}
&-enter-from,
&-leave-to {
opacity: 0;
}
}
}
&-link {
cursor: pointer;
padding: 10px 15px;
font-size: 14px;
line-height: 14px;
color: color("second");
transition: background-color 0.18s ease;
&:hover {
background: color("background-hover");
}
}
&-name {
margin-right: 3px;
}
&-icon {
height: 24px;
width: 24px;
margin-right: 3px;
}
&:hover {
color: color("second-hover");
}
&--opened {
#{$user}-caret {
transform: rotate(180deg);
}
}
}
&__button {
width: 24px;
height: 24px;
cursor: pointer;
}
&__button-icon {
width: 100%;
height: 100%;
transition: color 0.18s ease;
&:hover {
color: color("second-hover");
}
}
&__underline {
border-color: #e5e5e5;
}
}
.body--mm-opened {
.mobile-header {
left: 440px;
}
}
</style>

View File

@ -0,0 +1,84 @@
<template>
<div class="pre-header">
<div class="pre-header__container">
<div class="pre-header__weather">
<Weather />
</div>
<h2 class="pre-header__title">{{ title }}</h2>
<div class="pre-header__user">
<router-link class="pre-header__user-link" to="/user">
<div class="pre-header__user-name">{{ userText }}</div>
<font-awesome-icon class="pre-header__user-icon" icon="user" />
</router-link>
</div>
</div>
</div>
</template>
<script setup></script>
<script setup>
import Weather from "@/components/Weather.vue";
import { useUser } from "@/stores";
import { useUserVariables } from "@/composables/user";
import { computed } from "vue";
const userStore = useUser();
const { userText } = useUserVariables();
const title = computed(() => this.$route.meta.title);
</script>
<style lang="scss">
.pre-header {
&__container {
position: relative;
display: flex;
height: 45px;
justify-content: center;
align-items: center;
color: #6c6c6c;
font-size: 11px;
text-transform: uppercase;
border-bottom: 2px solid #e5e5e5;
}
&__title {
color: #6c6c6c;
font-size: 20px;
font-weight: 700;
line-height: 1;
}
&__user {
position: absolute;
right: 0;
&-link {
display: flex;
align-items: center;
cursor: pointer;
color: inherit;
text-decoration: none;
}
&-name {
margin-right: 3px;
}
&-icon {
height: 20px;
width: 25px;
margin-right: 3px;
}
}
&__weather {
position: absolute;
left: 0;
height: 100%;
}
}
</style>

View File

@ -0,0 +1,273 @@
<template>
<div class="mobile-menu" :class="uiStore.mobileMenuOpened ? 'mobile-menu--opened' : ''">
<div class="mobile-menu__closer" @click="close"></div>
<div class="mobile-menu__container">
<div class="mobile-menu__main">
<div class="mobile-menu__user" v-click-outside="closeUserMenu" :class="{ 'mobile-menu__user--opened': userMenuOpened }">
<div class="mobile-menu__user-main" @click="tryToggleUserMenu()">
<font-awesome-icon class="mobile-menu__user-icon" icon="user" />
<div class="mobile-menu__user-name">{{ userText }}</div>
<font-awesome-icon class="mobile-menu__user-caret" icon="caret-down" v-show="userStore.loggedIn" />
</div>
<Transition name="mobile-menu__user-dropdown--transition">
<div class="mobile-menu__user-dropdown" v-if="userMenuOpened" :style="`--height: ${userButtons.length * 41 + 1}px`">
<div
class="mobile-menu__user-link"
v-for="button in userButtons"
:key="button"
@click="
button.action();
closeUserMenu();
close();
"
>
{{ button.text }}
</div>
</div>
</Transition>
</div>
<ul class="mobile-menu__links" @click="close">
<li v-for="category in newsStore.categories" :key="category">
<router-link class="mobile-menu__link" :to="`/?category=${category.id}`" @click.prevent="setCategory(category.id)">
{{ category.title }}
</router-link>
</li>
</ul>
</div>
<div class="mobile-menu__bottom">
<ThemeComp class="mobile-menu__theme-switcher" />
<WeatherComp class="mobile-menu__weather" />
</div>
</div>
</div>
</template>
<script setup>
import { nextTick, ref } from "vue";
// stores
import { useUi, useUser, useNews } from "@/stores";
const userStore = useUser();
const uiStore = useUi();
const newsStore = useNews();
// router
import { useRouter } from "vue-router";
const router = useRouter();
// components
import WeatherComp from "@/components/Common/WeatherComp.vue";
import ThemeComp from "@/components/Common/ThemeComp.vue";
// directives
import vClickOutside from "@/directives/ClickOutside";
// composables
import { useUserVariables } from "@/composables/user";
const { userText, userButtons } = useUserVariables();
const userMenuOpened = ref(false);
function close() {
uiStore.closeMobileMenu();
}
function tryToggleUserMenu() {
if (userStore.loggedIn) {
if (userMenuOpened.value) {
closeUserMenu();
} else {
openUserMenu();
}
} else {
close();
router.push("/user/login");
}
}
function openUserMenu() {
userMenuOpened.value = true;
}
function closeUserMenu() {
userMenuOpened.value = false;
}
function setCategory(id) {
newsStore.resetFilters({ selectedCategoriesIds: [id] });
nextTick(() => router.push(`/?categories=${id}`));
}
</script>
<style lang="scss">
.mobile-menu {
$p: &;
&__container {
display: flex;
flex-direction: column;
position: fixed;
left: 0;
top: 0;
bottom: 0;
width: 0;
overflow: hidden;
background: color("background");
color: color("main");
box-shadow: -20px 0 20px -20px rgba(0, 0, 0, 0.5) inset;
font-size: 15px;
z-index: 50;
transition: width 0.4s ease;
}
&__main {
flex: 1;
overflow-x: hidden;
overflow-y: auto;
padding: 20px 0 20px 20px;
}
&__user {
$user: &;
position: relative;
margin-bottom: 7px;
@media (min-width: $mobileWidth) {
display: none;
}
&-link {
cursor: pointer;
padding: 10px 10px 10px 10px;
line-height: 20px;
border-bottom: 1px solid rgba(0, 0, 0, 0.15);
}
&-main {
cursor: pointer;
display: flex;
align-items: center;
padding-bottom: 7px;
}
&-name {
margin-left: 6px;
}
&-icon {
height: 24px;
width: 24px;
}
&-caret {
height: 15px;
width: 15px;
margin-left: 6px;
transition: transform 0.18s ease;
}
&-dropdown {
overflow: hidden;
height: var(--height);
border-bottom: 1px solid rgba(0, 0, 0, 0.15);
&--transition {
&-enter-active,
&-leave-active {
transition: all 0.18s ease;
}
&-enter-from,
&-leave-to {
height: 0;
border-color: #333333;
}
}
}
&--opened {
#{$user}-caret {
transform: rotate(180deg);
}
}
}
&__links {
list-style-type: none;
padding: 0;
}
&__link {
display: block;
padding: 10px 10px 10px 0;
line-height: 20px;
border-bottom: 1px solid rgba(0, 0, 0, 0.15);
text-overflow: ellipsis;
white-space: nowrap;
overflow: hidden;
color: inherit;
text-decoration: none;
font-weight: 700;
transition: color 0.18s ease;
&:hover {
color: color("link");
}
}
&__bottom {
display: flex;
justify-content: space-between;
align-items: center;
padding: 10px 20px 15px 20px;
overflow-x: hidden;
}
&__weather {
height: 40px;
@media (min-width: $mobileWidth) {
display: none;
}
}
&__theme-switcher {
color: color("second");
transition: color 0.18s ease;
&:hover {
color: color("second-hover");
}
}
&__closer {
position: fixed;
left: 0;
top: 0;
right: 0;
bottom: 0;
background: hsla(0, 0, 0, 0);
z-index: 50;
pointer-events: none;
transition: left 0.4s ease, background-color 0.4s ease;
}
&--opened {
position: relative;
z-index: 50;
#{$p}__container {
width: 240px;
@media (max-width: 550px) {
width: 80%;
}
}
#{$p}__closer {
left: 240px;
background: hsla(0, 0, 0, 0.1);
pointer-events: all;
@media (max-width: 550px) {
left: 80%;
}
}
}
}
</style>

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>

View File

@ -0,0 +1,375 @@
<template>
<div class="news-comments" ref="refRoot">
<div class="news-comments__header" ref="refHeader">
<HeaderComp class="news-comments__title">{{ commentsTitle }}</HeaderComp>
<div class="news-comments__navigation">
<div class="news-comments__navigation-container" :class="{ 'news-comments__navigation-container--fixed': navigationFixed }">
<div
class="news-comments__navigation-item"
v-if="displayedNavigationPages.length > 0 && displayedNavigationPages[0] !== 1"
@click="loadFirstPage()"
>
«
</div>
<div
class="news-comments__navigation-item"
:class="{ 'news-comments__navigation-item--current': page === currentPage }"
v-for="page in displayedNavigationPages"
:key="page"
@click="changePage(page)"
>
{{ page }}
</div>
<div
class="news-comments__navigation-item"
v-if="
displayedNavigationPages.length > 0 &&
displayedNavigationPages[displayedNavigationPages.length - 1] !== meta.pageCount
"
@click="loadLastPage()"
>
»
</div>
</div>
</div>
</div>
<div class="news-comments__page" v-for="page in commentsPages" :key="page" :ref="(el) => pageWasLoaded(el, page)">
<div class="news-comments__list">
<CommentsListItem
v-for="comment in page.comments"
:key="comment.id"
v-bind="comment"
@remove_comment="openRemoveCommentConfirmation"
/>
</div>
</div>
<ButtonComp
class="news-comments__expand"
v-if="!allCommentsLoaded && !loading"
:value="'load more'"
@click="loadNextPage()"
></ButtonComp>
<LoadingComp class="news-comments__loading" v-if="loading" />
<div class="news-comments__add">
<textarea class="news-comments__input" v-model="newCommentBody" placeholder="Add comment"></textarea>
<div class="news-comments__add-bottom">
<ButtonComp :value="'submit'" @click="sendComment()" :disabled="sendingComment" />
<div class="news-comments__add-error">{{ error }}</div>
</div>
</div>
<ModalComp ref="modal" @result="tryRemoveComment">
<div>Are you realy want to remove comment?</div>
</ModalComp>
<ModalComp ref="refModal"></ModalComp>
</div>
</template>
<script setup>
import { ref, defineProps, computed, onMounted, watch } from "vue";
// components
import CommentsListItem from "@/components/NewsPost/Comments/CommentsListItem.vue";
// composables
import { useCommentsApi } from "@/composables/api/comments";
const api = useCommentsApi();
// router
import { useRoute, useRouter } from "vue-router";
const route = useRoute();
const router = useRouter();
// stores
import { useUser } from "@/stores";
const userStore = useUser();
// init
const props = defineProps(["id"]);
const commentsPages = ref([]);
const meta = ref({
totalCount: 0,
pageCount: 0,
});
onMounted(() => {
commentsPages.value = [];
currentPage.value = route.query.page ? Number(route.query.page) : 1;
getComments(currentPage.value);
});
const commentsTitle = computed(() => `${meta.value.totalCount} Comment${meta.value.totalCount === 1 ? "" : "s"}`);
const allCommentsLoaded = computed(
() =>
meta.value.totalCount === 0 ||
(commentsPages.value.length > 0 && commentsPages.value[commentsPages.value.length - 1].page === meta.value.pageCount)
);
// #region adding new comment
const newCommentBody = ref("");
let sendingComment = ref(false);
const error = ref("");
const refModal = ref(null);
async function sendComment() {
if (!userStore.loggedIn) {
refModal.value.open({ type: "authorization_required" });
return;
}
if (sendingComment.value) return;
error.value = checkNewCommentBody();
if (error.value !== "") return;
sendingComment.value = true;
let success = await api.sendComment(props.id, newCommentBody.value);
if (success) {
newCommentBody.value = "";
await loadLastPage();
scrollToBottom();
}
sendingComment.value = false;
}
function checkNewCommentBody() {
if (newCommentBody.value.trim() === "") {
return "Comment text cannot be empty";
}
return "";
}
// #endregion
// #region removing comment
let removingComment = false;
const modal = ref(null);
function openRemoveCommentConfirmation(data) {
modal.value.open({ type: "yes_no", data });
}
function tryRemoveComment(data) {
if (data.success) {
removeComment(data.data);
}
}
async function removeComment(id) {
if (removingComment) return;
removingComment = true;
let success = await api.removeComment(id);
if (success) {
loadSpecificPageOnly(currentPage.value);
}
removingComment = false;
}
// #endregion
// #region loading comments pages
const loading = ref(false);
async function getComments(page) {
loading.value = true;
let resp = await api.getComments(props.id, page);
loading.value = false;
if (resp != null) {
commentsPages.value.push({ page: resp._meta.currentPage, comments: resp.comments });
meta.value.totalCount = resp._meta.totalCount;
meta.value.pageCount = resp._meta.pageCount;
}
}
async function loadSpecificPageOnly(page) {
commentsPages.value = [];
await getComments(page);
currentPage.value = page;
}
function loadNextPage() {
if (commentsPages.value.length > 0) {
getComments(commentsPages.value[commentsPages.value.length - 1].page + 1);
}
}
function changePage(page) {
scrollToTop();
loadSpecificPageOnly(page);
}
async function loadFirstPage() {
await loadSpecificPageOnly(1);
}
async function loadLastPage() {
await loadSpecificPageOnly(meta.value.pageCount);
}
// #endregion
// #region comments section scrolls
function scrollToTop() {
let el = refRoot.value;
window.scrollTo({ top: el.offsetTop - 45, behavior: "smooth" });
}
function scrollToBottom() {
let el = refRoot.value;
window.scrollTo({ top: el.offsetTop - 45 + el.offsetHeight, behavior: "smooth" });
}
// #endregion
// #region handling navigation
const navigationFixed = ref(false);
onMounted(() => {
const callback = (entries) => {
let intersectedTopOfScreen = entries[0].boundingClientRect.top < entries[0].boundingClientRect.height + 10;
navigationFixed.value = !entries[0].isIntersecting && intersectedTopOfScreen;
};
const observer = new IntersectionObserver(callback, { rootMargin: "-80px 0px 0px 0px" });
observer.observe(refHeader.value);
});
const displayedNavigationPages = computed(() => {
let from = Math.max(1, currentPage.value - 2);
let to = Math.min(currentPage.value + 2, meta.value.pageCount);
if (to >= from) {
return new Array(to - from + 1).fill().map((d, i) => i + from);
} else {
return [];
}
});
// #endregion
// #region handling currentPage & onScreenPages &
const currentPage = ref(1);
const onScreenPages = ref([]);
const refHeader = ref(null);
const refRoot = ref(null);
const refPages = ref([]);
function pageWasLoaded(el, page) {
const callback = (entries) => {
if (entries[0].isIntersecting) {
onScreenPages.value.push(page.page);
} else {
onScreenPages.value = onScreenPages.value.filter((x) => x !== page.page).sort((a, b) => a - b);
}
};
if (el && refPages.value.every((x) => x.el != el)) {
refPages.value.push({ el, page });
const observer = new IntersectionObserver(callback, {});
observer.observe(el);
}
}
watch(
() => onScreenPages.value,
async (to) => {
if (to.length > 0) {
router.replace({ hash: route.hash, query: { page: to[0] } });
currentPage.value = to[0];
} else {
router.replace({ hash: route.hash, query: {} });
}
},
{ deep: true }
);
// #endregion
</script>
<style lang="scss">
.news-comments {
display: flex;
flex-direction: column;
margin: 30px 0;
&__header {
display: flex;
align-items: center;
justify-content: space-between;
}
&__navigation {
flex: 1;
display: flex;
justify-content: flex-end;
color: color("second");
&-container {
display: flex;
padding: 0 3px;
&--fixed {
position: fixed;
top: 50px;
border-radius: 3px;
background: color("background-hover");
}
}
&-item {
padding: 0 3px;
cursor: pointer;
&:hover {
border-bottom: 2px solid color("second-hover");
color: color("second-hover");
}
&--current {
border-bottom: 2px solid;
&:hover {
border-bottom: 2px solid;
color: color("second");
}
}
}
}
&__title {
}
&__list {
display: flex;
flex-direction: column;
}
&__expand {
margin: 10px auto;
}
&__loading {
margin: 20px auto;
}
&__add {
display: flex;
flex-direction: column;
align-items: flex-start;
gap: 20px;
margin-top: 30px;
}
&__add-bottom {
display: flex;
align-items: center;
}
&__add-error {
margin-left: 15px;
color: color("warn");
}
&__input {
@include grey-input;
width: 500px;
height: 150px;
max-width: 95%;
resize: none;
}
}
</style>

View File

@ -0,0 +1,71 @@
<template>
<div class="comment-item">
<div class="comment-item__header">
<div class="comment-item__username">{{ username }}</div>
<font-awesome-icon class="comment-item__remove" icon="xmark" v-if="userPosted" @click="emitRemoveComments()" />
</div>
<div class="comment-item__body">{{ comment_body }}</div>
<LikesComp :scope="'comment'" :id="id" :like="like" :dislike="dislike" :user_like="user_like" :user_dislike="user_dislike" />
</div>
</template>
<script setup>
import { computed, defineProps, defineEmits } from "vue";
// components
import LikesComp from "@/components/Common/LikesComp.vue";
// stores
import { useUser } from "@/stores";
const userStore = useUser();
const props = defineProps(["id", "username", "comment_body", "like", "dislike", "user_like", "user_dislike"]);
const userPosted = computed(() => userStore.username === props.username);
const emit = defineEmits(["remove_comment"]);
function emitRemoveComments() {
emit("remove_comment", props.id);
}
</script>
<style lang="scss">
.comment-item {
$p: &;
display: flex;
flex-direction: column;
gap: 10px;
padding: 10px 15px;
border-bottom: 1px solid color("border");
&:hover {
#{$p}__remove {
opacity: 1;
}
}
&__header {
display: flex;
align-items: center;
line-height: normal;
font-size: 13px;
}
&__username {
text-transform: uppercase;
line-height: 14px;
font-weight: 700;
color: color("link");
}
&__remove {
width: 25px;
height: 14px;
opacity: 0;
cursor: pointer;
transition: opacity 0.18s ease;
}
&__body {
}
}
</style>

View File

@ -0,0 +1,107 @@
<template>
<article class="news-post">
<ImageComp class="news-post__image" @load="imageLoaded" :src="post.photo" :alt="post.title" />
<div class="news-post__container">
<HeaderComp>{{ post.title }}</HeaderComp>
<MetaComp class="news-post__meta" :id="id" :category="post.category" :tags="post.tags" :published_date="post.published_date" />
<SocialComp
class="news-post__social"
:scope="'news'"
:id="id"
:like="post.like"
:comments_count="post.comments_count"
:views="post.views"
/>
<div class="news-post__content" v-html="content"></div>
<CommentsList :id="id" ref="refComments" />
</div>
<FooterContainer />
</article>
</template>
<script setup>
import { computed, defineProps, onMounted, ref } from "vue";
// components
import CommentsList from "./Comments/CommentsList.vue";
import FooterContainer from "@/components/Footer/FooterContainer.vue";
import MetaComp from "@/components/Common/MetaComp.vue";
import SocialComp from "@/components/Common/SocialComp.vue";
// composables
import { useNewsApi } from "@/composables/api/news";
const { fetchPostById } = useNewsApi();
// reouter
import { onBeforeRouteUpdate, useRoute, useRouter } from "vue-router";
const route = useRoute();
const router = useRouter();
const props = defineProps(["id"]);
const post = ref({ title: "", news_body: "", photo: "", category: 0, tags: [], like: 0, comments_count: 0, views: 0 });
const refComments = ref(null);
const content = computed(() => {
if (post.value.news_body) {
let val = "<p>";
val += post.value.news_body.replaceAll("\n", "</p><p>");
val += "</p>";
return val;
}
return "";
});
onMounted(init);
async function init() {
window.scroll(0, 0);
let fetchedPost = await fetchPostById(props.id);
post.value = fetchedPost;
document.title = `${post.value.title} - DNR.ONE`;
}
function imageLoaded() {
if (route.hash) {
scrollToComments();
}
}
function scrollToComments() {
let el = refComments.value.$el;
window.scrollTo({ top: el.offsetTop - 45, behavior: "smooth" });
router.replace({ hash: "", query: route.query });
}
onBeforeRouteUpdate((to, from) => {
if (to.hash === "#comments" && from.hash !== "#comments") {
scrollToComments();
}
});
</script>
<style lang="scss">
.news-post {
width: 600px;
max-width: 100%;
margin: 0 auto;
&__container {
}
&__meta {
margin-bottom: 5px;
}
&__social {
margin-bottom: 5px;
}
&__image {
margin: 20px 0 20px 0;
width: 100%;
}
}
</style>

View File

@ -0,0 +1,140 @@
<template>
<div class="search-page">
<div class="search-page__input">
<font-awesome-icon class="search-page__input-close" icon="xmark" @click="goBackToMain()" />
<input class="search-page__input-field" type="text" placeholder="Поиск" v-model="searchText" />
<font-awesome-icon class="search-page__input-search" icon="magnifying-glass" @click="search()" />
</div>
<LoadingComp class="search-page__loading" v-if="loading" />
<InfiniteScroll class="search-page__list" :list="list" :startingId="0" :fillerNeeded="false" />
</div>
</template>
<script setup>
import { nextTick, onMounted, ref, watch } from "vue";
// components
import InfiniteScroll from "@/components/NewsList/List/InfiniteScroll.vue";
// stores
import { useNews } from "@/stores";
const newsStore = useNews();
// router
import { useRoute, useRouter } from "vue-router";
const route = useRoute();
const router = useRouter();
// composables
import { useNewsApi } from "@/composables/api/news";
const api = useNewsApi();
const searchText = ref("");
const list = ref([]);
const loading = ref(false);
onMounted(() => {
if (route.query.query) {
searchText.value = route.query.query;
}
if (newsStore.searchList.length > 0) {
list.value = newsStore.searchList.map((x, i) => ({ ...x, index: i }));
nextTick(() => {
clearTimeout(timeoutedSearch);
newsStore.searchList = [];
});
}
});
function goBackToMain() {
router.back();
}
let timeoutedSearch;
watch(searchText, () => {
if (timeoutedSearch) {
clearTimeout(timeoutedSearch);
}
timeoutedSearch = setTimeout(() => {
search();
}, 1000);
});
async function search() {
if (searchText.value.trim() === "") return;
loadMorePosts(1);
router.replace({ query: { query: searchText.value } });
}
const meta = ref({});
async function loadMorePosts(page) {
loading.value = true;
let resp = await api.findPostsByString(searchText.value, page);
loading.value = false;
if (resp != null && !resp.hasErrors) {
list.value = resp.news.map((x, i) => ({ ...x, index: i + list.value.length }));
meta.value = resp._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;
loadMorePosts(nextPageNumber);
}
}
watch(
() => newsStore.onScreenPostsIds,
() => {
loadMorePostsIfNeeded();
}
);
</script>
<style lang="scss">
.search-page {
width: 100%;
display: flex;
flex-direction: column;
align-items: center;
padding: 15px 0;
&__input {
display: flex;
align-items: center;
width: 600px;
max-width: 100%;
}
&__input-close,
&__input-search {
height: 30px;
width: auto;
margin: 0 10px;
cursor: pointer;
color: color("second");
}
&__input-field {
@include grey-input;
width: 100%;
}
&__loading {
margin: 50px;
}
&__list {
width: 100%;
}
}
</style>

View File

@ -0,0 +1,27 @@
<template>
<div class="page-404">
<div class="page-404__container">
<HeaderComp class="page-404__title">404</HeaderComp>
<h3>Page not found</h3>
<LinkComp to="/">go to main</LinkComp>
</div>
</div>
</template>
<script setup></script>
<style lang="scss">
.page-404 {
display: flex;
height: 100%;
width: 100%;
justify-content: center;
&__container {
display: flex;
flex-direction: column;
align-items: center;
margin-top: 30%;
}
}
</style>

View File

@ -0,0 +1,27 @@
<template>
<div>
<DateComp v-model:firstDate="firstDate" v-model:secondDate="secondDate" />
</div>
</template>
<script setup>
import { ref, watch } from "vue";
const firstDate = ref(new Date());
const secondDate = ref(new Date());
watch(
() => firstDate.value,
() => {
console.log("firstDate :>> ", firstDate.value);
}
);
watch(
() => secondDate.value,
() => {
console.log("secondDate :>> ", secondDate.value);
}
);
</script>
<style lang="scss"></style>

View File

@ -0,0 +1,34 @@
<template>
<input class="button-comp" type="button" :value="value" />
</template>
<script setup>
import { defineProps } from "vue";
defineProps(["value"]);
</script>
<style lang="scss">
.button-comp {
padding: 12px 30px;
border: 2px solid color("border");
cursor: pointer;
background: transparent;
color: color("link");
font-family: Montserrat, sans-serif;
font-size: 16px;
font-weight: 700;
transition: color 0.18s ease, background-color 0.18s ease, border-color 0.18s ease;
&:disabled {
color: color("second");
}
@media (min-width: $mobileWidth) {
&:enabled:hover {
border-color: color("link");
background-color: color("link");
color: #fff !important;
}
}
}
</style>

View File

@ -0,0 +1,112 @@
<template>
<div class="date-comp" v-click-outside="closeWindow" ref="refRoot">
<div class="date-comp__input" @click="openWindow">{{ displayedValue }}</div>
<Transition name="date-comp__window--transition">
<DateCompWindow
v-show="windowShowed"
class="date-comp__window"
v-model:firstDate="firstDateHandler"
v-model:secondDate="secondDateHandler"
ref="refDateCompWindow"
:style="windowPosition"
/>
</Transition>
</div>
</template>
<script setup>
import { computed, defineProps, ref, defineEmits } from "vue";
// components
import DateCompWindow from "./DateCompWindow.vue";
// directives
import vClickOutside from "@/directives/ClickOutside";
const emit = defineEmits(["update:firstDate", "update:secondDate"]);
const props = defineProps({ firstDate: Date, secondDate: Date });
const firstDateHandler = computed({
get: () => props.firstDate,
set: (to) => {
emit("update:firstDate", to);
closeWindow();
},
});
const secondDateHandler = computed({
get: () => props.secondDate,
set: (to) => {
emit("update:secondDate", to);
},
});
const displayedValue = computed(() => {
let date = `${props.firstDate.getDate()}.${props.firstDate.getMonth()}.${props.firstDate.getFullYear()}`;
date += "-";
date += `${props.secondDate.getDate()}.${props.secondDate.getMonth()}.${props.secondDate.getFullYear()}`;
return date;
});
const refRoot = ref(null);
const refDateCompWindow = ref(null);
const windowShowed = ref(false);
const windowPosition = ref({});
function openWindow() {
refDateCompWindow.value.init();
computeWindowPosition();
windowShowed.value = true;
}
function closeWindow() {
windowShowed.value = false;
}
function computeWindowPosition() {
const left = refRoot.value.getBoundingClientRect().left;
const width = refRoot.value.offsetWidth;
const right = refRoot.value.getBoundingClientRect().right;
if (left + width / 2 < 240 / 2) {
windowPosition.value = { left: 240 / 2 - left + 5 + "px" };
} else if (right + width / 2 < 240 / 2) {
windowPosition.value = { right: 240 / 2 - right + 5 + "px" };
} else {
windowPosition.value = { left: width / 2 + "px" };
}
}
</script>
<style lang="scss">
.date-comp {
position: relative;
display: inline;
user-select: none;
width: 138px;
&__input {
display: inline;
&-field {
text-align: center;
}
}
&__window {
position: absolute;
top: 24px;
z-index: 10;
transform: translateX(-50%);
&--transition {
&-enter-active,
&-leave-active {
transition: opacity 0.08s ease, margin 0.08s linear;
}
&-enter-from,
&-leave-to {
opacity: 0;
margin-top: -5px;
}
}
}
}
</style>

View File

@ -0,0 +1,421 @@
<template>
<div class="date-comp-window">
<div class="date-comp-window__header">
<div class="date-comp-window__header-item" @click="prevMonth()">
<font-awesome-icon class="date-comp-window__header-item-caret" icon="chevron-left" />
</div>
<div
class="date-comp-window__header-item"
:class="{ 'date-comp-window__header-item--active': showState.month }"
@click="toggleMonth()"
>
{{ months[localDate[current].month - 1] }}
<font-awesome-icon class="date-comp-window__header-item-caret" icon="chevron-down" />
</div>
<div
class="date-comp-window__header-item"
:class="{ 'date-comp-window__header-item--active': showState.year }"
@click="toggleYear()"
>
{{ localDate[current].year }}
<font-awesome-icon class="date-comp-window__header-item-caret" icon="chevron-down" />
</div>
<div class="date-comp-window__header-item" @click="nextMonth()">
<font-awesome-icon class="date-comp-window__header-item-caret" icon="chevron-right" />
</div>
</div>
<div class="date-comp-window__day" v-show="showState.day">
<div class="date-comp-window__day-head" v-for="weekday in weekdays" :key="weekday">
{{ weekday }}
</div>
<div
class="date-comp-window__day-item"
v-for="(day, index) in displayedDays"
:key="index"
:class="getClassModifiers(day)"
@click="selectDay(day)"
@mouseenter="setDayHovered(day, true)"
@mouseleave="setDayHovered(day, false)"
>
{{ day.day }}
</div>
</div>
<div class="date-comp-window__select" v-show="showState.month">
<div
class="date-comp-window__select-item"
v-for="(month, index) in months"
:key="month"
:class="{ 'date-comp-window__select-item--selected': index + 1 === localDate[current].month }"
@click="selectMonth(index + 1)"
>
{{ month }}
</div>
</div>
<div class="date-comp-window__select" v-show="showState.year">
<div
class="date-comp-window__select-item"
v-for="year in years"
:key="year"
:class="{ 'date-comp-window__select-item--selected': year === localDate[current].year }"
:ref="
(el) => {
if (year === localDate[current].year) refYear = el;
}
"
@click="selectYear(year)"
>
{{ year }}
</div>
</div>
</div>
</template>
<script setup>
import { computed, nextTick, ref, defineProps, defineEmits, defineExpose, onMounted, watch } from "vue";
class DateObj {
constructor(date = new Date()) {
this.date = date;
}
get day() {
return this._date.getDate();
}
set day(value) {
this._date.setDate(value);
}
get month() {
return this._date.getMonth() + 1;
}
set month(value) {
this._date.setMonth(value - 1, 1);
}
get year() {
return this._date.getFullYear();
}
set year(value) {
this._date.setFullYear(value);
}
get date() {
return this._date;
}
set date(value) {
this._date = new Date(value.getFullYear(), value.getMonth(), value.getDate());
}
toString() {
return `${(this.day < 10 ? "0" : "") + this.day}.${(this.month < 10 ? "0" : "") + this.month}.${this.year}`;
}
}
const localDate = ref({
first: new DateObj(),
second: new DateObj(),
});
const outerDate = ref({
first: new DateObj(),
second: new DateObj(),
});
const emit = defineEmits(["update:firstDate", "update:secondDate"]);
const props = defineProps({ firstDate: { type: Date, default: new Date() }, secondDate: { type: Date } });
const twoDatesMode = ref(false);
const current = ref("first");
onMounted(() => {
init();
});
function init() {
current.value = "first";
outerDate.value.first.date = props.firstDate;
localDate.value.first.date = props.firstDate.getTime() === 0 ? new Date() : props.firstDate;
if (props.secondDate != null) {
twoDatesMode.value = true;
outerDate.value.second.date = props.secondDate;
localDate.value.second.date = props.secondDate.getTime() === 0 ? new Date() : props.secondDate;
}
}
defineExpose({ init });
function selectDay(day) {
if (!twoDatesMode.value) {
localDate.value.first.date = day.date;
emit("update:firstDate", localDate.value.first.date);
} else if (current.value === "first") {
localDate.value.first.date = day.date;
localDate.value.second.date = day.date;
current.value = "second";
} else {
if (day.date.getTime() >= localDate.value.first.date.getTime()) {
localDate.value.second.date = day.date;
} else {
localDate.value.second.date = localDate.value.first.date;
localDate.value.first.date = day.date;
}
emit("update:firstDate", localDate.value.first.date);
emit("update:secondDate", localDate.value.second.date);
current.value = "first";
}
}
function selectMonth(month) {
localDate.value[current.value].month = month;
showState.value.month = false;
}
function selectYear(year) {
localDate.value[current.value].year = year;
showState.value.year = false;
}
watch(
() => props.firstDate,
() => {
init();
outerDate.value.first.date = props.firstDate;
localDate.value.first.date = props.firstDate.getTime() === 0 ? new Date() : props.firstDate;
}
);
watch(
() => props.secondDate,
() => {
outerDate.value.second.date = props.secondDate;
localDate.value.second.date = props.secondDate.getTime() === 0 ? new Date() : props.secondDate;
}
);
// #region handling navigation and show states
const showState = ref({
_month: false,
_year: false,
get day() {
return !this.month && !this.year;
},
get month() {
return this._month;
},
get year() {
return this._year;
},
set month(value) {
this._month = value;
this._year = false;
},
set year(value) {
this._year = value;
this._month = false;
},
});
function toggleMonth() {
showState.value.month = !showState.value.month;
}
const refYear = ref(null);
function toggleYear() {
showState.value.year = !showState.value.year;
if (showState.value.year) {
nextTick(() => {
refYear.value.parentNode.scrollTop = refYear.value.offsetTop - 110;
});
}
}
function prevMonth() {
localDate.value[current.value].month--;
}
function nextMonth() {
localDate.value[current.value].month++;
}
// #endregion
// #region handling window displayed dates
const weekdays = ["Пн", "Вт", "Ср", "Чт", "Пт", "Сб", "Вс"];
const months = ["Янв", "Фев", "Мар", "Апр", "Май", "Июн", "Июл", "Авг", "Сен", "Окт", "Ноя", "Дек"];
const years = new Array(2099 - 1970).fill().map((d, i) => i + 1970);
const dayHovered = ref(null);
function setDayHovered(day, hovered) {
if (hovered) {
dayHovered.value = day;
} else {
dayHovered.value = null;
}
}
const displayedDays = computed(() => {
let offset;
for (let i = 1; ; i++) {
if (new Date(localDate.value[current.value].year, localDate.value[current.value].month - 1, 0 - i).getDay() === 1) {
offset = i;
break;
}
}
let days = [];
for (let i = 0; i < 42; i++) {
let dateObj = new DateObj(new Date(localDate.value[current.value].year, localDate.value[current.value].month - 1, i - offset));
days.push(dateObj);
}
return days;
});
function getClassModifiers(dateObj) {
let monthPeriod =
dateObj.month < localDate.value[current.value].month
? "prev"
: dateObj.month > localDate.value[current.value].month
? "next"
: "curr";
let selected, border;
if (!twoDatesMode.value) {
selected = dateObj.date.getTime() === outerDate.value.first.date.getTime();
border = selected;
} else if (current.value === "first") {
selected =
dateObj.date.getTime() >= outerDate.value.first.date.getTime() &&
dateObj.date.getTime() <= outerDate.value.second.date.getTime();
border =
dateObj.date.getTime() === outerDate.value.first.date.getTime() ||
dateObj.date.getTime() === outerDate.value.second.date.getTime();
} else {
if (!dayHovered.value) {
selected = dateObj.date.getTime() === localDate.value.first.date.getTime();
border = selected;
} else {
if (dayHovered.value.date.getTime() >= localDate.value.first.date.getTime()) {
selected =
dateObj.date.getTime() >= localDate.value.first.date.getTime() &&
dateObj.date.getTime() <= dayHovered.value.date.getTime();
} else {
selected =
dateObj.date.getTime() >= dayHovered.value.date.getTime() &&
dateObj.date.getTime() <= localDate.value.first.date.getTime();
}
border =
dateObj.date.getTime() === localDate.value.first.date.getTime() ||
dateObj.date.getTime() === dayHovered.value.date.getTime();
}
}
return {
"date-comp-window__day-item--selected": selected,
"date-comp-window__day-item--border": border,
"date-comp-window__day-item--prev": monthPeriod === "prev",
"date-comp-window__day-item--next": monthPeriod === "next",
};
}
// #endregion
</script>
<style lang="scss">
.date-comp-window {
display: flex;
flex-direction: column;
gap: 5px;
width: 240px;
height: 240px;
background: color("background");
border: 2px solid color("border");
&__header {
display: flex;
&-item {
$i: &;
flex: 1;
display: flex;
justify-content: center;
align-items: center;
cursor: pointer;
&-caret {
margin-left: 6px;
transition: transform 0.18s ease;
}
&--active {
#{$i}-caret {
transform: rotate(180deg);
}
}
}
}
&__day {
display: flex;
flex-wrap: wrap;
flex: 1;
&-head {
flex: 1;
display: flex;
justify-content: center;
align-items: center;
min-width: 14%;
}
&-item {
flex: 1;
display: flex;
justify-content: center;
align-items: center;
min-width: 14%;
cursor: pointer;
transition: background-color 0.18s ease;
&:hover {
background: color("border");
}
&--selected {
background: color("background-hover");
}
&--border {
background: color("second", $alpha: -0.6);
}
&--prev,
&--next {
color: hsl(0, 0%, 50%);
}
}
}
&__select {
flex: 1;
display: flex;
flex-wrap: wrap;
height: 230px;
overflow-y: auto;
&-item {
flex: 1 0 30%;
display: flex;
justify-content: center;
align-items: center;
width: 30%;
min-height: 20%;
cursor: pointer;
transition: background-color 0.18s ease;
&:hover {
background: color("border");
}
&--selected {
background: color("background-hover");
&:hover {
background: color("background-hover");
}
}
}
}
}
</style>

View File

@ -0,0 +1,18 @@
<template>
<h1 class="header-comp">
<slot></slot>
</h1>
</template>
<script setup></script>
<style lang="scss">
.header-comp {
color: color("second");
font-family: "Montserrat", sans-serif;
font-weight: 700;
font-size: 24px;
line-height: 32px;
letter-spacing: -0.04em;
}
</style>

View File

@ -0,0 +1,81 @@
<template>
<div class="image-comp">
<div class="image-comp__container">
<img class="image-comp__img" :src="tempImage" :alt="alt" @click="openPreview()" @load="emitLoad" @error="changeSrc()" />
</div>
<ImagePreviewComp :src="tempImage" :alt="alt" :opened="opened" />
</div>
</template>
<script setup>
import { ref, defineProps, defineEmits, nextTick } from "vue";
// components
import ImagePreviewComp from "./ImagePreviewComp.vue";
import placeholder from "@/assets/images/placeholder.png";
const props = defineProps(["src", "alt"]);
const emit = defineEmits(["load"]);
const opened = ref(false);
const tempImage = ref(props.src);
// const tempImage = computed(() => {
// if (props.src == null || ["N/A", "test_path", "dfdvfbgfb", "fmvnfvjgnbjg"].some((x) => props.src.includes(x))) {
// let randW = Math.floor(Math.random() * 500 + 800);
// let randH = Math.floor((randW * 9) / 16);
// let randBColor = Math.floor(Math.random() * 16777215).toString(16);
// let randFColor = Math.floor(Math.random() * 16777215).toString(16);
// return `https://via.placeholder.com/${randW}x${randH}/${randBColor}/${randFColor}`;
// } else {
// return props.src;
// }
// });
function openPreview() {
opened.value = true;
nextTick(() => {
opened.value = false;
});
}
function emitLoad(event) {
emit("load", event);
}
function changeSrc() {
tempImage.value = placeholder;
// let randW = Math.floor(Math.random() * 500 + 800);
// let randH = Math.floor((randW * 9) / 16);
// let randBColor = Math.floor(Math.random() * 16777215).toString(16);
// let randFColor = Math.floor(Math.random() * 16777215).toString(16);
// tempImage.value = `https://via.placeholder.com/${randW}x${randH}/${randBColor}/${randFColor}`;
}
</script>
<style lang="scss">
.image-comp {
&__container {
width: 100%;
// padding: 10px;
// border: 1px solid color("border");
cursor: pointer;
height: 100%;
transition: box-shadow 0.18s;
@media (min-width: $mobileWidth) {
&:hover {
box-shadow: 0 0 18px color("background-hover");
}
}
}
&__img {
display: block;
width: 100%;
height: 100%;
object-fit: cover;
}
}
</style>

View File

@ -0,0 +1,122 @@
<template>
<Transition name="image-preview-comp--transition">
<div class="image-preview-comp" v-if="stateOpened" @click="closePreview()">
<div class="image-preview-comp__container" @click.stop>
<img class="image-preview-comp__image" :src="src" :alt="alt" />
<!-- <div class="image-preview-comp__caption">
{{ alt }}
</div> -->
<div class="image-preview-comp__close" @click="closePreview()">
<font-awesome-icon class="image-preview-comp__close-button" icon="xmark" />
</div>
</div>
</div>
</Transition>
</template>
<script setup>
import { ref, watch, defineProps } from "vue";
const props = defineProps(["src", "alt", "opened"]);
const stateOpened = ref(false);
function closePreview() {
stateOpened.value = false;
}
watch(
() => props.opened,
(to) => {
if (to) {
stateOpened.value = true;
}
}
);
</script>
<style lang="scss">
.image-preview-comp {
position: fixed;
display: flex;
top: 0;
right: 0;
bottom: 0;
left: 0;
z-index: 100;
justify-content: center;
align-items: center;
background: rgba(0, 0, 0, 0.95);
&--transition {
&-enter-active,
&-leave-active {
transition: opacity 0.18s;
}
&-enter-from,
&-leave-to {
opacity: 0;
}
}
&__container {
display: flex;
position: relative;
max-width: calc(100vw - 20px);
max-height: calc(100vh - 20px);
flex-direction: column;
gap: 20px;
background: color("background");
padding: 20px;
border-radius: 2px;
}
&__image-container {
min-height: 0;
flex: 0 1;
}
&__image {
display: block;
min-width: 0;
min-height: 0;
max-width: calc(100vw - 40px);
max-height: calc(100vh - 40px);
}
&__caption {
flex: 1 0;
line-height: 20px;
}
&__close {
display: flex;
position: absolute;
top: -0.5em;
right: -0.5em;
width: 24px;
height: 24px;
justify-content: center;
align-items: center;
padding: 13px;
line-height: 25px;
background-color: color("second");
color: color("background") !important;
border: 2px solid color("background");
border-radius: 100%;
transition: all 0.15s ease-out 0s;
cursor: pointer;
&:hover {
color: color("main") !important;
background-color: color("background-hover");
}
&-button {
height: 18px;
width: 18px;
}
}
}
</style>

View File

@ -0,0 +1,40 @@
<template>
<router-link class="link-comp" :to="to"><slot></slot></router-link>
</template>
<script setup>
import { defineProps } from "vue";
defineProps(["to"]);
</script>
<style lang="scss">
.link-comp {
position: relative;
color: color("link");
text-decoration: none;
transition: color 0.18s ease;
&:hover {
color: color("link-hover");
}
// &::after {
// width: 0;
// left: 0;
// margin-left: 50%;
// position: absolute;
// content: "";
// top: 100%;
// height: 0.08em;
// background: color("link-hover");
// transition: all 0.18s ease-in-out;
// }
// &:hover::after {
// width: 100%;
// margin-left: 0;
// }
}
</style>

View File

@ -0,0 +1,11 @@
<template>
<img class="loading-comp" src="@/assets/gif/loader@2x.gif" />
</template>
<script setup></script>
<style lang="scss">
.loading-comp {
display: block;
}
</style>

View File

@ -0,0 +1,90 @@
<template>
<Transition name="modal-comp--transition">
<div class="modal-comp" v-if="state.opened" :style="positionStyle" @click="close()">
<div class="modal-comp__container" @click.stop>
<slot />
<div v-if="state.type === 'authorization_required'">
You need to
<LinkComp to="/user/login">authorize</LinkComp>
or
<LinkComp to="/user/register">register</LinkComp>
to do this action
</div>
<div class="modal-comp__buttons" v-if="state.type === 'yes_no'">
<ButtonComp @click="returnResult(true)" :value="'Yes'"></ButtonComp>
<ButtonComp @click="returnResult(false)" :value="'No'"></ButtonComp>
</div>
</div>
</div>
</Transition>
</template>
<script setup>
import { ref, defineEmits, defineExpose, computed } from "vue";
const state = ref({ opened: false, type: "", position: "center" });
const outerData = ref(null);
const positionStyle = computed(() => ({
"--align-items": state.value.position === "top" ? "flex-start" : state.value.position === "bottom" ? "flex-end" : "center",
}));
const emit = defineEmits(["result"]);
function open({ type = "", position = "center", data = null } = {}) {
state.value.type = type;
state.value.position = position;
outerData.value = data;
state.value.opened = true;
document.body.classList.add("body--modal-opened");
}
function close() {
state.value.opened = false;
setTimeout(() => {
document.body.classList.remove("body--modal-opened");
}, 180);
}
defineExpose({ open, close });
function returnResult(result) {
emit("result", { success: result, data: outerData.value });
close();
}
</script>
<style lang="scss">
.modal-comp {
display: flex;
position: fixed;
justify-content: center;
align-items: var(--align-items);
inset: 0;
padding: 48px 10px;
z-index: 100;
background: rgba(0, 0, 0, 0.8);
overflow-y: auto;
&__container {
padding: 15px;
background: color("background");
border: 2px solid color("border");
}
&__buttons {
display: flex;
justify-content: space-between;
margin-top: 15px;
}
&--transition {
&-enter-active,
&-leave-active {
transition: opacity 0.18s ease;
}
&-enter-from,
&-leave-to {
opacity: 0;
}
}
}
</style>

View File

@ -0,0 +1,9 @@
import ImageComp from "./ImageComp/ImageComp.vue";
import HeaderComp from "./HeaderComp.vue";
import ButtonComp from "./ButtonComp.vue";
import LinkComp from "./LinkComp.vue";
import ModalComp from "./ModalComp.vue";
import LoadingComp from "./LoadingComp.vue";
import DateComp from "./DateComp/DateComp.vue";
export default { ImageComp, HeaderComp, ButtonComp, LinkComp, ModalComp, LoadingComp, DateComp };

View File

@ -0,0 +1,305 @@
<template>
<div class="login-register" :class="isLogin ? 'login-register--login' : 'login-register--register'">
<form class="login-register__form">
<input class="login-register__input" v-if="!isLogin" v-model="userData.email" type="email" placeholder="почта" name="email" />
<input class="login-register__input" v-model="userData.username" type="text" placeholder="имя пользователя" name="username" />
<input class="login-register__input" v-model="userData.password" type="password" placeholder="пароль" name="password" />
<input
class="login-register__input"
v-if="!isLogin"
v-model="userData.password2"
type="password"
placeholder="повторите пароль"
name="password2"
/>
<ButtonComp class="login-register__button" :value="buttonLabel" @click="submit()" :disabled="pending" />
<div class="login-register__redirect">
{{ redirectText }}
<router-link class="login-register__redirect-link" v-if="isLogin" to="/user/register" @click="reset()">
Регистрация
</router-link>
<router-link class="login-register__redirect-link" v-else to="/user/login" @click="reset()">Войти</router-link>
</div>
</form>
<div class="login-register__notifications">
<TransitionGroup name="login-register__notifications--transition">
<div
class="login-register__notification"
v-for="(notification, index) in notifications"
:key="notification"
:class="`login-register__notification--${notification.type}`"
>
<font-awesome-icon
v-if="notification.type === 'success'"
class="login-register__notification-icon"
:icon="['far', 'circle-check']"
/>
<font-awesome-icon v-else class="login-register__notification-icon" :icon="['far', 'circle-xmark']" />
<div class="login-register__notification-info">
<div class="login-register__notification-title" v-html="notification.title"></div>
<div class="login-register__notification-text" v-html="notification.text"></div>
</div>
<font-awesome-icon class="login-register__notification-close" icon="xmark" @click="removeNotification(index)" />
</div>
</TransitionGroup>
</div>
</div>
</template>
<script setup>
import { ref, defineProps, computed } from "vue";
import { FieldsContainer, FieldChecks } from "@/js/validator";
// composables
import { useUserApi } from "@/composables/api/user";
const api = useUserApi();
// stores
import { useUser } from "@/stores";
const userStore = useUser();
// router
import { onBeforeRouteLeave, useRouter } from "vue-router";
const router = useRouter();
const props = defineProps(["type"]);
const userData = ref({
email: "",
username: "",
password: "",
password2: "",
});
const notifications = ref([]);
const pending = ref(false);
const isLogin = computed(() => props.type === "login");
const buttonLabel = computed(() => (isLogin.value ? "Войти" : "Регистрация"));
const redirectText = computed(() => (isLogin.value ? "Нет аккаунта?" : "Уже есть аккаунт?"));
function submit() {
notifications.value = [];
let errors = getFieldsErrors();
if (errors.length == 0) {
if (isLogin.value) {
login();
} else {
register();
}
} else {
setTimeout(() => {
for (let i = 0; i < errors.length; i++) {
setTimeout(() => {
notifications.value.push({ type: "error", title: errors[i].title, text: errors[i].text });
}, i * 50);
}
}, 200);
}
}
function getFieldsErrors() {
const container = new FieldsContainer();
if (!isLogin.value) {
container.addField("почта", userData.value.email, [FieldChecks.nonEmpty, FieldChecks.email]);
}
container.addField("имя пользователя", userData.value.username, [
FieldChecks.nonEmpty,
FieldChecks.onlyLettersNumbersUnderscores,
FieldChecks.minLength(4),
FieldChecks.maxLength(20),
]);
container.addField("пароль", userData.value.password, [FieldChecks.nonEmpty, FieldChecks.minLength(6), FieldChecks.maxLength(32)]);
let errors = [];
for (let key in container.fields) {
let error = container.fields[key].tryGetFirstError();
if (error !== "") {
errors.push({ title: key, text: error });
}
}
if (!isLogin.value) {
if (userData.value.password !== userData.value.password2) {
errors.push({ title: "пароль", text: "пароли не совпадают" });
}
}
return errors;
}
async function login() {
if (pending.value) return;
pending.value = true;
let resp = await api.login({ username: userData.value.username, password: userData.value.password });
if (resp.hasErrors) {
for (let key in resp.error) {
notifications.value.push({ type: "error", title: key, text: resp.error[key][0] });
}
} else {
userStore.setUserData(resp.data);
userStore.saveUserData();
notifications.value.push({ type: "success", title: "вход", text: "Вход выполнен успешно." });
redirectAfterTimeout();
}
pending.value = false;
}
async function register() {
if (pending.value) return;
pending.value = true;
let resp = await api.register({ email: userData.value.email, username: userData.value.username, password: userData.value.password });
if (resp.hasErrors) {
for (let key in resp) {
notifications.value.push({ type: "error", title: key, text: resp.error[key][0] });
}
} else {
userStore.setUserData(resp.data);
userStore.saveUserData();
notifications.value.push({ type: "success", title: "регистрация", text: "Регистрация прошла успешно." });
redirectAfterTimeout();
}
pending.value = false;
}
function reset() {
userData.value = {
email: "",
username: "",
password: "",
password2: "",
};
notifications.value = [];
}
function removeNotification(index) {
notifications.value.splice(index, 1);
}
function redirectAfterTimeout() {
setTimeout(() => {
router.push("/");
}, 1000);
}
onBeforeRouteLeave(() => {
reset();
});
</script>
<style lang="scss">
.login-register {
$p: &;
width: 240px;
max-width: 100%;
margin: 0 auto;
padding-top: 25px;
color: color("second");
&__form {
display: flex;
flex-direction: column;
justify-content: flex-end;
height: 50vh;
gap: 5px;
}
&__input {
@include grey-input;
}
&__button {
}
&__redirect {
display: flex;
justify-content: center;
&-link {
margin-left: 5px;
color: color("second");
}
}
&__notifications {
margin-top: 5px;
&--transition {
&-enter-active,
&-leave-active {
transition: all 0.18s ease;
}
&-enter-from {
opacity: 0;
margin-top: 10px;
}
&-leave-to {
opacity: 0;
}
}
}
&__notification {
$p: &;
position: relative;
display: flex;
align-items: center;
gap: 5px;
padding: 5px 5px;
margin-bottom: 3px;
border: 1px solid color("border");
&-icon {
width: 25px;
height: auto;
display: block;
}
&-info {
flex: 1;
display: flex;
flex-direction: column;
justify-content: space-between;
gap: 3px;
height: 100%;
}
&-title {
font-size: 15px;
line-height: 13px;
}
&-text {
font-size: 13px;
line-height: 15px;
}
&-close {
cursor: pointer;
height: 100%;
width: 10px;
}
&--error {
#{$p}-icon {
color: color("warn");
}
}
&--success {
#{$p}-icon {
color: color("success");
}
}
}
&--login {
#{$p}__input--email {
display: none;
}
}
}
</style>

View File

@ -0,0 +1,7 @@
<template>
<router-view />
</template>
<script setup></script>
<style></style>

View File

@ -0,0 +1,29 @@
<template>
<div>
<ButtonComp :value="'Logout'" @click="userStore.logout()" />
<!-- <TabsNavigationComp>
<TabComp :title="'Information'">111</TabComp>
</TabsNavigationComp> -->
</div>
</template>
<script setup>
import { watch } from "vue";
// router
import { useRouter } from "vue-router";
const router = useRouter();
// stores
import { useUser } from "@/stores";
const userStore = useUser();
watch(
() => userStore.loggedIn,
(to) => {
if (!to) {
router.push({ path: "/user/login" });
}
}
);
</script>
<style></style>

View File

@ -0,0 +1,54 @@
import myaxios from "@/composables/api/myaxios";
import { useUser } from "@/stores";
export const useCommentsApi = () => {
const userStore = useUser();
async function getComments(news_id, page) {
let expand = "expand=like,dislike" + (userStore.loggedIn ? ",user_like,user_dislike" : "");
let path = `comment/news-comments?${expand}&news_id=${news_id}&page=${page}`;
let resp = await myaxios(path);
if (resp != null) {
return resp;
} else {
alert("Failed to load post " + news_id);
return null;
}
}
async function sendComment(news_id, comment_body) {
useUser;
if (!userStore.loggedIn) {
alert("authorization required");
return false;
}
let path = `comment/create`;
let config = {
method: "post",
data: { news_id, comment_body },
};
let resp = await myaxios(path, config, true);
return resp != null && !resp.hasErrors;
}
async function removeComment(comment_id) {
if (!userStore.loggedIn) {
alert("authorization required");
return false;
}
let path = `comment/delete?comment_id=${comment_id}`;
let config = {
method: "delete",
};
let resp = await myaxios(path, config, true);
return resp != null && !resp.hasErrors;
}
return { getComments, sendComment, removeComment };
};

View File

@ -0,0 +1,72 @@
import myaxios from "@/composables/api/myaxios";
import { useUser } from "@/stores";
export const useLikesApi = () => {
const userStore = useUser();
async function checkStatusForUser(scope, type, id) {
if (!userStore.loggedIn) return false;
let path = "";
if (scope === "news") {
if (type === "like") {
path = `user-news-like/check-news-like?news_id=${id}`;
}
}
if (path === "") return false;
let resp = await myaxios(path, {});
return resp.data?.news_id === id;
}
async function set(scope, type, id) {
let path = "";
if (scope === "news") {
if (type === "like") {
path = `user-news-like/set-like`;
}
} else if (scope === "comment") {
if (type === "like") {
path = `user-comment-like/comment-set-like`;
} else if (type === "dislike") {
path = `user-comment-dislike/comment-set-dislike`;
}
}
if (path === "") return false;
let config = {
method: "post",
data: { [`${scope}_id`]: id },
};
let resp = await myaxios(path, config);
return resp?.data && resp.data[`${scope}_id`] === id;
}
async function remove(scope, type, id) {
let path = "";
if (scope === "news") {
if (type === "like") {
path = `user-news-like/delete-like?news_id=${id}`;
}
} else if (scope === "comment") {
if (type === "like") {
path = `user-comment-like/comment-delete-like?comment_id=${id}`;
} else if (type === "dislike") {
path = `user-comment-dislike/comment-delete-dislike?comment_id=${id}`;
}
}
if (path === "") return false;
let config = {
method: "delete",
};
let resp = await myaxios(path, config);
return resp?.data && resp.data[`${scope}_id`] === id;
}
return { checkStatusForUser, set, remove };
};

View File

@ -0,0 +1,52 @@
import { useUser } from "@/stores";
import { useRouter } from "vue-router";
import axios from "axios";
export default async (path, config = {}) => {
let root = process.env.NODE_ENV === "production" ? "https://front.dnr.one" : "https://front.dnr.one";
const userStore = useUser();
const router = useRouter();
if (validateToken()) {
config.headers = config.headers || {};
config.headers.Authorization = `Bearer ${userStore.access_token}`;
}
config.timeout = config.timeout || 3000;
config.validateStatus = false;
let tries = 0;
while (tries < 3) {
try {
let response = await axios(`${root}/api/${path}`, config);
if (response.status === 500) {
throw new Error(response.status);
}
if (response.status === 401) {
userStore.logout();
}
if (response.data) {
return response.data;
}
} catch (e) {
console.log(path, e.message);
router?.push("/page404");
}
tries++;
}
return null;
};
function validateToken() {
const userStore = useUser();
if (userStore.access_token) {
if (Date.parse(userStore.access_token_expired_at) < Date.now()) {
userStore.logout();
return false;
} else {
return true;
}
} else {
return false;
}
}

View File

@ -0,0 +1,80 @@
import myaxios from "@/composables/api/myaxios";
export const useNewsApi = () => {
async function fetchAllCategories() {
let path = `category/category?expand=tags`;
let resp = await myaxios(path);
if (resp?.data) {
return resp.data;
} else {
console.log("Failed to load posts info");
}
return null;
}
async function fetchAllTags() {
let path = `tag/tag`;
let resp = await myaxios(path);
if (resp?.data) {
return resp.data;
} else {
console.log("Failed to load posts info");
}
return null;
}
async function fetchPostsIdsByFilters(newsStore, page) {
let categories = newsStore.selectedCategoriesIds.map((x, i) => `category[${i}]=${x}`).join("&");
categories = categories ? "&" + categories : "";
let tags = newsStore.selectedTagsIds.map((x, i) => `tags[${i}]=${x}`).join("&");
tags = tags ? "&" + tags : "";
let firstDateMinimized = new Date(newsStore.selectedDatesStart);
firstDateMinimized.setHours(0, 0, 0);
let from_date = newsStore.selectedDatesStart ? "&from_date=" + Math.floor(firstDateMinimized.getTime() / 1000) : "";
let secondDateMaximized = new Date(newsStore.selectedDatesEnd);
secondDateMaximized.setHours(23, 59, 59);
let published = newsStore.selectedDatesEnd ? "&published=" + Math.floor(secondDateMaximized.getTime() / 1000) : "";
let path = `news/filter?expand=category,tags,comments_count,photo,news_body,like${categories}${tags}${from_date}${published}&page=${page}`;
let resp = await myaxios(path);
if (resp != null) {
return resp;
} else {
return null;
}
}
async function fetchPostById(id) {
let path = `news/news?expand=category,tags,comments_count,photo,news_body,like&news_id=${id}`;
let resp = await myaxios(path);
if (resp != null) {
return resp.data;
} else {
return null;
}
}
async function findPostsByString(searchText, page) {
let path = `news/find?expand=category,tags,comments_count,photo,news_body,like&text=${searchText}&page=${page}`;
let resp = await myaxios(path);
if (resp != null) {
return resp;
} else {
console.log(`Failed to search posts "${searchText}"`);
return null;
}
}
return { fetchAllCategories, fetchAllTags, fetchPostsIdsByFilters, fetchPostById, findPostsByString };
};

View File

@ -0,0 +1,24 @@
import myaxios from "@/composables/api/myaxios";
export const useUserApi = () => {
async function register(data) {
let path = "user/create";
let config = {
method: "post",
data: data,
};
let response = await myaxios(path, config);
return response;
}
async function login(data) {
let path = `user/login?username=${data.username}&password=${data.password}`;
let response = await myaxios(path);
return response;
}
return { register, login };
};

View File

@ -0,0 +1,12 @@
let theme = "light";
export function useThemes() {
function loadTheme() {
theme = localStorage.getItem("theme") ?? "light";
}
function saveTheme() {
localStorage.setItem("theme", theme);
}
return { loadTheme, saveTheme };
}

View File

@ -0,0 +1,47 @@
import router from "@/router";
import { useUser } from "@/stores";
import { computed } from "vue";
export const useUserVariables = () => {
const userStore = useUser();
const userText = computed(() => {
return userStore.username === "" ? "Войти" : userStore.username;
});
const userButtons = computed(() => {
let buttons = [];
if (userStore.loggedIn) {
buttons.push({
text: "Профиль",
action: () => {
router.push("/user");
},
});
buttons.push({
text: "Выйти",
action: () => {
userStore.logout();
router.go();
},
});
} else {
buttons.push({
text: "Войти",
action: () => {
router.push("/user/login");
},
});
buttons.push({
text: "Регистрация",
action: () => {
router.push("/user/register");
},
});
}
return buttons;
});
return { userText, userButtons };
};

View File

@ -0,0 +1,17 @@
import axios from "axios";
import { ref } from "vue";
const appid = "bdf127740921661e863599c9f02021d0";
const cityid = "709717";
const weather = ref({});
const init = async () => {
const response = await axios.get(`https://api.openweathermap.org/data/2.5/weather?id=${cityid}&appid=${appid}&units=metric&lang=ru`);
if (response.status === 200) {
weather.value = response.data;
}
};
init();
export default { weather };

View File

@ -0,0 +1,16 @@
export default {
beforeMount(el, binding, vnode) {
var vm = vnode.context;
var callback = binding.value;
el.clickOutsideEvent = function (event) {
if (!(el == event.target || el.contains(event.target))) {
return callback.call(vm, event);
}
};
document.body.addEventListener("click", el.clickOutsideEvent);
},
unmounted(el) {
document.body.removeEventListener("click", el.clickOutsideEvent);
},
};

49
src/js/fontawesome.js Normal file
View File

@ -0,0 +1,49 @@
import {
faBars,
faFilter,
faChevronDown,
faUser,
faThumbsUp,
faThumbsDown,
faXmark,
faPlus,
faMagnifyingGlass,
faCaretDown,
faChevronLeft,
faChevronRight,
faEye,
faSun,
faMoon,
faComment,
faShareFromSquare,
faLink,
} from "@fortawesome/free-solid-svg-icons";
import { faCircleXmark, faCircleCheck } from "@fortawesome/free-regular-svg-icons";
import { faTelegram, faInstagramSquare, faVk, faFacebook } from "@fortawesome/free-brands-svg-icons";
export const icons = [
faBars,
faFilter,
faChevronDown,
faUser,
faThumbsUp,
faThumbsDown,
faXmark,
faPlus,
faTelegram,
faInstagramSquare,
faVk,
faFacebook,
faCircleXmark,
faCircleCheck,
faMagnifyingGlass,
faCaretDown,
faChevronLeft,
faChevronRight,
faEye,
faSun,
faMoon,
faComment,
faShareFromSquare,
faLink,
];

View File

@ -0,0 +1,58 @@
class FieldCheck {
constructor(error) {
this.error = error;
}
check() {
throw new Error("check() not implemented");
}
}
class FieldCheckRegex extends FieldCheck {
constructor(regex, error) {
super(error);
this.regex = regex;
}
check(val) {
if (!this.regex.test(val)) return this.error;
return "";
}
}
class FieldCheckMaxLength extends FieldCheck {
constructor(maxLength) {
super(`Максимальная длина равна ${maxLength}.`);
this.maxLength = maxLength;
}
check(val) {
if (val.length > this.maxLength) return this.error;
return "";
}
}
class FieldCheckMinLength extends FieldCheck {
constructor(minLength) {
super(`Минимальная длина равна ${minLength}.`);
this.minLength = minLength;
}
check(val) {
if (val.length < this.minLength) return this.error;
return "";
}
}
export class FieldChecks {
static nonEmpty = new FieldCheckRegex(/\S+/, "Поле не может быть пустым.");
static onlyLettersWhitespaces = new FieldCheckRegex(/^[A-Za-z\s]+$/, "Разрешены только Латинские буквы и пробелы.");
static onlyLettersNumbersUnderscores = new FieldCheckRegex(
/^[A-Za-z0-9_\s]+$/,
"Разрешены только Латинские буквы, цифры и нижнее подчёркивание."
);
static email = new FieldCheckRegex(/^[^\s@]+@[^\s@]+\.[^\s@]+$/, "Проверьте почтовый адрес.");
static noSpecialChars = new FieldCheckRegex(/^[A-Za-z0-9\s.,()!%]+$/, "Специальные символы не разрешены.");
static maxLength = (maxLength) => new FieldCheckMaxLength(maxLength);
static minLength = (minLength) => new FieldCheckMinLength(minLength);
}

View File

@ -0,0 +1,41 @@
export class FieldsContainer {
constructor() {
this.fields = {};
}
/**
* Add field to container.
* @param {string} name - The field name.
* @param {string} value - The field value.
* @param {Array} checks - Array of necessary FieldCheck objects.
*/
addField(name, value, checks) {
this.fields[name] = new Field(value, checks);
}
}
export class Field {
constructor(value, checks) {
this.value = value;
this.checks = checks;
}
tryGetFirstError() {
let result = "";
this.checks.every((fieldCheck) => {
result = fieldCheck.check(this.value);
return result === ""; //break or continue every()
});
return result;
}
tryGetAllErrors() {
let result = this.checks.flatMap((fieldCheck) => {
let err = fieldCheck.check(this.value);
return err === "" ? [] : [err];
});
return result.join(", ");
}
}

View File

@ -0,0 +1,2 @@
export { FieldChecks } from "./fieldChecks";
export { Field, FieldsContainer } from "./formFields";

23
src/main.js Normal file
View File

@ -0,0 +1,23 @@
import { createApp } from "vue";
import App from "./App.vue";
import router from "./router";
import { library } from "@fortawesome/fontawesome-svg-core";
import { icons } from "./js/fontawesome";
import { FontAwesomeIcon } from "@fortawesome/vue-fontawesome";
import UI from "@/components/Ui";
import { createPinia } from "pinia";
library.add(icons);
let app = createApp(App);
app.use(createPinia());
app.use(router);
app.component("font-awesome-icon", FontAwesomeIcon);
for (let key in UI) {
app.component(key, UI[key]);
}
app.mount("#app");

100
src/router/index.js Normal file
View File

@ -0,0 +1,100 @@
import { createRouter, createWebHistory } from "vue-router";
import NewsListContainer from "@/components/NewsList/NewsListContainer.vue";
const NewsPostContainer = () => import("@/components/NewsPost/NewsPostContainer.vue");
const SearchPageContainer = () => import("@/components/SearchPage/SearchPageContainer.vue");
const UserContainer = () => import("@/components/User/UserContainer.vue");
const UserPage = () => import("@/components/User/UserPage.vue");
const LoginRegisterPage = () => import("@/components/User/LoginRegisterPage.vue");
const PathNotFoundPage = () => import("@/components/Service/PathNotFoundPage.vue");
const TestsPolygon = () => import("@/components/TestsPolygon.vue");
import { useUser } from "@/stores";
const routes = [
{
path: "/",
name: "News",
meta: { title: "Новости" },
component: NewsListContainer,
},
{
path: "/post/:id(\\d+)",
meta: { title: "" },
component: NewsPostContainer,
props: (route) => ({ id: Number(route.params.id) }),
},
{
path: "/user",
meta: { title: "Пользователь" },
component: UserContainer,
children: [
{
path: "",
component: UserPage,
meta: { title: "User" },
},
{
path: "login",
component: LoginRegisterPage,
meta: { title: "Вход" },
props: { type: "login" },
},
{
path: "register",
component: LoginRegisterPage,
meta: { title: "Регистрация" },
props: { type: "register" },
},
],
},
{
path: "/search",
meta: { title: "Поиск" },
component: SearchPageContainer,
},
{
path: "/test",
meta: { title: "Test" },
component: TestsPolygon,
},
{
path: "/:pathMatch(.*)*",
name: "404",
component: PathNotFoundPage,
},
];
// const scrollBehavior = (to) => {
// if (to.hash) {
// return { el: to.hash, behavior: "smooth" };
// } else {
// return { x: 0, y: 0 };
// }
// };
const router = createRouter({
history: createWebHistory(process.env.NODE_ENV === "test" ? "/dnr.one/" : "/"),
routes,
// scrollBehavior,
});
router.beforeEach((to, from) => {
if (to.path === "/user") {
if (!useUser().loggedIn) {
return "/user/login";
}
} else if (to.path === "/user/login" || to.path === "/user/register") {
if (useUser().loggedIn) {
return "/user";
}
}
if (to.meta.title !== from.meta.title) {
if (to.meta.title) {
document.title = `${to.meta.title} - DNR.ONE`;
} else {
document.title = `DNR.ONE`;
}
}
});
export default router;

4
src/stores/index.js Normal file
View File

@ -0,0 +1,4 @@
export { mapStores, mapState, mapWritableState, mapActions } from "pinia";
export { default as useUi } from "./ui";
export { default as useUser } from "./user";
export { default as useNews } from "./news";

58
src/stores/news.js Normal file
View File

@ -0,0 +1,58 @@
import { defineStore } from "pinia";
import { useNewsApi } from "@/composables/api/news";
const { fetchAllCategories, fetchAllTags } = useNewsApi();
export default defineStore("news", {
state: () => {
return {
categories: [],
tags: [],
selectedCategoriesIds: [],
selectedTagsIds: [],
selectedDatesStart: 0,
selectedDatesEnd: 0,
onScreenPostsIds: [],
filterNeeded: false,
pageReseted: false,
searchList: [],
};
},
getters: {
selectedCategories() {
return this.categories.filter((x) => this.selectedCategoriesIds.includes(x.id));
},
selectedTags() {
return this.tags.filter((x) => this.selectedTagsIds.includes(x.id));
},
possibleTags() {
return this.selectedCategories?.length > 0
? [].concat.apply(
[],
this.selectedCategories.map((x) => x.tags)
)
: [];
},
},
actions: {
async init() {
this.categories = await fetchAllCategories();
this.tags = await fetchAllTags();
},
addOnScreenPostId(id) {
this.onScreenPostsIds = [...this.onScreenPostsIds, id].sort((a, b) => a - b);
},
removeOnScreenPostId(id) {
this.onScreenPostsIds = this.onScreenPostsIds.filter((x) => x !== id);
},
resetFilters(values = {}) {
this.selectedCategoriesIds = values.selectedCategoriesIds ?? [];
this.selectedTagsIds = values.selectedTagsIds ?? [];
this.selectedDatesStart = values.selectedDatesStart ?? 0;
this.selectedDatesEnd = values.selectedDatesEnd ?? 0;
this.pageReseted = true;
},
},
});

33
src/stores/ui.js Normal file
View File

@ -0,0 +1,33 @@
import { defineStore } from "pinia";
export default defineStore("ui", {
state: () => {
return {
mobileMenuOpened: false,
sidebarOpened: false,
searchWindowOpened: false,
};
},
actions: {
openMobileMenu() {
this.mobileMenuOpened = true;
},
closeMobileMenu() {
this.mobileMenuOpened = false;
},
openSidebar() {
this.sidebarOpened = true;
},
closeSidebar() {
this.sidebarOpened = false;
},
openSearchWindow() {
this.searchWindowOpened = true;
},
closeSearchWindow() {
this.searchWindowOpened = false;
},
},
});

40
src/stores/user.js Normal file
View File

@ -0,0 +1,40 @@
import { defineStore } from "pinia";
export default defineStore("user", {
state: () => {
return {
loggedIn: false,
id: -1,
username: "",
email: "",
access_token: "",
access_token_expired_at: "",
};
},
actions: {
setUserData(data) {
this.id = data.id;
this.username = data.username;
this.email = data.email;
this.access_token = data.access_token;
this.access_token_expired_at = data.access_token_expired_at;
this.loggedIn = !!data.username;
},
logout() {
this.$reset();
this.saveUserData();
},
saveUserData() {
localStorage.setItem("user", JSON.stringify(this.$state));
},
loadUserData() {
let data = localStorage.getItem("user");
if (data != null) {
this.setUserData(JSON.parse(data));
}
},
},
});

42
src/styles/themes.scss Normal file
View File

@ -0,0 +1,42 @@
:root {
@include define-color("background", hsl(0, 0%, 100%));
@include define-color("background-hover", hsl(0, 0%, 90%));
@include define-color("border", hsl(0, 0%, 70%));
@include define-color("main", hsl(0, 0%, 12%));
@include define-color("second", hsl(0, 0%, 42%));
@include define-color("second-hover", hsl(0, 0%, 55%));
@include define-color("link", hsl(203, 85%, 36%));
@include define-color("link-hover", hsl(203, 85%, 49%));
@include define-color("warn", hsl(0, 85%, 36%));
@include define-color("warn-hover", hsl(0, 85%, 49%));
@include define-color("success", hsl(120, 85%, 36%));
@include define-color("success-hover", hsl(120, 85%, 49%));
}
[theme="light"] {
@include define-color("background", hsl(0, 0%, 100%));
@include define-color("background-hover", hsl(0, 0%, 90%));
@include define-color("border", hsl(0, 0%, 70%));
@include define-color("main", hsl(0, 0%, 12%));
@include define-color("second", hsl(0, 0%, 42%));
@include define-color("second-hover", hsl(0, 0%, 55%));
@include define-color("link", hsl(203, 85%, 36%));
@include define-color("link-hover", hsl(203, 85%, 49%));
@include define-color("warn", hsl(0, 85%, 36%));
@include define-color("warn-hover", hsl(0, 85%, 49%));
@include define-color("success", hsl(120, 85%, 36%));
@include define-color("success-hover", hsl(120, 85%, 49%));
}
[theme="dark"] {
@include define-color("background", hsl(0, 0%, 10%));
@include define-color("background-hover", hsl(0, 0%, 20%));
@include define-color("border", hsl(0, 0%, 40%));
@include define-color("main", hsl(0, 0%, 88%));
@include define-color("second", hsl(0, 0%, 70%));
@include define-color("second-hover", hsl(0, 0%, 83%));
@include define-color("link", hsl(203, 85%, 60%));
@include define-color("link-hover", hsl(203, 85%, 73%));
@include define-color("warn", hsl(0, 85%, 60%));
@include define-color("warn-hover", hsl(0, 85%, 73%));
@include define-color("success", hsl(120, 85%, 60%));
@include define-color("success-hover", hsl(120, 85%, 73%));
}

59
src/styles/variables.scss Normal file
View File

@ -0,0 +1,59 @@
$tabletWidth: 990px;
$mobileWidth: 768px;
@mixin define-color($title, $color) {
--#{$title}-h: #{hue($color)};
--#{$title}-l: #{lightness($color)};
--#{$title}-s: #{saturation($color)};
--#{$title}-a: #{alpha($color)};
}
@function color($title, $hue: 0deg, $lightness: 0%, $saturation: 0%, $alpha: 0) {
@return hsla(
calc(var(--#{$title}-h) + #{$hue}),
calc(var(--#{$title}-s) + #{$saturation}),
calc(var(--#{$title}-l) + #{$lightness}),
calc(var(--#{$title}-a) + #{$alpha})
);
}
@font-face {
font-family: "Roboto";
src: url("@/assets/fonts/Roboto-Regular.woff2");
font-weight: normal;
}
@font-face {
font-family: "Roboto";
src: url("@/assets/fonts/Roboto-Bold.woff2");
font-weight: bold;
}
@font-face {
font-family: "Montserrat";
src: url("@/assets/fonts/Montserrat-Regular.woff2");
font-weight: normal;
}
@font-face {
font-family: "Montserrat";
src: url("@/assets/fonts/Montserrat-Bold.woff2");
font-weight: bold;
}
@mixin grey-input {
padding: 12px 18px;
background: color("background");
border: 2px solid color("border");
color: color("main");
font-family: "Montserrat", sans-serif;
font-size: 14px;
transition: border-color 0.18s ease;
&::placeholder {
font-family: "Montserrat", sans-serif;
color: color("second");
}
&:focus {
border-color: color("link");
outline: none;
}
}

23
vue.config.js Normal file
View File

@ -0,0 +1,23 @@
const { defineConfig } = require("@vue/cli-service");
module.exports = defineConfig({
css: {
loaderOptions: {
scss: {
additionalData: `@import "@/styles/variables.scss";`,
},
},
},
transpileDependencies: true,
publicPath: process.env.NODE_ENV === "test" ? "/dnr.one/" : "/",
productionSourceMap: false,
filenameHashing: false,
devServer: {
proxy: {
"^/api": {
target: "https://front.dnr.one",
ws: false,
changeOrigin: true,
},
},
},
});