This commit is contained in:
irpsv
2020-01-07 19:00:56 +05:00
parent 5d4c440bfd
commit a44690780f
31 changed files with 13122 additions and 0 deletions

9
.editorconfig Normal file
View File

@ -0,0 +1,9 @@
root = true
[*]
indent_style = space
indent_size = 2
charset = utf-8
end_of_line = lf
trim_trailing_whitespace = false
insert_final_newline = false

21
.gitignore vendored Normal file
View File

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

5
babel.config.js Normal file
View File

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

12255
package-lock.json generated Normal file

File diff suppressed because it is too large Load Diff

49
package.json Normal file
View File

@ -0,0 +1,49 @@
{
"name": "vue-blog-habr",
"version": "0.1.0",
"private": true,
"scripts": {
"serve": "vue-cli-service serve",
"build": "vue-cli-service build",
"lint": "vue-cli-service lint"
},
"dependencies": {
"axios": "^0.19.0",
"bootstrap-vue": "^2.1.0",
"core-js": "^3.4.4",
"sass-loader": "^8.0.0",
"vue": "^2.6.10",
"vue-router": "^3.1.3",
"vuex": "^3.1.2"
},
"devDependencies": {
"@vue/cli-plugin-babel": "^4.1.0",
"@vue/cli-plugin-eslint": "^4.1.0",
"@vue/cli-service": "^4.1.0",
"babel-eslint": "^10.0.3",
"eslint": "^5.16.0",
"eslint-plugin-vue": "^5.0.0",
"node-sass": "^4.13.0",
"vue-template-compiler": "^2.6.10"
},
"eslintConfig": {
"root": true,
"env": {
"node": true
},
"extends": [
"plugin:vue/essential",
"eslint:recommended"
],
"rules": {
"no-console": "off"
},
"parserOptions": {
"parser": "babel-eslint"
}
},
"browserslist": [
"> 1%",
"last 2 versions"
]
}

BIN
public/favicon.ico Normal file

Binary file not shown.

After

Width:  |  Height:  |  Size: 4.2 KiB

17
public/index.html Normal file
View File

@ -0,0 +1,17 @@
<!DOCTYPE html>
<html lang="en">
<head>
<meta charset="utf-8">
<meta http-equiv="X-UA-Compatible" content="IE=edge">
<meta name="viewport" content="width=device-width,initial-scale=1.0">
<link rel="icon" href="<%= BASE_URL %>favicon.ico">
<title>vue-blog-habr</title>
</head>
<body>
<noscript>
<strong>We're sorry but vue-blog-habr doesn't work properly without JavaScript enabled. Please enable it to continue.</strong>
</noscript>
<div id="app"></div>
<!-- built files will be auto injected -->
</body>
</html>

33
src/App.vue Normal file
View File

@ -0,0 +1,33 @@
<template>
<div id="app">
<template v-if="is404">
<router-view></router-view>
</template>
<template v-else>
<Header></Header>
<main>
<b-container>
<router-view></router-view>
</b-container>
</main>
</template>
</div>
</template>
<script>
import '@/styles/index.scss';
import Header from '@/components/Header.vue';
export default {
name: 'App',
components: {
Header,
},
computed: {
is404() {
return this.$route.name === '404';
},
},
}
</script>

62
src/api/index.js Normal file
View File

@ -0,0 +1,62 @@
import Article from '@/models/Article';
import Comment from '@/models/Comment';
import Category from '@/models/Category';
export default {
getArticles() {
const comments = this.getComments();
const items = [
{
id: 1, title: 'Статья 1', content: 'Содержание статьи 1',
},
{
id: 2, title: 'Статья 2', content: 'Содержание статьи 2',
},
{
id: 3, title: 'Статья 3', content: 'Содержание статьи 3',
},
{
id: 4, title: 'Статья 4', content: 'Содержание статьи 4',
},
{
id: 5, title: 'Статья 5', content: 'Содержание статьи 5',
},
{
id: 6, title: 'Статья 6', content: 'Содержание статьи 6',
},
];
return items.map((item) => {
const article = Article.createFrom(item);
article.comments = comments.filter((comment) => comment.article_id == article.id);
return article;
});
},
getComments() {
const items = [
{
id: 1, article_id: 1, content: 'Комментарий к статье 1',
},
];
return items.map((item) => Comment.createFrom(item))
},
getCategories() {
const items = [
{
id: 1, title: 'Новости', articles: [1,3,5],
},
{
id: 2, title: 'Спорт', articles: [2,3,4],
},
{
id: 3, title: 'Красота', articles: [],
},
];
return items.map((item) => Category.createFrom(item))
},
addComment(comment) {
if (comment) {
// отправка запроса на бэк
}
},
};

