first commit
This commit is contained in:
commit
9494762e86
25
.gitignore
vendored
Normal file
25
.gitignore
vendored
Normal 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?
|
3
babel.config.js
Normal file
3
babel.config.js
Normal file
@ -0,0 +1,3 @@
|
||||
module.exports = {
|
||||
presets: ["@vue/cli-plugin-babel/preset"],
|
||||
};
|
6
capacitor.config.json
Normal file
6
capacitor.config.json
Normal file
@ -0,0 +1,6 @@
|
||||
{
|
||||
"appId": "com.craftgroup.dnrone",
|
||||
"appName": "dnr.one",
|
||||
"webDir": "dist",
|
||||
"bundledWebRuntime": false
|
||||
}
|
205
db.json
Normal file
205
db.json
Normal 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
7
ionic.config.json
Normal file
@ -0,0 +1,7 @@
|
||||
{
|
||||
"name": "dnr.one",
|
||||
"integrations": {
|
||||
"capacitor": {}
|
||||
},
|
||||
"type": "custom"
|
||||
}
|
19
jsconfig.json
Normal file
19
jsconfig.json
Normal 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
21135
package-lock.json
generated
Normal file
File diff suppressed because it is too large
Load Diff
68
package.json
Normal file
68
package.json
Normal 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
BIN
public/favicon-16x16.png
Normal file
Binary file not shown.
After Width: | Height: | Size: 472 B |
BIN
public/favicon-32x32.png
Normal file
BIN
public/favicon-32x32.png
Normal file
Binary file not shown.
After Width: | Height: | Size: 479 B |
26
public/index.html
Normal file
26
public/index.html
Normal 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
140
src/App.vue
Normal 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>
|
BIN
src/assets/fonts/Lato-Bold.ttf
Normal file
BIN
src/assets/fonts/Lato-Bold.ttf
Normal file
Binary file not shown.
BIN
src/assets/fonts/Lato-Bold.woff2
Normal file
BIN
src/assets/fonts/Lato-Bold.woff2
Normal file
Binary file not shown.
BIN
src/assets/fonts/Lato-Regular.ttf
Normal file
BIN
src/assets/fonts/Lato-Regular.ttf
Normal file
Binary file not shown.
BIN
src/assets/fonts/Lato-Regular.woff2
Normal file
BIN
src/assets/fonts/Lato-Regular.woff2
Normal file
Binary file not shown.
BIN
src/assets/fonts/Montserrat-Bold.ttf
Normal file
BIN
src/assets/fonts/Montserrat-Bold.ttf
Normal file
Binary file not shown.
BIN
src/assets/fonts/Montserrat-Bold.woff2
Normal file
BIN
src/assets/fonts/Montserrat-Bold.woff2
Normal file
Binary file not shown.
BIN
src/assets/fonts/Montserrat-Regular.ttf
Normal file
BIN
src/assets/fonts/Montserrat-Regular.ttf
Normal file
Binary file not shown.
BIN
src/assets/fonts/Montserrat-Regular.woff2
Normal file
BIN
src/assets/fonts/Montserrat-Regular.woff2
Normal file
Binary file not shown.
BIN
src/assets/fonts/Roboto-Bold.ttf
Normal file
BIN
src/assets/fonts/Roboto-Bold.ttf
Normal file
Binary file not shown.
BIN
src/assets/fonts/Roboto-Bold.woff2
Normal file
BIN
src/assets/fonts/Roboto-Bold.woff2
Normal file
Binary file not shown.
BIN
src/assets/fonts/Roboto-Regular.ttf
Normal file
BIN
src/assets/fonts/Roboto-Regular.ttf
Normal file
Binary file not shown.
BIN
src/assets/fonts/Roboto-Regular.woff2
Normal file
BIN
src/assets/fonts/Roboto-Regular.woff2
Normal file
Binary file not shown.
BIN
src/assets/gif/loader.gif
Normal file
BIN
src/assets/gif/loader.gif
Normal file
Binary file not shown.
After Width: | Height: | Size: 885 B |
BIN
src/assets/gif/loader@2x.gif
Normal file
BIN
src/assets/gif/loader@2x.gif
Normal file
Binary file not shown.
After Width: | Height: | Size: 1.1 KiB |
BIN
src/assets/images/logo.png
Normal file
BIN
src/assets/images/logo.png
Normal file
Binary file not shown.
After Width: | Height: | Size: 13 KiB |
BIN
src/assets/images/placeholder.png
Normal file
BIN
src/assets/images/placeholder.png
Normal file
Binary file not shown.
After Width: | Height: | Size: 6.0 KiB |
175
src/components/Common/LikesComp.vue
Normal file
175
src/components/Common/LikesComp.vue
Normal 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>
|
141
src/components/Common/MetaComp.vue
Normal file
141
src/components/Common/MetaComp.vue
Normal 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>
|
162
src/components/Common/SocialComp.vue
Normal file
162
src/components/Common/SocialComp.vue
Normal 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>
|
114
src/components/Common/ThemeComp.vue
Normal file
114
src/components/Common/ThemeComp.vue
Normal 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>
|
39
src/components/Common/WeatherComp.vue
Normal file
39
src/components/Common/WeatherComp.vue
Normal 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>
|
54
src/components/Footer/FooterContainer.vue
Normal file
54
src/components/Footer/FooterContainer.vue
Normal 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>
|
39
src/components/Header/HeaderContainer.vue
Normal file
39
src/components/Header/HeaderContainer.vue
Normal 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>
|
38
src/components/Header/HeaderLogo.vue
Normal file
38
src/components/Header/HeaderLogo.vue
Normal 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>
|
63
src/components/Header/MainHeader.vue
Normal file
63
src/components/Header/MainHeader.vue
Normal 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>
|
263
src/components/Header/MobileHeader.vue
Normal file
263
src/components/Header/MobileHeader.vue
Normal 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>
|
84
src/components/Header/PreHeader.vue
Normal file
84
src/components/Header/PreHeader.vue
Normal 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>
|
273
src/components/MobileMenu/MobileMenuContainer.vue
Normal file
273
src/components/MobileMenu/MobileMenuContainer.vue
Normal 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>
|
16
src/components/NewsList/List/FixedList.vue
Normal file
16
src/components/NewsList/List/FixedList.vue
Normal file
@ -0,0 +1,16 @@
|
||||
<template>
|
||||
<div>
|
||||
<FixedListItem v-for="item in props.list" :key="item.id" v-bind="item" />
|
||||
</div>
|
||||
</template>
|
||||
|
||||
<script setup>
|
||||
// components
|
||||
import FixedListItem from "@/components/NewsList/List/FixedListItem.vue";
|
||||
|
||||
import { defineProps } from "vue";
|
||||
|
||||
const props = defineProps(["list"]);
|
||||
</script>
|
||||
|
||||
<style lang="scss"></style>
|
114
src/components/NewsList/List/FixedListItem.vue
Normal file
114
src/components/NewsList/List/FixedListItem.vue
Normal file
@ -0,0 +1,114 @@
|
||||
<template>
|
||||
<article class="fl-item">
|
||||
<ImageComp class="fl-item__image" :src="photo" :alt="'image'" />
|
||||
<div class="fl-item__container">
|
||||
<HeaderComp class="fl-item__title">
|
||||
<LinkComp :to="`/post/${id}`">{{ title }}</LinkComp>
|
||||
</HeaderComp>
|
||||
|
||||
<MetaComp class="fl-item__meta" :id="id" :category="category" :tags="tags" :published_date="published_date" />
|
||||
|
||||
<SocialComp class="fl-item__social" :scope="'news'" :id="id" :like="like" :comments_count="comments_count" :views="views" />
|
||||
|
||||
<div class="fl-item__content" v-html="contentSubstring"></div>
|
||||
|
||||
<LinkComp :to="`/post/${id}`">read more...</LinkComp>
|
||||
</div>
|
||||
</article>
|
||||
</template>
|
||||
|
||||
<script setup>
|
||||
import { computed, defineProps } from "vue";
|
||||
// components
|
||||
import MetaComp from "@/components/Common/MetaComp.vue";
|
||||
import SocialComp from "@/components/Common/SocialComp.vue";
|
||||
|
||||
const props = defineProps([
|
||||
"id",
|
||||
"title",
|
||||
"news_body",
|
||||
"photo",
|
||||
"category",
|
||||
"tags",
|
||||
"comments_count",
|
||||
"scrollPosition",
|
||||
"like",
|
||||
"published_date",
|
||||
"views",
|
||||
]);
|
||||
|
||||
const content = computed(() => {
|
||||
if (props.news_body) {
|
||||
let val = "<p>";
|
||||
val += props.news_body.replaceAll("\n", "</p><p>");
|
||||
val += "</p>";
|
||||
return val;
|
||||
}
|
||||
return "";
|
||||
});
|
||||
|
||||
const contentSubstring = computed(() => {
|
||||
let val = "";
|
||||
if (content.value.includes("<br>")) {
|
||||
val = content.value.substring(0, content.value.indexOf("<br>"));
|
||||
}
|
||||
if (val.length > 50) {
|
||||
val = val.substring(0, 50) + "...";
|
||||
}
|
||||
return val;
|
||||
});
|
||||
</script>
|
||||
|
||||
<style lang="scss">
|
||||
.fl-item {
|
||||
position: relative;
|
||||
padding: 20px 0;
|
||||
border-bottom: 1px solid color("border");
|
||||
|
||||
@media (min-width: $mobileWidth) {
|
||||
display: flex;
|
||||
height: 250px;
|
||||
width: 100%;
|
||||
justify-content: space-between;
|
||||
}
|
||||
|
||||
&__container {
|
||||
@media (min-width: $mobileWidth) {
|
||||
display: flex;
|
||||
flex-direction: column;
|
||||
align-items: flex-start;
|
||||
max-width: calc(100% - 310px);
|
||||
max-height: 100%;
|
||||
}
|
||||
}
|
||||
|
||||
&__title {
|
||||
}
|
||||
|
||||
&__meta {
|
||||
margin-bottom: 5px;
|
||||
}
|
||||
|
||||
&__social {
|
||||
margin-bottom: 5px;
|
||||
}
|
||||
|
||||
&__content {
|
||||
}
|
||||
|
||||
&__expand {
|
||||
cursor: pointer;
|
||||
color: color("link");
|
||||
}
|
||||
|
||||
&__image {
|
||||
height: 160px;
|
||||
|
||||
@media (min-width: $mobileWidth) {
|
||||
order: 1;
|
||||
width: 300px;
|
||||
height: 100%;
|
||||
}
|
||||
}
|
||||
}
|
||||
</style>
|
82
src/components/NewsList/List/InfiniteScroll.vue
Normal file
82
src/components/NewsList/List/InfiniteScroll.vue
Normal file
@ -0,0 +1,82 @@
|
||||
<template>
|
||||
<div class="infinite-scroll">
|
||||
<InfiniteScrollItem v-for="item in localList" :key="item.id" v-bind="item" />
|
||||
</div>
|
||||
</template>
|
||||
|
||||
<script setup>
|
||||
import { computed, nextTick, onMounted, ref, watch, defineProps } from "vue";
|
||||
// components
|
||||
import InfiniteScrollItem from "@/components/NewsList/List/InfiniteScrollItem.vue";
|
||||
// stores
|
||||
import { useNews } from "@/stores";
|
||||
const newsStore = useNews();
|
||||
|
||||
const props = defineProps(["list"]);
|
||||
|
||||
const localList = ref([]);
|
||||
|
||||
const allPostsFetched = computed(() => localList.value.length > 0 && localList.value.every((x) => x.showed));
|
||||
|
||||
async function showPostIfNeeded() {
|
||||
let getNextIndexIfNeeded = () => {
|
||||
let lastOnscreenItemId = newsStore.onScreenPostsIds[newsStore.onScreenPostsIds.length - 1];
|
||||
let lastOnscreenItem = localList.value.find((x) => x.id === lastOnscreenItemId);
|
||||
if (lastOnscreenItem && lastOnscreenItem.showed && lastOnscreenItem.index < localList.value.length - 1) {
|
||||
let nextItemIndex = lastOnscreenItem.index + 1;
|
||||
|
||||
if (!localList.value[nextItemIndex].showed) {
|
||||
return nextItemIndex;
|
||||
}
|
||||
}
|
||||
return -1;
|
||||
};
|
||||
|
||||
for (;;) {
|
||||
if (allPostsFetched.value || localList.value.length === 0) break;
|
||||
|
||||
let index = -1;
|
||||
|
||||
if (newsStore.onScreenPostsIds.length === 0 && localList.value.every((x) => !x.showed)) {
|
||||
index = 0;
|
||||
} else {
|
||||
index = getNextIndexIfNeeded();
|
||||
}
|
||||
|
||||
if (index > -1) {
|
||||
if (!localList.value[index]) break;
|
||||
|
||||
localList.value[index].showed = true;
|
||||
} else break;
|
||||
await nextTick();
|
||||
}
|
||||
}
|
||||
|
||||
onMounted(() => {
|
||||
localList.value = props.list;
|
||||
});
|
||||
|
||||
watch(
|
||||
() => props.list,
|
||||
async (to) => {
|
||||
if (to.length > 20) {
|
||||
localList.value.push(...to.slice(localList.value.length).map((x) => ({ ...x, showed: false })));
|
||||
} else {
|
||||
localList.value = to.map((x) => ({ ...x, showed: false }));
|
||||
}
|
||||
await nextTick();
|
||||
showPostIfNeeded();
|
||||
},
|
||||
{ deep: true }
|
||||
);
|
||||
|
||||
watch(
|
||||
() => newsStore.onScreenPostsIds,
|
||||
() => {
|
||||
showPostIfNeeded();
|
||||
},
|
||||
{ deep: true }
|
||||
);
|
||||
</script>
|
||||
|
||||
<style lang="scss"></style>
|
194
src/components/NewsList/List/InfiniteScrollItem.vue
Normal file
194
src/components/NewsList/List/InfiniteScrollItem.vue
Normal file
@ -0,0 +1,194 @@
|
||||
<template>
|
||||
<article class="is-item" ref="el" v-if="showed">
|
||||
<ImageComp class="is-item__image" :src="photo" :alt="'image'" />
|
||||
<div class="is-item__container">
|
||||
<HeaderComp class="is-item__title">
|
||||
<LinkComp :to="`/post/${id}`">{{ title }}</LinkComp>
|
||||
</HeaderComp>
|
||||
|
||||
<MetaComp class="is-item__meta" :id="id" :category="category" :tags="tags" :published_date="published_date" />
|
||||
|
||||
<SocialComp class="is-item__social" :scope="'news'" :id="id" :like="like" :comments_count="comments_count" :views="views" />
|
||||
|
||||
<div class="is-item__content" v-html="expanded ? content : contentSubstring"></div>
|
||||
|
||||
<div class="is-item__expand" v-if="!expanded" @click="expanded = true">read more...</div>
|
||||
</div>
|
||||
<!-- <div class="is-item__filler" :style="`background-color: rgba(0,0,0,${opacity});`"></div> -->
|
||||
</article>
|
||||
</template>
|
||||
|
||||
<script setup>
|
||||
import { ref, defineProps, computed, watch, onBeforeUnmount, nextTick } from "vue";
|
||||
// components
|
||||
import MetaComp from "@/components/Common/MetaComp.vue";
|
||||
import SocialComp from "@/components/Common/SocialComp.vue";
|
||||
// store
|
||||
import { useNews } from "@/stores";
|
||||
const newsStore = useNews();
|
||||
|
||||
const props = defineProps([
|
||||
"fillerNeeded",
|
||||
"showed",
|
||||
"index",
|
||||
"id",
|
||||
"title",
|
||||
"news_body",
|
||||
"photo",
|
||||
"category",
|
||||
"tags",
|
||||
"comments_count",
|
||||
"like",
|
||||
"published_date",
|
||||
"views",
|
||||
]);
|
||||
|
||||
const onScreen = ref(false);
|
||||
// let windowHeight = 0;
|
||||
const el = ref(null);
|
||||
const expanded = ref(false);
|
||||
|
||||
const content = computed(() => {
|
||||
if (props.news_body) {
|
||||
let val = "<p>";
|
||||
val += props.news_body.replaceAll("\n", "</p><p>");
|
||||
val += "</p>";
|
||||
return val;
|
||||
}
|
||||
return "";
|
||||
});
|
||||
|
||||
const contentSubstring = computed(() => {
|
||||
if (content.value.length > 70) {
|
||||
return content.value.substring(0, 70) + "...";
|
||||
}
|
||||
return content.value;
|
||||
});
|
||||
|
||||
// const scrollPosition = ref(0);
|
||||
// function scrollListener() {
|
||||
// scrollPosition.value = window.scrollY;
|
||||
// }
|
||||
// watch(
|
||||
// () => onScreen.value,
|
||||
// (to) => {
|
||||
// if (props.fillerNeeded) {
|
||||
// if (to) {
|
||||
// window.addEventListener("scroll", scrollListener);
|
||||
// } else {
|
||||
// window.removeEventListener("scroll", scrollListener);
|
||||
// }
|
||||
// }
|
||||
// }
|
||||
// );
|
||||
|
||||
// const opacity = computed(() => {
|
||||
// if (props.index === 0 || !props.fillerNeeded) return 0;
|
||||
// let scroll = scrollPosition.value + 48;
|
||||
// let elementPosition = el.value?.offsetTop || 0;
|
||||
// if (elementPosition - windowHeight * 0.5 < scroll) return 0;
|
||||
// let value = (elementPosition - scroll - windowHeight * 0.5) / (windowHeight * 0.5 - 50);
|
||||
// return value;
|
||||
// });
|
||||
|
||||
const callback = (entries) => {
|
||||
if (entries[0].isIntersecting) {
|
||||
onScreen.value = true;
|
||||
newsStore.addOnScreenPostId(props.id);
|
||||
} else {
|
||||
onScreen.value = false;
|
||||
newsStore.removeOnScreenPostId(props.id);
|
||||
}
|
||||
};
|
||||
const observer = new IntersectionObserver(callback, {});
|
||||
|
||||
// onMounted(() => {
|
||||
// windowHeight = window.innerHeight;
|
||||
// });
|
||||
|
||||
onBeforeUnmount(() => {
|
||||
onScreen.value = false;
|
||||
newsStore.removeOnScreenPostId(props.id);
|
||||
if (el.value) {
|
||||
observer.unobserve(el.value);
|
||||
}
|
||||
});
|
||||
|
||||
watch(
|
||||
() => props.showed,
|
||||
async (to) => {
|
||||
if (to) {
|
||||
nextTick(() => {
|
||||
observer.observe(el.value);
|
||||
});
|
||||
}
|
||||
}
|
||||
);
|
||||
</script>
|
||||
|
||||
<style lang="scss">
|
||||
.is-item {
|
||||
position: relative;
|
||||
padding: 20px 0;
|
||||
border-bottom: 1px solid color("border");
|
||||
|
||||
@media (min-width: $mobileWidth) {
|
||||
display: flex;
|
||||
min-height: 250px;
|
||||
width: 100%;
|
||||
justify-content: space-between;
|
||||
}
|
||||
|
||||
&__container {
|
||||
@media (min-width: $mobileWidth) {
|
||||
display: flex;
|
||||
flex-direction: column;
|
||||
align-items: flex-start;
|
||||
max-width: calc(100% - 310px);
|
||||
max-height: 100%;
|
||||
}
|
||||
}
|
||||
|
||||
&__title {
|
||||
}
|
||||
|
||||
&__meta {
|
||||
margin-bottom: 5px;
|
||||
}
|
||||
|
||||
&__social {
|
||||
margin-bottom: 5px;
|
||||
}
|
||||
|
||||
&__content {
|
||||
}
|
||||
|
||||
&__expand {
|
||||
cursor: pointer;
|
||||
color: color("link");
|
||||
}
|
||||
|
||||
&__image {
|
||||
height: 160px;
|
||||
|
||||
@media (min-width: $mobileWidth) {
|
||||
order: 1;
|
||||
width: 300px;
|
||||
height: 210px;
|
||||
}
|
||||
}
|
||||
|
||||
&__filler {
|
||||
position: absolute;
|
||||
margin: 0 calc((100% - 100vw) / 2);
|
||||
top: 0;
|
||||
bottom: 0;
|
||||
left: 0;
|
||||
right: 0;
|
||||
z-index: 10;
|
||||
pointer-events: none;
|
||||
background-color: rgba(0, 0, 0, 0.6);
|
||||
//box-shadow: 0 -5px 5px #000;
|
||||
}
|
||||
}
|
||||
</style>
|
273
src/components/NewsList/NewsListContainer.vue
Normal file
273
src/components/NewsList/NewsListContainer.vue
Normal file
@ -0,0 +1,273 @@
|
||||
<template>
|
||||
<div class="news-page">
|
||||
<div class="news-page__info-filters" v-if="newsStore.selectedCategories.length > 0">
|
||||
<div class="news-page__info-categories">
|
||||
<template v-for="category in newsStore.selectedCategories" :key="category">
|
||||
<div class="news-page__info-categories-item">
|
||||
{{ category.title }}
|
||||
<font-awesome-icon class="news-page__info-categories-item-icon" icon="xmark" @click="removeCategory(category.id)" />
|
||||
</div>
|
||||
</template>
|
||||
</div>
|
||||
<div class="news-page__info-tags">
|
||||
<template v-for="tag in newsStore.selectedTags" :key="tag">
|
||||
<div class="news-page__info-tags-item">
|
||||
{{ tag.title }}
|
||||
<font-awesome-icon class="news-page__info-tags-item-icon" icon="xmark" @click="removeTag(tag.id)" />
|
||||
</div>
|
||||
</template>
|
||||
</div>
|
||||
</div>
|
||||
<InfiniteScroll :list="list" />
|
||||
<LoadingComp class="news-page__loading" v-if="loading" />
|
||||
<div class="news-page__loading-failed" v-if="loadingFailed">К сожалению, ничего не найдено</div>
|
||||
<SidebarContainer class="news-page__sidebar" />
|
||||
<NewsSearch />
|
||||
</div>
|
||||
</template>
|
||||
|
||||
<script>
|
||||
export default { name: "NewsListContainer" };
|
||||
</script>
|
||||
<script setup>
|
||||
import { computed, nextTick, onActivated, onMounted, ref, watch } from "vue";
|
||||
// components
|
||||
import InfiniteScroll from "@/components/NewsList/List/InfiniteScroll.vue";
|
||||
import SidebarContainer from "@/components/NewsList/SideBar/SidebarContainer.vue";
|
||||
import NewsSearch from "@/components/NewsList/Search/NewsSearch.vue";
|
||||
// router
|
||||
import { useRoute, useRouter } from "vue-router";
|
||||
const route = useRoute();
|
||||
const router = useRouter();
|
||||
// stores
|
||||
import { useNews } from "@/stores";
|
||||
const newsStore = useNews();
|
||||
// composables
|
||||
import { useNewsApi } from "@/composables/api/news";
|
||||
const newsApi = useNewsApi();
|
||||
|
||||
const loading = computed(() => list.value.length === 0 && !loadingFailed.value);
|
||||
const loadingFailed = ref(false);
|
||||
const pageLoaded = ref(0);
|
||||
|
||||
const list = ref([]);
|
||||
|
||||
onMounted(() => {
|
||||
if (!newsStore.pageReseted) {
|
||||
init();
|
||||
}
|
||||
});
|
||||
onActivated(() => {
|
||||
if (newsStore.pageReseted) {
|
||||
filterHandler();
|
||||
nextTick(() => (newsStore.pageReseted = false));
|
||||
}
|
||||
});
|
||||
|
||||
watch(
|
||||
() => newsStore.pageReseted,
|
||||
(to) => {
|
||||
if (to) {
|
||||
filterHandler();
|
||||
nextTick(() => (newsStore.pageReseted = false));
|
||||
}
|
||||
}
|
||||
);
|
||||
|
||||
async function init() {
|
||||
list.value = [];
|
||||
getFilterDataFromQuery();
|
||||
|
||||
await newsStore.init(); // load categories and tags list
|
||||
await nextTick();
|
||||
await filterHandler();
|
||||
}
|
||||
|
||||
// #region handling filtration
|
||||
function removeCategory(id) {
|
||||
newsStore.selectedCategoriesIds = newsStore.selectedCategoriesIds.filter((x) => x !== id);
|
||||
newsStore.pageReseted = true;
|
||||
}
|
||||
|
||||
function removeTag(id) {
|
||||
newsStore.selectedTagsIds = newsStore.selectedTagsIds.filter((x) => x !== id);
|
||||
newsStore.pageReseted = true;
|
||||
}
|
||||
|
||||
function getFilterDataFromQuery() {
|
||||
if (route.query.categories) {
|
||||
newsStore.selectedCategoriesIds = route.query.categories.split(",").map(Number);
|
||||
} else {
|
||||
newsStore.selectedCategoriesIds = [];
|
||||
}
|
||||
|
||||
if (route.query.tags) {
|
||||
newsStore.selectedTagsIds = route.query.tags.split(",").map(Number);
|
||||
} else {
|
||||
newsStore.selectedTagsIds = [];
|
||||
}
|
||||
}
|
||||
|
||||
async function filterHandler() {
|
||||
list.value = [];
|
||||
|
||||
removeWrongFilterIds();
|
||||
setFilterDataToQuery();
|
||||
|
||||
newsStore.filterNeeded = false;
|
||||
await loadMorePosts(1);
|
||||
}
|
||||
|
||||
function setFilterDataToQuery() {
|
||||
let query = Object.assign({}, route.query);
|
||||
["categories", "tags"].forEach((x) => {
|
||||
let value = x === "categories" ? newsStore.selectedCategoriesIds.join(",") : newsStore.selectedTagsIds.join(",");
|
||||
|
||||
if (value === "") {
|
||||
delete query[x];
|
||||
} else {
|
||||
query[x] = value;
|
||||
}
|
||||
});
|
||||
|
||||
router.replace({ query });
|
||||
}
|
||||
|
||||
function removeWrongFilterIds() {
|
||||
let remove = (target, possible) => {
|
||||
let possibleIds = possible.map((x) => x.id);
|
||||
return target.filter((x) => possibleIds.includes(x));
|
||||
};
|
||||
|
||||
let target = newsStore.selectedCategoriesIds;
|
||||
let edited = remove([...new Set(target.sort((a, b) => a - b))], newsStore.categories);
|
||||
if (JSON.stringify(target) !== JSON.stringify(edited)) {
|
||||
newsStore.selectedCategoriesIds = edited;
|
||||
}
|
||||
|
||||
target = newsStore.selectedTagsIds;
|
||||
edited = remove([...new Set(target.sort((a, b) => a - b))], newsStore.possibleTags);
|
||||
if (JSON.stringify(target) !== JSON.stringify(edited)) {
|
||||
newsStore.selectedTagsIds = edited;
|
||||
}
|
||||
}
|
||||
|
||||
watch(
|
||||
() => newsStore.filterNeeded,
|
||||
(to) => {
|
||||
if (to) {
|
||||
filterHandler();
|
||||
}
|
||||
}
|
||||
);
|
||||
// #endregion
|
||||
|
||||
// #region handling fetching posts
|
||||
const meta = ref({});
|
||||
|
||||
async function loadMorePosts(page) {
|
||||
loadingFailed.value = false;
|
||||
let posts = await newsApi.fetchPostsIdsByFilters(newsStore, page);
|
||||
|
||||
if (posts == null) {
|
||||
return;
|
||||
} else if (posts.news == null || posts.news.length === 0) {
|
||||
loadingFailed.value = true;
|
||||
} else {
|
||||
list.value.push(...posts.news.map((x, i) => ({ ...x, index: i + list.value.length })));
|
||||
meta.value = posts._meta;
|
||||
}
|
||||
}
|
||||
|
||||
function loadMorePostsIfNeeded() {
|
||||
if (newsStore.onScreenPostsIds.length === 0 || list.value.length < 20) return;
|
||||
|
||||
let lastOnscreenId = newsStore.onScreenPostsIds[newsStore.onScreenPostsIds.length - 1];
|
||||
let lastOnscreenIdEqualsLastListId = lastOnscreenId === list.value[list.value.length - 1].id;
|
||||
let morePostsCanBeFetched = list.value.length < meta.value.totalCount;
|
||||
|
||||
if (lastOnscreenIdEqualsLastListId && morePostsCanBeFetched) {
|
||||
let nextPageNumber = Math.ceil(list.value.length / meta.value.perPage) + 1;
|
||||
if (nextPageNumber !== pageLoaded.value) {
|
||||
loadMorePosts(nextPageNumber);
|
||||
pageLoaded.value = nextPageNumber;
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
watch(
|
||||
() => newsStore.onScreenPostsIds,
|
||||
() => {
|
||||
loadMorePostsIfNeeded();
|
||||
},
|
||||
{ deep: true }
|
||||
);
|
||||
// #endregion
|
||||
</script>
|
||||
|
||||
<style lang="scss">
|
||||
.news-page {
|
||||
&__info-filters {
|
||||
}
|
||||
|
||||
&__info-categories {
|
||||
display: flex;
|
||||
flex-wrap: wrap;
|
||||
font-size: 20px;
|
||||
padding-top: 20px;
|
||||
|
||||
&-item {
|
||||
display: flex;
|
||||
align-items: center;
|
||||
gap: 5px;
|
||||
margin-right: 10px;
|
||||
|
||||
&-icon {
|
||||
height: 15px;
|
||||
cursor: pointer;
|
||||
color: color("second");
|
||||
transition: color 0.18s ease;
|
||||
|
||||
&:hover {
|
||||
color: color("second-hover");
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
&__info-tags {
|
||||
display: flex;
|
||||
flex-wrap: wrap;
|
||||
gap: 7px;
|
||||
padding-top: 10px;
|
||||
|
||||
&-item {
|
||||
display: flex;
|
||||
align-items: center;
|
||||
gap: 3px;
|
||||
|
||||
&-icon {
|
||||
height: 13px;
|
||||
cursor: pointer;
|
||||
color: color("second");
|
||||
transition: color 0.18s ease;
|
||||
|
||||
&:hover {
|
||||
color: color("second-hover");
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
&__loading {
|
||||
margin: 50px auto;
|
||||
}
|
||||
|
||||
&__loading-failed {
|
||||
margin: 50px 0;
|
||||
display: flex;
|
||||
justify-content: center;
|
||||
text-align: center;
|
||||
}
|
||||
}
|
||||
</style>
|
165
src/components/NewsList/Search/NewsSearch.vue
Normal file
165
src/components/NewsList/Search/NewsSearch.vue
Normal file
@ -0,0 +1,165 @@
|
||||
<template>
|
||||
<ModalComp ref="modal">
|
||||
<div class="news-search">
|
||||
<div class="news-search__input">
|
||||
<font-awesome-icon class="news-search__input-close" icon="xmark" @click="closeSearch()" />
|
||||
<input
|
||||
class="news-search__input-field"
|
||||
type="text"
|
||||
placeholder="Поиск"
|
||||
v-model="searchText"
|
||||
ref="input"
|
||||
@keydown="waitForEnter"
|
||||
/>
|
||||
<font-awesome-icon class="news-search__input-search" icon="magnifying-glass" @click="openSearchPage()" />
|
||||
</div>
|
||||
|
||||
<LoadingComp class="news-search__loading" v-if="loading" />
|
||||
<div class="news-search__list wrap" v-else-if="list.length > 0">
|
||||
<FixedList :list="list" />
|
||||
<ButtonComp
|
||||
class="news-search__more"
|
||||
v-if="listLength > 4"
|
||||
:value="`Show all ${listLength} results`"
|
||||
@click="openSearchPage()"
|
||||
/>
|
||||
</div>
|
||||
</div>
|
||||
</ModalComp>
|
||||
</template>
|
||||
|
||||
<script setup>
|
||||
import { nextTick, ref, watch } from "vue";
|
||||
// components
|
||||
import FixedList from "@/components/NewsList/List/FixedList.vue";
|
||||
// stores
|
||||
import { useUi, useNews } from "@/stores";
|
||||
const uiStore = useUi();
|
||||
const newsStore = useNews();
|
||||
// router
|
||||
import { useRouter } from "vue-router";
|
||||
const router = useRouter();
|
||||
// composables
|
||||
import { useNewsApi } from "@/composables/api/news";
|
||||
const api = useNewsApi();
|
||||
|
||||
const searchText = ref("");
|
||||
const list = ref([]);
|
||||
const loading = ref(false);
|
||||
|
||||
const modal = ref(null);
|
||||
const input = ref(null);
|
||||
|
||||
let listLength = ref(0);
|
||||
|
||||
async function search() {
|
||||
if (searchText.value.trim() === "") return;
|
||||
|
||||
loading.value = true;
|
||||
let resp = await api.findPostsByString(searchText.value);
|
||||
loading.value = false;
|
||||
|
||||
if (resp != null && !resp.hasErrors) {
|
||||
listLength.value = resp.news.length;
|
||||
if (listLength.value > 4) {
|
||||
list.value = resp.news.slice(0, 4);
|
||||
} else {
|
||||
list.value = resp.news;
|
||||
}
|
||||
return;
|
||||
}
|
||||
list.value = [];
|
||||
listLength.value = 0;
|
||||
}
|
||||
|
||||
function waitForEnter(event) {
|
||||
if (event.key === "Enter") {
|
||||
openSearchPage();
|
||||
}
|
||||
}
|
||||
|
||||
function closeSearch() {
|
||||
modal.value.close();
|
||||
}
|
||||
|
||||
function openSearchPage() {
|
||||
newsStore.searchList = list.value;
|
||||
router.push(`/search?query=${searchText.value}`);
|
||||
closeSearch();
|
||||
}
|
||||
|
||||
// #region handling delayed search
|
||||
let timeoutedSearch;
|
||||
watch(searchText, () => {
|
||||
if (timeoutedSearch) {
|
||||
clearTimeout(timeoutedSearch);
|
||||
}
|
||||
timeoutedSearch = setTimeout(() => {
|
||||
search();
|
||||
}, 1000);
|
||||
});
|
||||
|
||||
watch(
|
||||
() => uiStore.searchWindowOpened,
|
||||
(to) => {
|
||||
if (to) {
|
||||
list.value = [];
|
||||
listLength.value = 0;
|
||||
searchText.value = "";
|
||||
clearTimeout(timeoutedSearch);
|
||||
modal.value.open({ position: "top" });
|
||||
uiStore.searchWindowOpened = false;
|
||||
if (window.innerWidth > 768) {
|
||||
nextTick(() => {
|
||||
input.value.focus();
|
||||
});
|
||||
}
|
||||
}
|
||||
}
|
||||
);
|
||||
// #endregion
|
||||
</script>
|
||||
|
||||
<style lang="scss">
|
||||
.news-search {
|
||||
display: flex;
|
||||
flex-direction: column;
|
||||
align-items: center;
|
||||
width: 100%;
|
||||
|
||||
&__input {
|
||||
display: flex;
|
||||
align-items: center;
|
||||
gap: 10px;
|
||||
width: calc(min(100vw - 55px, 600px));
|
||||
}
|
||||
|
||||
&__input-close,
|
||||
&__input-search {
|
||||
height: 30px;
|
||||
width: auto;
|
||||
cursor: pointer;
|
||||
color: color("second");
|
||||
}
|
||||
|
||||
&__input-field {
|
||||
@include grey-input;
|
||||
width: 100%;
|
||||
}
|
||||
|
||||
&__loading {
|
||||
margin: 10px auto;
|
||||
}
|
||||
|
||||
&__list {
|
||||
display: flex;
|
||||
justify-content: center;
|
||||
flex-direction: column;
|
||||
width: 100%;
|
||||
}
|
||||
|
||||
&__more {
|
||||
margin: 10px auto;
|
||||
}
|
||||
}
|
||||
</style>
|
197
src/components/NewsList/SideBar/CategoriesSelector.vue
Normal file
197
src/components/NewsList/SideBar/CategoriesSelector.vue
Normal file
@ -0,0 +1,197 @@
|
||||
<template>
|
||||
<div class="categories-selector" :class="{ 'categories-selector--active': active }" v-click-outside="deactivate">
|
||||
<div class="categories-selector__input-panel" @click.self="activate">
|
||||
<span class="categories-selector__label" @click.self="activate">{{ label }}</span>
|
||||
<input class="categories-selector__input" :placeholder="placeholder" ref="refInput" v-model="filter" />
|
||||
<div class="categories-selector__toggle" @click="toggleActivation">
|
||||
<font-awesome-icon class="categories-selector__toggle-icon" icon="chevron-down" />
|
||||
</div>
|
||||
</div>
|
||||
<Transition v-show="active" name="categories-selector__content--transition-content">
|
||||
<ul class="categories-selector__content" :style="styleHeight">
|
||||
<li
|
||||
v-for="category in categoriesFiltered"
|
||||
class="categories-selector__item"
|
||||
:class="newsStore.selectedCategoriesIds.includes(category.id) ? 'categories-selector__item--selected' : ''"
|
||||
:key="category.id"
|
||||
@click="toggleSelection(category.id)"
|
||||
>
|
||||
{{ category.title }}
|
||||
</li>
|
||||
<li class="categories-selector__no-items" v-show="categoriesFiltered.length === 0">Категорий не найдено</li>
|
||||
</ul>
|
||||
</Transition>
|
||||
</div>
|
||||
</template>
|
||||
<script setup>
|
||||
import { computed, nextTick, ref } from "vue";
|
||||
// directives
|
||||
import vClickOutside from "@/directives/ClickOutside";
|
||||
// stores
|
||||
import { useNews } from "@/stores";
|
||||
const newsStore = useNews();
|
||||
|
||||
const active = ref(false);
|
||||
const placeholder = ref("Выберите категории");
|
||||
const filter = ref("");
|
||||
|
||||
const styleHeight = computed(() => ({
|
||||
"--height": (newsStore.categories.length > 5 ? 200 : 34.4 * newsStore.categories.length + 4) + "px",
|
||||
}));
|
||||
|
||||
const refInput = ref(null);
|
||||
|
||||
const categoriesFiltered = computed(() => newsStore.categories.filter((x) => x.title.includes(filter.value)));
|
||||
const label = computed(() => {
|
||||
const length = newsStore.selectedCategoriesIds.length;
|
||||
if (length === 1) {
|
||||
return `1 категория выбрана`;
|
||||
} else if (length > 1 && length <= 4) {
|
||||
return `${length} категории выбрано`;
|
||||
} else if (length > 4) {
|
||||
return `${length} категорий выбрано`;
|
||||
} else {
|
||||
return placeholder.value;
|
||||
}
|
||||
});
|
||||
|
||||
function activate() {
|
||||
if (!active.value) {
|
||||
active.value = true;
|
||||
|
||||
if (window.innerWidth > 768) {
|
||||
nextTick(() => {
|
||||
refInput.value.focus();
|
||||
});
|
||||
}
|
||||
}
|
||||
}
|
||||
function deactivate() {
|
||||
active.value = false;
|
||||
}
|
||||
function toggleActivation() {
|
||||
active.value ? deactivate() : activate();
|
||||
}
|
||||
|
||||
function toggleSelection(id) {
|
||||
if (newsStore.selectedCategoriesIds.includes(id)) {
|
||||
newsStore.selectedCategoriesIds = newsStore.selectedCategoriesIds.filter((x) => x !== id);
|
||||
} else {
|
||||
newsStore.selectedCategoriesIds = [...newsStore.selectedCategoriesIds, id];
|
||||
}
|
||||
newsStore.filterNeeded = true;
|
||||
}
|
||||
</script>
|
||||
<style lang="scss">
|
||||
.categories-selector {
|
||||
$p: &;
|
||||
position: relative;
|
||||
|
||||
&__input-panel {
|
||||
display: flex;
|
||||
position: relative;
|
||||
justify-content: space-between;
|
||||
align-items: center;
|
||||
width: 100%;
|
||||
height: 40px;
|
||||
padding: 0 10px;
|
||||
color: color("main");
|
||||
border: 2px solid color("border");
|
||||
font-family: "Montserrat", sans-serif;
|
||||
font-size: 14px;
|
||||
}
|
||||
|
||||
&__input {
|
||||
display: none;
|
||||
width: 100%;
|
||||
height: 100%;
|
||||
background: color("background");
|
||||
color: color("main");
|
||||
outline: none;
|
||||
border: none;
|
||||
font-size: 14px;
|
||||
|
||||
&::placeholder {
|
||||
color: color("second");
|
||||
font-family: "Montserrat", sans-serif;
|
||||
}
|
||||
}
|
||||
|
||||
&__toggle {
|
||||
position: absolute;
|
||||
width: 20px;
|
||||
height: 20px;
|
||||
right: 7px;
|
||||
top: 7px;
|
||||
transition: transform 0.2s ease;
|
||||
|
||||
&-icon {
|
||||
width: 100%;
|
||||
height: 100%;
|
||||
}
|
||||
}
|
||||
|
||||
&__content {
|
||||
position: relative;
|
||||
width: 100%;
|
||||
height: var(--height);
|
||||
background: color("background");
|
||||
list-style: none;
|
||||
border: 2px solid color("border");
|
||||
overflow: auto;
|
||||
|
||||
&--transition-content,
|
||||
&--transition-list {
|
||||
&-enter-active,
|
||||
&-leave-active {
|
||||
transition: height 0.18s;
|
||||
overflow-y: hidden;
|
||||
}
|
||||
&-enter-from,
|
||||
&-leave-to {
|
||||
height: 0;
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
&__item {
|
||||
padding: 5px 10px;
|
||||
cursor: pointer;
|
||||
color: color("main");
|
||||
|
||||
&--selected {
|
||||
background: color("background-hover");
|
||||
}
|
||||
|
||||
@media (min-width: $mobileWidth) {
|
||||
&--selected {
|
||||
&:hover {
|
||||
background: color("background-hover", $saturation: 20%);
|
||||
}
|
||||
}
|
||||
|
||||
&:not(&--selected) {
|
||||
&:hover {
|
||||
background: color("background-hover", $hue: 120deg, $saturation: 20%);
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
&__no-items {
|
||||
padding: 5px 10px;
|
||||
}
|
||||
|
||||
&--active {
|
||||
#{$p}__input {
|
||||
display: block;
|
||||
}
|
||||
#{$p}__label {
|
||||
display: none;
|
||||
}
|
||||
#{$p}__toggle {
|
||||
transform: rotate(180deg);
|
||||
}
|
||||
}
|
||||
}
|
||||
</style>
|
268
src/components/NewsList/SideBar/DateSelector.vue
Normal file
268
src/components/NewsList/SideBar/DateSelector.vue
Normal file
@ -0,0 +1,268 @@
|
||||
<template>
|
||||
<div class="date-selector" :class="{ 'date-selector--showed': mainShowed }" v-click-outside="closeAll">
|
||||
<div class="date-selector__input-panel" @click.self="toggleMain">
|
||||
<span class="date-selector__label" @click.self="toggleMain">{{ displayedValue }}</span>
|
||||
<div class="date-selector__toggle" @click="toggleMain">
|
||||
<font-awesome-icon class="date-selector__toggle-icon" icon="chevron-down" />
|
||||
</div>
|
||||
</div>
|
||||
<Transition v-show="mainShowed" name="date-selector--transition">
|
||||
<ul class="date-selector__content">
|
||||
<li v-for="variant in variants" class="date-selector__item" :key="variant" @click="variant.callback()">
|
||||
{{ variant.title }}
|
||||
</li>
|
||||
</ul>
|
||||
</Transition>
|
||||
<Transition v-show="dateCompWindowShowed" name="date-selector--transition">
|
||||
<DateCompWindow
|
||||
class="date-selector__calendar"
|
||||
v-model:firstDate="firstDate"
|
||||
v-model:secondDate="secondDate"
|
||||
ref="refDateCompWindow"
|
||||
/>
|
||||
</Transition>
|
||||
</div>
|
||||
</template>
|
||||
|
||||
<script setup>
|
||||
import { computed, ref, watch } from "vue";
|
||||
// directives
|
||||
import vClickOutside from "@/directives/ClickOutside";
|
||||
// components
|
||||
import DateCompWindow from "@/components/Ui/DateComp/DateCompWindow.vue";
|
||||
// stores
|
||||
import { useNews } from "@/stores";
|
||||
const newsStore = useNews();
|
||||
|
||||
const firstDate = computed({
|
||||
get: () => new Date(newsStore.selectedDatesStart),
|
||||
set: (to) => {
|
||||
newsStore.selectedDatesStart = to.getTime();
|
||||
},
|
||||
});
|
||||
watch(
|
||||
() => firstDate.value,
|
||||
(to) => {
|
||||
if (to.getTime() === 0) {
|
||||
selectedVariant.value = "";
|
||||
closeDateCompWindow();
|
||||
}
|
||||
}
|
||||
);
|
||||
|
||||
const secondDate = computed({
|
||||
get: () => new Date(newsStore.selectedDatesEnd),
|
||||
set: (to) => {
|
||||
newsStore.selectedDatesEnd = to.getTime();
|
||||
},
|
||||
});
|
||||
|
||||
watch(
|
||||
() => secondDate.value,
|
||||
(to) => {
|
||||
if (to.getTime() !== 0 && selectedVariant.value === "select") {
|
||||
selectedVariant.value = "selected";
|
||||
closeDateCompWindow();
|
||||
emitFiltration();
|
||||
}
|
||||
}
|
||||
);
|
||||
|
||||
const variants = [
|
||||
{ title: "Всё время", name: "all", callback: () => setDates("all") },
|
||||
{ title: "Неделя", name: "week", callback: () => setDates("week") },
|
||||
{ title: "Месяц", name: "month", callback: () => setDates("month") },
|
||||
{ title: "Год", name: "year", callback: () => setDates("year") },
|
||||
{ title: "Выбрать период", name: "select", callback: () => setDates("select") },
|
||||
];
|
||||
|
||||
function setDates(type) {
|
||||
let date = new Date();
|
||||
|
||||
switch (type) {
|
||||
case "all":
|
||||
firstDate.value = new Date(0);
|
||||
secondDate.value = new Date(0);
|
||||
selectedVariant.value = "";
|
||||
emitFiltration();
|
||||
closeMain();
|
||||
break;
|
||||
case "week":
|
||||
date.setDate(date.getDate() - 7);
|
||||
firstDate.value = date;
|
||||
secondDate.value = new Date();
|
||||
selectedVariant.value = "week";
|
||||
emitFiltration();
|
||||
closeMain();
|
||||
break;
|
||||
case "month":
|
||||
date.setMonth(date.getMonth() - 1);
|
||||
firstDate.value = date;
|
||||
secondDate.value = new Date();
|
||||
selectedVariant.value = "month";
|
||||
emitFiltration();
|
||||
closeMain();
|
||||
break;
|
||||
case "year":
|
||||
date.setFullYear(date.getFullYear() - 1);
|
||||
firstDate.value = date;
|
||||
secondDate.value = new Date();
|
||||
selectedVariant.value = "year";
|
||||
emitFiltration();
|
||||
closeMain();
|
||||
break;
|
||||
case "select":
|
||||
firstDate.value = new Date();
|
||||
secondDate.value = new Date(0);
|
||||
selectedVariant.value = "select";
|
||||
openDateCompWindow();
|
||||
break;
|
||||
}
|
||||
}
|
||||
|
||||
function dateToString(date) {
|
||||
let day = date.getDate();
|
||||
day = day < 10 ? "0" + day : day;
|
||||
let month = date.getMonth() + 1;
|
||||
month = month < 10 ? "0" + month : month;
|
||||
let year = date.getFullYear();
|
||||
return `${day}.${month}.${year}`;
|
||||
}
|
||||
|
||||
function emitFiltration() {
|
||||
newsStore.filterNeeded = true;
|
||||
}
|
||||
|
||||
// #region handling show states and showed values
|
||||
const mainShowed = ref(false);
|
||||
const dateCompWindowShowed = ref(false);
|
||||
const placeholder = ref("Выберите период времени");
|
||||
const selectedVariant = ref("");
|
||||
|
||||
function closeAll() {
|
||||
closeMain();
|
||||
closeDateCompWindow();
|
||||
}
|
||||
|
||||
function toggleMain() {
|
||||
mainShowed.value ? closeMain() : openMain();
|
||||
}
|
||||
|
||||
function openMain() {
|
||||
if (!mainShowed.value) {
|
||||
mainShowed.value = true;
|
||||
closeDateCompWindow();
|
||||
}
|
||||
}
|
||||
|
||||
function closeMain() {
|
||||
mainShowed.value = false;
|
||||
}
|
||||
|
||||
function openDateCompWindow() {
|
||||
closeMain();
|
||||
dateCompWindowShowed.value = true;
|
||||
}
|
||||
function closeDateCompWindow() {
|
||||
dateCompWindowShowed.value = false;
|
||||
if (selectedVariant.value === "Select") {
|
||||
selectedVariant.value = "";
|
||||
}
|
||||
}
|
||||
|
||||
const displayedValue = computed(() => {
|
||||
if (selectedVariant.value === "selected") {
|
||||
return `${dateToString(firstDate.value)} - ${dateToString(secondDate.value)}`;
|
||||
} else if (selectedVariant.value !== "") {
|
||||
return variants.find((x) => x.name === selectedVariant.value).title;
|
||||
} else {
|
||||
return placeholder.value;
|
||||
}
|
||||
});
|
||||
// #endregion
|
||||
</script>
|
||||
|
||||
<style lang="scss">
|
||||
.date-selector {
|
||||
$p: &;
|
||||
position: relative;
|
||||
|
||||
&__input-panel {
|
||||
display: flex;
|
||||
position: relative;
|
||||
justify-content: space-between;
|
||||
align-items: center;
|
||||
width: 100%;
|
||||
height: 40px;
|
||||
padding: 0 10px;
|
||||
color: color("main");
|
||||
border: 2px solid color("border");
|
||||
font-family: "Montserrat", sans-serif;
|
||||
font-size: 14px;
|
||||
cursor: pointer;
|
||||
}
|
||||
|
||||
&__toggle {
|
||||
position: absolute;
|
||||
width: 20px;
|
||||
height: 20px;
|
||||
right: 7px;
|
||||
top: 7px;
|
||||
transition: transform 0.2s ease;
|
||||
|
||||
&-icon {
|
||||
width: 100%;
|
||||
height: 100%;
|
||||
}
|
||||
}
|
||||
|
||||
&__content {
|
||||
position: relative;
|
||||
width: 100%;
|
||||
height: 176px;
|
||||
background: color("background");
|
||||
list-style: none;
|
||||
border: 2px solid color("border");
|
||||
overflow: auto;
|
||||
}
|
||||
|
||||
&__item {
|
||||
padding: 5px 10px;
|
||||
cursor: pointer;
|
||||
color: color("main");
|
||||
|
||||
&--selected {
|
||||
background: color("background-hover");
|
||||
}
|
||||
|
||||
@media (min-width: $mobileWidth) {
|
||||
&:hover {
|
||||
background: color("background-hover");
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
&__calendar {
|
||||
margin: 0 auto;
|
||||
}
|
||||
|
||||
&--showed {
|
||||
#{$p}__toggle {
|
||||
transform: rotate(180deg);
|
||||
}
|
||||
}
|
||||
|
||||
&--transition,
|
||||
&--transition-list {
|
||||
&-enter-active,
|
||||
&-leave-active {
|
||||
transition: height 0.18s;
|
||||
overflow-y: hidden;
|
||||
}
|
||||
&-enter-from,
|
||||
&-leave-to {
|
||||
height: 0;
|
||||
}
|
||||
}
|
||||
}
|
||||
</style>
|
94
src/components/NewsList/SideBar/SidebarContainer.vue
Normal file
94
src/components/NewsList/SideBar/SidebarContainer.vue
Normal file
@ -0,0 +1,94 @@
|
||||
<template>
|
||||
<aside class="sidebar wrap" :class="uiStore.sidebarOpened ? 'sidebar--opened' : ''">
|
||||
<Transition name="sidebar__closer--transition">
|
||||
<div class="sidebar__closer" v-show="uiStore.sidebarOpened" @click="uiStore.closeSidebar"></div>
|
||||
</Transition>
|
||||
<Transition name="sidebar__container--transition">
|
||||
<div class="sidebar__container" v-show="uiStore.sidebarOpened">
|
||||
<SidebarContent />
|
||||
<font-awesome-icon class="sidebar__close-icon" icon="xmark" @click="uiStore.closeSidebar" />
|
||||
</div>
|
||||
</Transition>
|
||||
</aside>
|
||||
</template>
|
||||
<script setup>
|
||||
import SidebarContent from "@/components/NewsList/SideBar/SidebarContent.vue";
|
||||
// stores
|
||||
import { useUi } from "@/stores";
|
||||
const uiStore = useUi();
|
||||
</script>
|
||||
<style lang="scss">
|
||||
.sidebar {
|
||||
$p: &;
|
||||
position: fixed;
|
||||
top: 48px;
|
||||
max-height: calc(100vh - 53px);
|
||||
display: flex;
|
||||
justify-content: flex-end;
|
||||
pointer-events: none;
|
||||
z-index: 70;
|
||||
|
||||
&__container {
|
||||
position: relative;
|
||||
width: 350px;
|
||||
right: 0;
|
||||
padding: 11px 10px;
|
||||
pointer-events: all;
|
||||
background: color("background");
|
||||
overflow: auto;
|
||||
border: 2px solid color("border");
|
||||
|
||||
@media (max-width: $mobileWidth) {
|
||||
width: 100%;
|
||||
}
|
||||
|
||||
&--transition {
|
||||
&-enter-active,
|
||||
&-leave-active {
|
||||
transition: 0.2s;
|
||||
}
|
||||
&-enter-from,
|
||||
&-leave-to {
|
||||
right: -106%;
|
||||
opacity: 0;
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
&__closer {
|
||||
position: fixed;
|
||||
top: 0;
|
||||
right: 0;
|
||||
bottom: 0;
|
||||
left: 0;
|
||||
background: rgba(0, 0, 0, 0.5);
|
||||
pointer-events: all;
|
||||
|
||||
&--transition {
|
||||
&-enter-active,
|
||||
&-leave-active {
|
||||
transition: 0.2s;
|
||||
}
|
||||
&-enter-from,
|
||||
&-leave-to {
|
||||
opacity: 0;
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
&__close-icon {
|
||||
position: absolute;
|
||||
top: 8px;
|
||||
right: 10px;
|
||||
width: 25px;
|
||||
height: 25px;
|
||||
color: color("second");
|
||||
cursor: pointer;
|
||||
transition: color 0.18s ease;
|
||||
|
||||
&:hover {
|
||||
color: color("second-hover");
|
||||
}
|
||||
}
|
||||
}
|
||||
</style>
|
61
src/components/NewsList/SideBar/SidebarContent.vue
Normal file
61
src/components/NewsList/SideBar/SidebarContent.vue
Normal file
@ -0,0 +1,61 @@
|
||||
<template>
|
||||
<div class="sidebar-content">
|
||||
<div class="sidebar-content__header">
|
||||
<div class="sidebar-content__header-text">Фильтры:</div>
|
||||
<div class="sidebar-content__reset" v-show="resetShowed" @click="resetFilters()">сбросить фильтры</div>
|
||||
</div>
|
||||
|
||||
<DateSelector />
|
||||
<CategoriesSelector />
|
||||
<TagsSelector />
|
||||
</div>
|
||||
</template>
|
||||
<script setup>
|
||||
import { computed } from "vue";
|
||||
// components
|
||||
import CategoriesSelector from "@/components/NewsList/SideBar/CategoriesSelector.vue";
|
||||
import TagsSelector from "@/components/NewsList/SideBar/TagsSelector.vue";
|
||||
import DateSelector from "@/components/NewsList/SideBar/DateSelector.vue";
|
||||
// stores
|
||||
import { useNews } from "@/stores";
|
||||
const newsStore = useNews();
|
||||
|
||||
const resetShowed = computed(
|
||||
() => newsStore.selectedCategoriesIds.length > 0 || newsStore.selectedTagsIds.length > 0 || newsStore.selectedDatesEnd > 0
|
||||
);
|
||||
|
||||
function resetFilters() {
|
||||
newsStore.selectedCategoriesIds = [];
|
||||
newsStore.selectedTagsIds = [];
|
||||
newsStore.selectedDatesStart = 0;
|
||||
newsStore.selectedDatesEnd = 0;
|
||||
newsStore.filterNeeded = true;
|
||||
}
|
||||
</script>
|
||||
<style lang="scss">
|
||||
.sidebar-content {
|
||||
display: flex;
|
||||
flex-direction: column;
|
||||
gap: 10px;
|
||||
|
||||
&__header {
|
||||
display: flex;
|
||||
gap: 20px;
|
||||
line-height: normal;
|
||||
width: 85%;
|
||||
}
|
||||
|
||||
&__header-text {
|
||||
flex: 1;
|
||||
}
|
||||
|
||||
&__reset {
|
||||
cursor: pointer;
|
||||
transition: color 0.18s ease;
|
||||
|
||||
&:hover {
|
||||
color: color("second");
|
||||
}
|
||||
}
|
||||
}
|
||||
</style>
|
243
src/components/NewsList/SideBar/TagsSelector.vue
Normal file
243
src/components/NewsList/SideBar/TagsSelector.vue
Normal file
@ -0,0 +1,243 @@
|
||||
<template>
|
||||
<Transition name="tags-selector--transition">
|
||||
<div
|
||||
class="tags-selector"
|
||||
v-if="newsStore.possibleTags.length > 0"
|
||||
:class="{ 'tags-selector--active': active }"
|
||||
v-click-outside="deactivate"
|
||||
>
|
||||
<div class="tags-selector__input-panel" @click.self="activate">
|
||||
<ul class="tags-selector__selected-list" v-show="tagsSelected.length > 0" @click.self="activate">
|
||||
<li v-for="tag in tagsSelected" class="tags-selector__selected-item" :key="tag.id">
|
||||
<div class="tags-selector__selected-item-text">{{ tag.title }}</div>
|
||||
<font-awesome-icon class="tags-selector__selected-item-icon" icon="xmark" @click="toggleSelection(tag.id)" />
|
||||
</li>
|
||||
</ul>
|
||||
|
||||
<div class="tags-selector__input-container" v-show="tagsSelected.length === 0 || active" @click.self="activate">
|
||||
<div class="tags-selector__label" @click.self="activate">{{ label }}</div>
|
||||
<input class="tags-selector__input" :placeholder="placeholder" ref="refInput" v-model="filter" />
|
||||
</div>
|
||||
<div class="tags-selector__toggle" @click="toggleActivation">
|
||||
<font-awesome-icon class="tags-selector__toggle-icon" icon="chevron-down" />
|
||||
</div>
|
||||
</div>
|
||||
|
||||
<Transition v-show="active" name="tags-selector__content--transition-content">
|
||||
<ul class="tags-selector__content" :style="styleHeight">
|
||||
<li
|
||||
v-for="tag in tagsFiltered"
|
||||
class="tags-selector__item"
|
||||
:class="newsStore.selectedTagsIds.includes(tag.id) ? 'tags-selector__item--selected' : ''"
|
||||
:key="tag.id"
|
||||
@click="toggleSelection(tag.id)"
|
||||
>
|
||||
{{ tag.title }}
|
||||
</li>
|
||||
<li class="tags-selector__no-items" v-show="tagsFiltered.length === 0">Тегов не найдено</li>
|
||||
</ul>
|
||||
</Transition>
|
||||
</div>
|
||||
</Transition>
|
||||
</template>
|
||||
<script setup>
|
||||
import { computed, nextTick, ref } from "vue";
|
||||
// directives
|
||||
import vClickOutside from "@/directives/ClickOutside";
|
||||
// stores
|
||||
import { useNews } from "@/stores";
|
||||
const newsStore = useNews();
|
||||
|
||||
const active = ref(false);
|
||||
const placeholder = ref("Выберите теги");
|
||||
const filter = ref("");
|
||||
|
||||
const styleHeight = computed(() => ({
|
||||
"--height": (newsStore.possibleTags.length > 5 ? 200 : 34.4 * newsStore.possibleTags.length + 4) + "px",
|
||||
}));
|
||||
const tagsFiltered = computed(() => newsStore.possibleTags.filter((x) => x.title.includes(filter.value)));
|
||||
const tagsSelected = computed(() => newsStore.possibleTags.filter((x) => newsStore.selectedTagsIds.includes(x.id)));
|
||||
|
||||
const refInput = ref(null);
|
||||
|
||||
const label = computed(() => {
|
||||
if (newsStore.selectedTagsIds.length > 0) {
|
||||
return "";
|
||||
} else {
|
||||
return placeholder.value;
|
||||
}
|
||||
});
|
||||
|
||||
function activate() {
|
||||
if (!active.value) {
|
||||
active.value = true;
|
||||
|
||||
if (window.innerWidth > 768) {
|
||||
nextTick(() => {
|
||||
refInput.value.focus();
|
||||
});
|
||||
}
|
||||
}
|
||||
}
|
||||
function deactivate() {
|
||||
active.value = false;
|
||||
}
|
||||
function toggleActivation() {
|
||||
active.value ? deactivate() : activate();
|
||||
}
|
||||
|
||||
function toggleSelection(id) {
|
||||
if (newsStore.selectedTagsIds.includes(id)) {
|
||||
newsStore.selectedTagsIds = newsStore.selectedTagsIds.filter((x) => x !== id);
|
||||
} else {
|
||||
newsStore.selectedTagsIds = [...newsStore.selectedTagsIds, id];
|
||||
}
|
||||
newsStore.filterNeeded = true;
|
||||
}
|
||||
</script>
|
||||
<style lang="scss">
|
||||
.tags-selector {
|
||||
$p: &;
|
||||
position: relative;
|
||||
|
||||
&__selected-list {
|
||||
display: flex;
|
||||
gap: 4px;
|
||||
flex-wrap: wrap;
|
||||
margin: 5.62px 0;
|
||||
list-style: none;
|
||||
}
|
||||
|
||||
&__selected-item {
|
||||
display: inline-flex;
|
||||
align-items: center;
|
||||
gap: 5px;
|
||||
padding: 0 4px;
|
||||
border: 1px solid color("border");
|
||||
|
||||
&-icon {
|
||||
cursor: pointer;
|
||||
|
||||
&:hover {
|
||||
color: color("second-hover");
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
&__input-panel {
|
||||
position: relative;
|
||||
width: 100%;
|
||||
min-height: 40px;
|
||||
padding: 0 10px;
|
||||
color: color("main");
|
||||
border: 2px solid color("border");
|
||||
font-family: "Montserrat", sans-serif;
|
||||
font-size: 14px;
|
||||
}
|
||||
|
||||
&__input-container {
|
||||
display: flex;
|
||||
justify-content: space-between;
|
||||
align-items: center;
|
||||
|
||||
height: 36px;
|
||||
}
|
||||
|
||||
&__label {
|
||||
}
|
||||
|
||||
&__input {
|
||||
display: none;
|
||||
width: 100%;
|
||||
height: 100%;
|
||||
margin: 9px 0;
|
||||
background: color("background");
|
||||
color: color("main");
|
||||
outline: none;
|
||||
border: none;
|
||||
font-size: 14px;
|
||||
|
||||
&::placeholder {
|
||||
color: color("second");
|
||||
font-family: "Montserrat", sans-serif;
|
||||
}
|
||||
}
|
||||
|
||||
&__toggle {
|
||||
position: absolute;
|
||||
width: 20px;
|
||||
height: 20px;
|
||||
right: 7px;
|
||||
top: 7px;
|
||||
transition: transform 0.2s ease;
|
||||
|
||||
&-icon {
|
||||
width: 100%;
|
||||
height: 100%;
|
||||
}
|
||||
}
|
||||
|
||||
&__content {
|
||||
position: relative;
|
||||
width: 100%;
|
||||
height: var(--height);
|
||||
background: color("background");
|
||||
list-style: none;
|
||||
border: 2px solid color("border");
|
||||
overflow: auto;
|
||||
|
||||
&--transition-content,
|
||||
&--transition-list {
|
||||
&-enter-active,
|
||||
&-leave-active {
|
||||
transition: height 0.18s;
|
||||
overflow-y: hidden;
|
||||
}
|
||||
&-enter-from,
|
||||
&-leave-to {
|
||||
height: 0;
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
&__item {
|
||||
padding: 5px 10px;
|
||||
cursor: pointer;
|
||||
color: color("main");
|
||||
|
||||
&--selected {
|
||||
background: color("background-hover");
|
||||
}
|
||||
|
||||
@media (min-width: $mobileWidth) {
|
||||
&--selected {
|
||||
&:hover {
|
||||
background: color("background-hover", $saturation: 20%);
|
||||
}
|
||||
}
|
||||
|
||||
&:not(&--selected) {
|
||||
&:hover {
|
||||
background: color("background-hover", $hue: 120deg, $saturation: 20%);
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
&__no-items {
|
||||
padding: 5px 10px;
|
||||
}
|
||||
|
||||
&--active {
|
||||
#{$p}__input {
|
||||
display: block;
|
||||
}
|
||||
#{$p}__label {
|
||||
display: none;
|
||||
}
|
||||
#{$p}__toggle {
|
||||
transform: rotate(180deg);
|
||||
}
|
||||
}
|
||||
}
|
||||
</style>
|
375
src/components/NewsPost/Comments/CommentsList.vue
Normal file
375
src/components/NewsPost/Comments/CommentsList.vue
Normal 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>
|
71
src/components/NewsPost/Comments/CommentsListItem.vue
Normal file
71
src/components/NewsPost/Comments/CommentsListItem.vue
Normal 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>
|
107
src/components/NewsPost/NewsPostContainer.vue
Normal file
107
src/components/NewsPost/NewsPostContainer.vue
Normal 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>
|
140
src/components/SearchPage/SearchPageContainer.vue
Normal file
140
src/components/SearchPage/SearchPageContainer.vue
Normal 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>
|
27
src/components/Service/PathNotFoundPage.vue
Normal file
27
src/components/Service/PathNotFoundPage.vue
Normal 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>
|
27
src/components/TestsPolygon.vue
Normal file
27
src/components/TestsPolygon.vue
Normal 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>
|
34
src/components/Ui/ButtonComp.vue
Normal file
34
src/components/Ui/ButtonComp.vue
Normal 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>
|
112
src/components/Ui/DateComp/DateComp.vue
Normal file
112
src/components/Ui/DateComp/DateComp.vue
Normal 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>
|
421
src/components/Ui/DateComp/DateCompWindow.vue
Normal file
421
src/components/Ui/DateComp/DateCompWindow.vue
Normal 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>
|
18
src/components/Ui/HeaderComp.vue
Normal file
18
src/components/Ui/HeaderComp.vue
Normal 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>
|
81
src/components/Ui/ImageComp/ImageComp.vue
Normal file
81
src/components/Ui/ImageComp/ImageComp.vue
Normal 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>
|
122
src/components/Ui/ImageComp/ImagePreviewComp.vue
Normal file
122
src/components/Ui/ImageComp/ImagePreviewComp.vue
Normal 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>
|
40
src/components/Ui/LinkComp.vue
Normal file
40
src/components/Ui/LinkComp.vue
Normal 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>
|
11
src/components/Ui/LoadingComp.vue
Normal file
11
src/components/Ui/LoadingComp.vue
Normal 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>
|
90
src/components/Ui/ModalComp.vue
Normal file
90
src/components/Ui/ModalComp.vue
Normal 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>
|
9
src/components/Ui/index.js
Normal file
9
src/components/Ui/index.js
Normal 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 };
|
305
src/components/User/LoginRegisterPage.vue
Normal file
305
src/components/User/LoginRegisterPage.vue
Normal 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>
|
7
src/components/User/UserContainer.vue
Normal file
7
src/components/User/UserContainer.vue
Normal file
@ -0,0 +1,7 @@
|
||||
<template>
|
||||
<router-view />
|
||||
</template>
|
||||
|
||||
<script setup></script>
|
||||
|
||||
<style></style>
|
29
src/components/User/UserPage.vue
Normal file
29
src/components/User/UserPage.vue
Normal 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>
|
54
src/composables/api/comments.js
Normal file
54
src/composables/api/comments.js
Normal 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 };
|
||||
};
|
72
src/composables/api/likes.js
Normal file
72
src/composables/api/likes.js
Normal 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 };
|
||||
};
|
52
src/composables/api/myaxios.js
Normal file
52
src/composables/api/myaxios.js
Normal 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;
|
||||
}
|
||||
}
|
80
src/composables/api/news.js
Normal file
80
src/composables/api/news.js
Normal 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 };
|
||||
};
|
24
src/composables/api/user.js
Normal file
24
src/composables/api/user.js
Normal 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 };
|
||||
};
|
12
src/composables/themes/index.js
Normal file
12
src/composables/themes/index.js
Normal 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 };
|
||||
}
|
47
src/composables/user/index.js
Normal file
47
src/composables/user/index.js
Normal 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 };
|
||||
};
|
17
src/composables/weather/index.js
Normal file
17
src/composables/weather/index.js
Normal 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 };
|
16
src/directives/ClickOutside.js
Normal file
16
src/directives/ClickOutside.js
Normal 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
49
src/js/fontawesome.js
Normal 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,
|
||||
];
|
58
src/js/validator/fieldChecks.js
Normal file
58
src/js/validator/fieldChecks.js
Normal 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);
|
||||
}
|
41
src/js/validator/formFields.js
Normal file
41
src/js/validator/formFields.js
Normal 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(", ");
|
||||
}
|
||||
}
|
2
src/js/validator/index.js
Normal file
2
src/js/validator/index.js
Normal file
@ -0,0 +1,2 @@
|
||||
export { FieldChecks } from "./fieldChecks";
|
||||
export { Field, FieldsContainer } from "./formFields";
|
23
src/main.js
Normal file
23
src/main.js
Normal 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
100
src/router/index.js
Normal 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
4
src/stores/index.js
Normal 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
58
src/stores/news.js
Normal 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
33
src/stores/ui.js
Normal 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
40
src/stores/user.js
Normal 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
42
src/styles/themes.scss
Normal 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
59
src/styles/variables.scss
Normal 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
23
vue.config.js
Normal 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,
|
||||
},
|
||||
},
|
||||
},
|
||||
});
|
Loading…
Reference in New Issue
Block a user