BIN
src/assets/logo.png Normal file

Binary file not shown.

After

Width:  |  Height:  |  Size: 6.7 KiB

View File

@ -0,0 +1,34 @@
<template>
<b-card :title="item.title" class="article-item-card">
<router-link :to="getArticleRoute" class="card-link">
Подробнее
</router-link>
</b-card>
</template>
<script>
import Article from '@/models/Article';
export default {
name: 'ArticleItem',
props: {
item: Article,
},
computed: {
getArticleRoute() {
return {
name: 'Article',
params: {
post_id: this.item.id,
},
};
},
},
}
</script>
<style>
.article-item-card {
margin-bottom: 1rem;
}
</style>

View File

@ -0,0 +1,39 @@
<template>
<ListItems :items="items" class="row">
<template v-slot:default="props">
<b-col :cols="itemCols">
<ArticleItem :item="props.item"></ArticleItem>
</b-col>
</template>
<template v-slot:empty>
<b-col>
Статьи еще пишутся :)
</b-col>
</template>
</ListItems>
</template>
<script>
import ListItems from '@/components/ListItems.vue'
import ArticleItem from '@/components/ArticleItem.vue'
export default {
name: 'ArticleItems',
components: {
ArticleItem,
ListItems,
},
extends: ListItems,
props: {
cols: {
type: Number,
default: 1,
},
},
computed: {
itemCols() {
return 12 / this.cols;
},
},
}
</script>

View File

@ -0,0 +1,16 @@
<template>
<div>
{{ item.title }}
</div>
</template>
<script>
import Category from '@/models/Category';
export default {
name: 'CategoryItem',
props: {
item: Category,
},
}
</script>

View File

@ -0,0 +1,32 @@
<template>
<form @submit.prevent="onSubmit">
<textarea class='form-control' v-model="content"></textarea>
<br>
<button type="submit" class="btn btn-primary">Сохранить</button>
</form>
</template>
<script>
export default {
name: 'CommentForm',
props: {
articleId: Number,
},
data() {
return {
content: '',
};
},
methods: {
onSubmit() {
if (this.content) {
this.$store.dispatch('addComment', {
content: this.content,
article_id: this.articleId,
});
this.content = '';
}
},
},
}
</script>

View File

@ -0,0 +1,31 @@
<template>
<b-card>
<b-card-text>
{{ item.content }}
</b-card-text>
<router-link :to="getArticleRoute">
Перейти к статье
</router-link>
</b-card>
</template>
<script>
import Comment from '@/models/Comment';
export default {
name: 'CommentItem',
props: {
item: Comment,
},
computed: {
getArticleRoute() {
return {
name: 'Article',
params: {
post_id: this.item.article_id,
},
};
},
},
}
</script>

16
src/components/Header.vue Normal file
View File

@ -0,0 +1,16 @@
<template>
<b-container>
<b-navbar>
<b-navbar-brand to="/">
<img alt="Vue logo" src="@/assets/logo.png" style="height:50px;">
Vue-blog-habr
</b-navbar-brand>
</b-navbar>
</b-container>
</template>
<script>
export default {
name: 'Header',
}
</script>

View File

@ -0,0 +1,29 @@
<template>
<div class='list-items'>
<template v-if='items && items.length'>
<template v-for='(item, index) in items'>
<slot :item="item" :index="index">
Элемент {{ index }}
</slot>
</template>
</template>
<template v-else>
<slot name="empty">
Нет элементов
</slot>
</template>
</div>
</template>
<script>
export default {
name: 'ListItems',
props: {
items: Array,
},
}
</script>

18
src/main.js Normal file
View File

@ -0,0 +1,18 @@
import App from './App.vue'
import Vue from 'vue'
import VueRouter from 'vue-router'
import BootstrapVue from 'bootstrap-vue'
import store from './store'
import router from './router'
Vue.config.productionTip = false
Vue.use(VueRouter)
Vue.use(BootstrapVue)
new Vue({
store,
router,
render: h => h(App),
}).$mount('#app')

18
src/models/Article.js Normal file
View File

@ -0,0 +1,18 @@
export default class Article
{
constructor(id, title, content) {
this.id = id;
this.title = title;
this.content = content;
this.comments = [];
}
addComment(item) {
this.comments.push(item);
}
static createFrom(data) {
const {id, title, content} = data;
return new this(id, title, content);
}
}

13
src/models/Category.js Normal file
View File

@ -0,0 +1,13 @@
export default class Category
{
constructor(id, title, articles) {
this.id = id;
this.title = title;
this.articles = articles;
}
static createFrom(data) {
const {id, title, articles} = data;
return new this(id, title, articles);
}
}

13
src/models/Comment.js Normal file
View File

@ -0,0 +1,13 @@
export default class Comment
{
constructor(id, content, article_id) {
this.id = id;
this.content = content;
this.article_id = article_id;
}
static createFrom(data) {
const {id, content, article_id} = data;
return new this(id, content, article_id);
}
}

11
src/pages/404.vue Normal file
View File

@ -0,0 +1,11 @@
<template>
<div>
Ничего не найдено
</div>
</template>
<script>
export default {
name: 'Article',
}
</script>

88
src/pages/Article.vue Normal file
View File

@ -0,0 +1,88 @@
<template>
<b-row v-if="article">
<b-col md="8" lg="9">
<h1>
{{ article.title }}
</h1>
<p class="mb-4">
{{ article.content }}
</p>
<table class="table table-bordered">
<tbody>
<tr>
<td class="w-50">
<router-link v-if="prevArticle" :to="getArticleRoute(prevArticle)">
{{ prevArticle.title }}
</router-link>
</td>
<td class="text-right">
<router-link v-if="nextArticle" :to="getArticleRoute(nextArticle)">
{{ nextArticle.title }}
</router-link>
</td>
</tr>
</tbody>
</table>
<CommentForm :articleId="article.id"></CommentForm>
<CommentItem v-for="(item, index) in article.comments" :key="index" :item="item"></CommentItem>
</b-col>
<b-col md="4" lg="3">
<CommentItem v-for="(item, index) in lastComments" :key="index" :item="item"></CommentItem>
</b-col>
</b-row>
</template>
<script>
import CommentForm from '@/components/CommentForm.vue';
import CommentItem from '@/components/CommentItem.vue';
import {
mapActions,
mapGetters,
} from 'vuex'
export default {
name: 'Article',
components: {
CommentForm,
CommentItem,
},
computed: {
...mapGetters([
'lastComments',
'nextArticle',
'prevArticle',
]),
articleId() {
return this.$route.params['post_id'] || null;
},
article() {
return this.$store.state.blog.activeArticle;
},
},
methods: {
...mapActions([
'loadComments',
'loadActiveArticle',
]),
getArticleRoute(item) {
return {
name: 'Article',
params: {
post_id: item.id,
},
};
},
},
mounted() {
this.loadComments();
this.loadActiveArticle(this.articleId);
},
watch: {
articleId(value) {
this.loadActiveArticle(value);
},
},
}
</script>

46
src/pages/Category.vue Normal file
View File

@ -0,0 +1,46 @@
<template>
<div>
<div v-if="category">
<h1>
{{ category.title }}
</h1>
<ArticleItems :items="articles" :cols="2"></ArticleItems>
</div>
<div v-else>
Категория не найдена
</div>
</div>
</template>
<script>
import ArticleItems from '@/components/ArticleItems.vue'
import {
mapActions,
} from 'vuex'
export default {
name: 'Category',
components: {
ArticleItems,
},
computed: {
categoryId() {
return this.$route.params['category_id'] || null;
},
category() {
return this.$store.state.blog.activeCategory;
},
articles() {
return this.$store.getters.activeCategoryArticles;
},
},
methods: {
...mapActions([
'loadActiveCategory',
]),
},
mounted() {
this.loadActiveCategory(this.categoryId);
},
}
</script>

61
src/pages/Index.vue Normal file
View File

@ -0,0 +1,61 @@
<template>
<b-row>
<b-col md="8" lg="9">
<ArticleItems :items="lastArticles"></ArticleItems>
</b-col>
<b-col md="4" lg="3">
<ListItems :items="popularCategories" v-slot="props">
<router-link :to="getCategoryRoute(props.item)">
{{ props.item.title }}
</router-link>
</ListItems>
<CommentItem v-for="(item, index) in lastComments" :key="index" :item="item"></CommentItem>
</b-col>
</b-row>
</template>
<script>
import ListItems from '@/components/ListItems.vue'
import ArticleItems from '@/components/ArticleItems.vue'
import CommentItem from '@/components/CommentItem.vue'
import {
mapGetters,
} from 'vuex'
export default {
name: 'Index',
components: {
ListItems,
CommentItem,
ArticleItems,
},
data() {
return {};
},
methods: {
getCategoryRoute(item) {
return {
name: 'Category',
params: {
category_id: item.id,
},
};
},
},
computed: {
...mapGetters([
'lastArticles',
'lastComments',
'popularCategories',
]),
},
created() {
/**
* Запросы делаем к действиям (а не мутациям)
*/
this.$store.dispatch('loadArticles');
this.$store.dispatch('loadComments');
this.$store.dispatch('loadCategories');
},
}
</script>

12
src/router/blog/index.js Normal file
View File

@ -0,0 +1,12 @@
export default [
{
path: '/cat-:category_id',
name: 'Category',
component: () => import('@/pages/Category.vue'),
},
{
path: '/post-:post_id',
name: 'Article',
component: () => import('@/pages/Article.vue'),
},
];

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

@ -0,0 +1,24 @@
import VueRouter from 'vue-router'
import blog from './blog'
export default new VueRouter({
mode: 'history',
routes: [
{
path: '/',
name: 'Index',
component: () => import('@/pages/Index.vue'),
},
/**
* Когда проект большой, роуты лучше выносить в отдельные файлы
* Распределенные по модулям приложения
* В данном случае это излишне и это просто демонстрация
*/
...blog,
{
path: '*',
name: '404',
component: () => import('@/pages/404.vue'),
},
]
})

25
src/services/Display.js Normal file
View File

@ -0,0 +1,25 @@
export default {
/**
* Получить координаты элемента в документе
* @param {DOMElement} elem
*/
getCoords(elem) {
const box = elem.getBoundingClientRect();
const body = document.body;
const docEl = document.documentElement;
const scrollTop = window.pageYOffset || docEl.scrollTop || body.scrollTop;
const scrollLeft = window.pageXOffset || docEl.scrollLeft || body.scrollLeft;
const clientTop = docEl.clientTop || body.clientTop || 0;
const clientLeft = docEl.clientLeft || body.clientLeft || 0;
const top = box.top + scrollTop - clientTop;
const left = box.left + scrollLeft - clientLeft;
return {
top,
left,
};
}
};

11
src/store/index.js Normal file
View File

@ -0,0 +1,11 @@
import Vue from 'vue'
import Vuex from 'vuex'
import blog from './modules/blog'
Vue.use(Vuex)
export default new Vuex.Store({
modules: {
blog,
},
})

132
src/store/modules/blog.js Normal file
View File

@ -0,0 +1,132 @@
import Api from '@/api';
import Comment from '@/models/Comment';
export default {
state: {
articles: [],
comments: [],
categories: [],
//
activeArticle: null,
activeCategory: null,
},
getters: {
lastArticles(state) {
return state.articles.slice(0, 10);
},
lastComments(state) {
return state.comments.slice(0, 10);
},
popularCategories(state) {
return state.categories.slice(0, 10);
},
activeCategoryArticles(state) {
if (!state.activeCategory) {
return [];
}
return state.articles.filter((item) => state.activeCategory.articles.indexOf(item.id) >= 0);
},
activeArticleComments(state) {
if (!state.activeArticle) {
return [];
}
return state.comments.filter((item) => state.activeArticle.id == item.article_id);
},
prevArticle(state) {
let prevItem = null;
if (state.activeArticle) {
state.articles.forEach((item, index) => {
if (item.id == state.activeArticle.id) {
prevItem = state.articles[index-1] || null;
}
});
}
return prevItem;
},
nextArticle(state) {
let nextItem = null;
if (state.activeArticle) {
state.articles.forEach((item, index) => {
if (item.id == state.activeArticle.id) {
nextItem = state.articles[index+1] || null;
}
});
}
return nextItem;
},
},
mutations: {
setArticles(state, payload) {
state.articles = payload.items;
},
setComments(state, payload) {
state.comments = payload.items;
},
setCategories(state, payload) {
state.categories = payload.items;
},
setActiveCategory(state, payload) {
state.activeCategory = payload;
},
setActiveArticle(state, payload) {
state.activeArticle = payload;
},
addComment(state, payload) {
state.comments.push(payload);
state.activeArticle.addComment(Comment.createFrom(payload));
},
},
actions: {
async loadArticles({ commit, state }) {
if (state.articles.length > 0) {
return;
}
const items = await Api.getArticles();
commit('setArticles', {
items
});
},
async loadComments({ commit, state }) {
if (state.comments.length > 0) {
return;
}
const items = await Api.getComments();
commit('setComments', {
items
});
},
async loadCategories({ commit, state }) {
if (state.categories.length > 0) {
return;
}
const items = await Api.getCategories();
commit('setCategories', {
items
});
},
async loadActiveCategory(context, id) {
await context.dispatch('loadArticles');
await context.dispatch('loadCategories');
let category = context.state.categories.find((item) => {
return item.id == id;
});
context.commit('setActiveCategory', category);
},
async loadActiveArticle(context, id) {
await context.dispatch('loadArticles');
let model = context.state.articles.find((item) => {
return item.id == id;
});
context.commit('setActiveArticle', model);
},
async addComment({ commit }, payload) {
await Api.addComment(payload);
commit('addComment', payload);
},
},
}

2
src/styles/index.scss Normal file
View File

@ -0,0 +1,2 @@
@import 'node_modules/bootstrap/scss/bootstrap';
@import 'node_modules/bootstrap-vue/src/index.scss';