Setup admin pages for editing and creating blog articles
This commit is contained in:
parent
16786bac64
commit
f7e4a5051e
@ -1,20 +1,72 @@
|
|||||||
<template>
|
<template>
|
||||||
<textarea v-model="markdown" cols="30" rows="10"></textarea>
|
<div class="markdown-editor">
|
||||||
<div ref="target" v-html="html"></div>
|
<div class="md-editor">
|
||||||
|
<div class="controls">
|
||||||
|
<ul ref="controlsList">
|
||||||
|
<li><a href="javascript:void(0)" @click="insertTag('**', '**')"><i class="fas fa-bold"></i></a></li>
|
||||||
|
<li><a href="javascript:void(0)" @click="insertTag('*', '*')"><i class="fas fa-italic"></i></a></li>
|
||||||
|
<li><a href="javascript:void(0)" @click="insertTag('<u>', '</u>')"><i class="fas fa-underline"></i></a></li>
|
||||||
|
<li><a href="javascript:void(0)" @click="insertTag('<s>', '</s>')"><i class="fas fa-strikethrough"></i></a></li>
|
||||||
|
<li><a href="javascript:void(0)" @click="insertTag('- ', null, true)"><i class="fas fa-list-ul"></i></a></li>
|
||||||
|
<li><a href="javascript:void(0)" @click="insertTag('1. ', null, true)"><i class="fas fa-list-ol"></i></a></li>
|
||||||
|
<li><a href="javascript:void(0)" @click="imagePopupOpen = !imagePopupOpen"><i class="fas fa-image"></i></a></li>
|
||||||
|
<li><a href="javascript:void(0)" @click="linkPopupOpen = !linkPopupOpen"><i class="fas fa-link"></i></a></li>
|
||||||
|
</ul>
|
||||||
|
</div>
|
||||||
|
<textarea class="input" ref="textarea" v-model="markdown" :cols="cols" :rows="rows"></textarea>
|
||||||
|
</div>
|
||||||
|
<button class="submit" @click="save">Save</button>
|
||||||
|
<div class="render" v-html="html" v-if="render"></div>
|
||||||
|
|
||||||
|
<div class="popup" :style="{ top: popupTop + 'px', left: popupLeft + 'px' }" v-if="imagePopupOpen">
|
||||||
|
Insert Image
|
||||||
|
<form @submit.prevent="onImageInsert">
|
||||||
|
<input type="text" name="url">
|
||||||
|
or
|
||||||
|
<input type="file" name="image" accept="image/*">
|
||||||
|
<button class="submit">Insert</button>
|
||||||
|
</form>
|
||||||
|
</div>
|
||||||
|
<div class="popup" :style="{ top: popupTop + 'px', left: popupLeft + 'px' }" v-else-if="linkPopupOpen">
|
||||||
|
Insert Link
|
||||||
|
<form @submit.prevent="onLinkInsert">
|
||||||
|
<input type="text" name="url">
|
||||||
|
<button class="submit">Insert</button>
|
||||||
|
</form>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
</template>
|
</template>
|
||||||
|
|
||||||
<script>
|
<script>
|
||||||
import { debounce } from "../helpers";
|
import { debounce } from "../helpers";
|
||||||
|
import { nextTick } from "vue";
|
||||||
|
|
||||||
export default {
|
export default {
|
||||||
data() {
|
data() {
|
||||||
return {
|
return {
|
||||||
|
edited: false,
|
||||||
markdown: "",
|
markdown: "",
|
||||||
html: null,
|
html: null,
|
||||||
converter: null,
|
converter: null,
|
||||||
onChange: () => {}
|
onChange: () => {},
|
||||||
|
imagePopupOpen: false,
|
||||||
|
linkPopupOpen: false,
|
||||||
};
|
};
|
||||||
},
|
},
|
||||||
|
props: {
|
||||||
|
render: {
|
||||||
|
type: Boolean,
|
||||||
|
default: true,
|
||||||
|
},
|
||||||
|
cols: {
|
||||||
|
type: Number,
|
||||||
|
default: 30,
|
||||||
|
},
|
||||||
|
rows: {
|
||||||
|
type: Number,
|
||||||
|
default: 10,
|
||||||
|
},
|
||||||
|
},
|
||||||
mounted() {
|
mounted() {
|
||||||
this.converter = new showdown.Converter();
|
this.converter = new showdown.Converter();
|
||||||
this.onChange = debounce(() => {
|
this.onChange = debounce(() => {
|
||||||
@ -24,12 +76,118 @@ export default {
|
|||||||
methods: {
|
methods: {
|
||||||
onChangeHandler() {
|
onChangeHandler() {
|
||||||
this.html = this.converter.makeHtml(this.markdown);
|
this.html = this.converter.makeHtml(this.markdown);
|
||||||
|
},
|
||||||
|
save() {
|
||||||
|
this.$emit('save', this.markdown);
|
||||||
|
this.edited = false;
|
||||||
|
},
|
||||||
|
canRouteChange() {
|
||||||
|
if (!this.edited) {
|
||||||
|
return true;
|
||||||
|
}
|
||||||
|
|
||||||
|
return window.confirm('Do you really want to leave? You have unsaved changes!');
|
||||||
|
},
|
||||||
|
setContent(markdown, blockSave = true) {
|
||||||
|
this.markdown = markdown;
|
||||||
|
nextTick(() => {
|
||||||
|
this.edited = blockSave;
|
||||||
|
});
|
||||||
|
},
|
||||||
|
insertTag(tagFront, tagBack, startOfLine = false) {
|
||||||
|
const textarea = this.$refs.textarea;
|
||||||
|
const start = textarea.selectionStart;
|
||||||
|
const end = textarea.selectionEnd;
|
||||||
|
|
||||||
|
textarea.focus();
|
||||||
|
|
||||||
|
if (!startOfLine) {
|
||||||
|
textarea.selectionStart = end;
|
||||||
|
document.execCommand('insertText', false, tagBack);
|
||||||
|
|
||||||
|
textarea.selectionStart = start;
|
||||||
|
textarea.selectionEnd = start;
|
||||||
|
document.execCommand('insertText', false, tagFront);
|
||||||
|
|
||||||
|
textarea.selectionStart = start + tagFront.length;
|
||||||
|
textarea.selectionEnd = end + tagFront.length;
|
||||||
|
} else {
|
||||||
|
const contentBeforeSelection = this.markdown.slice(0, start);
|
||||||
|
const contentSelection = this.markdown.slice(start, end - 1);
|
||||||
|
|
||||||
|
let startLinePos = contentBeforeSelection.lastIndexOf('\n');
|
||||||
|
|
||||||
|
if (startLinePos < 0) {
|
||||||
|
startLinePos = -1;
|
||||||
|
}
|
||||||
|
|
||||||
|
let endLinePos = contentSelection.lastIndexOf('\n');
|
||||||
|
|
||||||
|
if (endLinePos < 0) {
|
||||||
|
endLinePos = 0;
|
||||||
|
} else {
|
||||||
|
endLinePos += contentBeforeSelection.length;
|
||||||
|
}
|
||||||
|
|
||||||
|
const allNewlines = [startLinePos];
|
||||||
|
let i = startLinePos;
|
||||||
|
while ((i = this.markdown.indexOf('\n', i+1)) >= 0) {
|
||||||
|
if (i <= endLinePos) {
|
||||||
|
allNewlines.push(i);
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
for (let i = 0; i < allNewlines.length; i++) {
|
||||||
|
textarea.selectionStart = textarea.selectionEnd = allNewlines[i] + tagFront.length * i + 1;
|
||||||
|
document.execCommand('insertText', false, tagFront);
|
||||||
|
}
|
||||||
|
}
|
||||||
|
},
|
||||||
|
onImageInsert(event) {
|
||||||
|
const data = new FormData(event.target);
|
||||||
|
const url = data.get('url');
|
||||||
|
const image = data.get('image');
|
||||||
|
|
||||||
|
if (image.size > 0) {
|
||||||
|
const fr = new FileReader();
|
||||||
|
fr.addEventListener('load', event => {
|
||||||
|
this.insertTag("`);
|
||||||
|
});
|
||||||
|
fr.readAsDataURL(image);
|
||||||
|
} else {
|
||||||
|
if (url.length > 0) {
|
||||||
|
this.insertTag("`);
|
||||||
|
}
|
||||||
|
}
|
||||||
|
},
|
||||||
|
onLinkInsert(event) {
|
||||||
|
const data = new FormData(event.target);
|
||||||
|
const url = data.get('url');
|
||||||
|
|
||||||
|
if (url.length > 0) {
|
||||||
|
this.insertTag("[", `](${url})`);
|
||||||
|
}
|
||||||
}
|
}
|
||||||
},
|
},
|
||||||
watch: {
|
watch: {
|
||||||
markdown() {
|
markdown() {
|
||||||
|
this.edited = true;
|
||||||
|
this.$emit('update:markdown', this.markdown);
|
||||||
this.onChange();
|
this.onChange();
|
||||||
|
},
|
||||||
|
html() {
|
||||||
|
this.$emit('update:html', this.html);
|
||||||
}
|
}
|
||||||
|
},
|
||||||
|
computed: {
|
||||||
|
popupTop() {
|
||||||
|
const rect = this.$refs.controlsList.getBoundingClientRect();
|
||||||
|
return rect.bottom;
|
||||||
|
},
|
||||||
|
popupLeft() {
|
||||||
|
const rect = this.$refs.controlsList.getBoundingClientRect();
|
||||||
|
return rect.left;
|
||||||
|
},
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
</script>
|
</script>
|
||||||
|
|||||||
7
resources/js/router-app-admin.js
vendored
7
resources/js/router-app-admin.js
vendored
@ -1,12 +1,19 @@
|
|||||||
const VueRouter = require('vue-router');
|
const VueRouter = require('vue-router');
|
||||||
|
|
||||||
import Home from './views/AdminHome.vue';
|
import Home from './views/AdminHome.vue';
|
||||||
|
import Blog from "./views/Blog.vue";
|
||||||
|
import BlogArticle from "./views/BlogArticle.vue";
|
||||||
|
import BlogArticleEdit from "./views/BlogArticleEdit.vue";
|
||||||
|
|
||||||
import NotFound from './views/NotFound.vue';
|
import NotFound from './views/NotFound.vue';
|
||||||
|
|
||||||
// TODO: Change base url to be `/admin`
|
// TODO: Change base url to be `/admin`
|
||||||
const routes = [
|
const routes = [
|
||||||
{ path: '/admin/', name: 'index', component: Home },
|
{ path: '/admin/', name: 'index', component: Home },
|
||||||
|
{ path: '/admin/blog', name: 'blog', component: Blog },
|
||||||
|
{ path: '/admin/blog/:id', name: 'blog-article', component: BlogArticle, props: route => ({ id: route.params.id, admin: true }) },
|
||||||
|
{ path: '/admin/blog/:id/edit', name: 'blog-article-edit', component: BlogArticleEdit, props: true },
|
||||||
|
{ path: '/admin/blog/create', name: 'blog-article-create', component: BlogArticleEdit },
|
||||||
{ path: '/:pathMatch(.*)*', name: 'NotFound', component: NotFound },
|
{ path: '/:pathMatch(.*)*', name: 'NotFound', component: NotFound },
|
||||||
];
|
];
|
||||||
|
|
||||||
|
|||||||
@ -1,15 +1,69 @@
|
|||||||
<template>
|
<template>
|
||||||
{{ id }} - {{ slug }}
|
<container>
|
||||||
|
<div class="blog-links">
|
||||||
|
<a href="javascript:void(0)" @click="$router.push({ name: 'blog' })"><i class="fas fa-backward"></i> Back</a>
|
||||||
|
<router-link :to="{ name: 'blog-article-edit', params: { id } }" v-if="admin"><i class="fas fa-edit"></i> Edit</router-link>
|
||||||
|
</div>
|
||||||
|
<div class="article" v-if="!loading && !error">
|
||||||
|
<h1>{{ title }}</h1>
|
||||||
|
<div class="content" v-html="content"></div>
|
||||||
|
</div>
|
||||||
|
<div v-else-if="!!error && errorCode === 404">
|
||||||
|
<p>Not found</p>
|
||||||
|
</div>
|
||||||
|
<div v-else-if="!!error">
|
||||||
|
<p>{{ error }}</p>
|
||||||
|
</div>
|
||||||
|
</container>
|
||||||
</template>
|
</template>
|
||||||
|
|
||||||
<script>
|
<script>
|
||||||
|
import Container from "../components/Container.vue";
|
||||||
|
import NotFound from "./NotFound";
|
||||||
|
|
||||||
export default {
|
export default {
|
||||||
|
components: {
|
||||||
|
NotFound,
|
||||||
|
Container,
|
||||||
|
},
|
||||||
|
data() {
|
||||||
|
return {
|
||||||
|
loading: false,
|
||||||
|
errorCode: null,
|
||||||
|
error: null,
|
||||||
|
title: null,
|
||||||
|
date: null,
|
||||||
|
summary: null,
|
||||||
|
content: null,
|
||||||
|
};
|
||||||
|
},
|
||||||
props: {
|
props: {
|
||||||
'year': String|Number,
|
id: String|Number,
|
||||||
'month': String|Number,
|
slug: String,
|
||||||
'day': String|Number,
|
admin: false,
|
||||||
'id': String|Number,
|
},
|
||||||
'slug': String,
|
mounted() {
|
||||||
|
console.error("asdf");
|
||||||
|
axios.get(`/api/blog/${this.id}`)
|
||||||
|
.then(res => {
|
||||||
|
if (res.status > 399) {
|
||||||
|
this.errorCode = res.status;
|
||||||
|
this.error = res.statusText;
|
||||||
|
return;
|
||||||
|
}
|
||||||
|
|
||||||
|
this.title = res.data.title;
|
||||||
|
this.date = moment(res.data.date);
|
||||||
|
this.summary = res.data.summary;
|
||||||
|
|
||||||
|
const converter = new showdown.Converter();
|
||||||
|
this.content = converter.makeHtml(res.data.content);
|
||||||
|
|
||||||
|
this.loading = false;
|
||||||
|
}).catch(error => {
|
||||||
|
this.errorCode = error.response.status;
|
||||||
|
this.error = error.message;
|
||||||
|
});
|
||||||
},
|
},
|
||||||
}
|
}
|
||||||
</script>
|
</script>
|
||||||
|
|||||||
84
resources/js/views/BlogArticleEdit.vue
Normal file
84
resources/js/views/BlogArticleEdit.vue
Normal file
@ -0,0 +1,84 @@
|
|||||||
|
<template>
|
||||||
|
<div class="blog-edit">
|
||||||
|
<container>
|
||||||
|
<a href="javascript:void(0)" @click="gotoArticle" v-if="!!this.id"><i class="fas fa-eye"></i> View Article</a>
|
||||||
|
<div class="article-form">
|
||||||
|
<input type="text" v-model="title">
|
||||||
|
<markdown-editor ref="editor" :render="false" @update:html="html = $event" @save="save" :rows="20"></markdown-editor>
|
||||||
|
</div>
|
||||||
|
<div class="article-render">
|
||||||
|
<h1>{{ this.title }}</h1>
|
||||||
|
<div v-html="html"></div>
|
||||||
|
</div>
|
||||||
|
</container>
|
||||||
|
</div>
|
||||||
|
</template>
|
||||||
|
|
||||||
|
<script>
|
||||||
|
import Container from "../components/Container.vue";
|
||||||
|
import MarkdownEditor from "../components/MarkdownEditor";
|
||||||
|
|
||||||
|
export default {
|
||||||
|
components: {
|
||||||
|
Container,
|
||||||
|
MarkdownEditor,
|
||||||
|
},
|
||||||
|
data() {
|
||||||
|
return {
|
||||||
|
html: "",
|
||||||
|
title: "",
|
||||||
|
errors: null,
|
||||||
|
};
|
||||||
|
},
|
||||||
|
props: {
|
||||||
|
id: undefined,
|
||||||
|
},
|
||||||
|
mounted() {
|
||||||
|
if (!!this.id) {
|
||||||
|
axios.get(`/api/blog/${this.id}`)
|
||||||
|
.then(res => {
|
||||||
|
this.$refs.editor.setContent(res.data.content, false);
|
||||||
|
this.title = res.data.title;
|
||||||
|
});
|
||||||
|
}
|
||||||
|
},
|
||||||
|
methods: {
|
||||||
|
save(markdown) {
|
||||||
|
console.log(markdown);
|
||||||
|
if (!!this.id) {
|
||||||
|
// Update existing post
|
||||||
|
axios.put(`/api/blog/${this.id}`, {
|
||||||
|
title: this.title,
|
||||||
|
content: markdown,
|
||||||
|
})
|
||||||
|
// It returns a 204 - No content if successful, so no need for a `.then()`
|
||||||
|
.catch(error => {
|
||||||
|
this.errors = error.errors;
|
||||||
|
});
|
||||||
|
} else {
|
||||||
|
// Store new post
|
||||||
|
axios.post('/api/blog', {
|
||||||
|
title: this.title,
|
||||||
|
content: markdown,
|
||||||
|
})
|
||||||
|
.then(res => {
|
||||||
|
this.$router.push({ name: 'blog-article-edit', params: { id: res.data.id }});
|
||||||
|
})
|
||||||
|
.catch(error => {
|
||||||
|
this.errors = error.errors;
|
||||||
|
});
|
||||||
|
}
|
||||||
|
},
|
||||||
|
gotoArticle() {
|
||||||
|
this.$router.push({ name: 'blog-article', params: { id: this.id }});
|
||||||
|
},
|
||||||
|
},
|
||||||
|
beforeRouteLeave(to, from, next) {
|
||||||
|
if (this.$refs.editor.canRouteChange()) {
|
||||||
|
next();
|
||||||
|
} else {
|
||||||
|
next(false);
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
|
</script>
|
||||||
6
resources/scss/_blog.scss
vendored
6
resources/scss/_blog.scss
vendored
@ -120,3 +120,9 @@ section.blog-popular {
|
|||||||
}
|
}
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
|
.blog-links {
|
||||||
|
&> :not(:first-child) {
|
||||||
|
margin-left: 1rem;
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|||||||
74
resources/scss/_blogedit.scss
vendored
Normal file
74
resources/scss/_blogedit.scss
vendored
Normal file
@ -0,0 +1,74 @@
|
|||||||
|
.blog-edit {
|
||||||
|
.article-form {
|
||||||
|
input,
|
||||||
|
textarea {
|
||||||
|
padding: .1rem;
|
||||||
|
width: calc(100% - .2rem);
|
||||||
|
margin: 0;
|
||||||
|
}
|
||||||
|
|
||||||
|
.controls {
|
||||||
|
width: 100%;
|
||||||
|
}
|
||||||
|
|
||||||
|
input {
|
||||||
|
border-radius: 5px;
|
||||||
|
}
|
||||||
|
|
||||||
|
.md-editor {
|
||||||
|
margin-top: .5rem;
|
||||||
|
|
||||||
|
:first-child {
|
||||||
|
border-top-left-radius: 5px;
|
||||||
|
border-top-right-radius: 5px;
|
||||||
|
}
|
||||||
|
|
||||||
|
:last-child {
|
||||||
|
border-bottom-left-radius: 5px;
|
||||||
|
border-bottom-right-radius: 5px;
|
||||||
|
}
|
||||||
|
|
||||||
|
&> * {
|
||||||
|
border-width: 3px;
|
||||||
|
}
|
||||||
|
|
||||||
|
.controls {
|
||||||
|
background-color: $background-lighter;
|
||||||
|
border-color: $background-darker;
|
||||||
|
border-bottom-width: 0;
|
||||||
|
border-style: solid;
|
||||||
|
|
||||||
|
ul {
|
||||||
|
display: flex;
|
||||||
|
list-style: none;
|
||||||
|
padding: .25rem;
|
||||||
|
margin: 0;
|
||||||
|
|
||||||
|
li {
|
||||||
|
a {
|
||||||
|
display: inline-block;
|
||||||
|
width: 32px;
|
||||||
|
height: 32px;
|
||||||
|
|
||||||
|
i {
|
||||||
|
width: 100%;
|
||||||
|
max-height: 100%;
|
||||||
|
font-size: 24px;
|
||||||
|
text-align: center;
|
||||||
|
vertical-align: baseline;
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
input,
|
||||||
|
textarea,
|
||||||
|
button {
|
||||||
|
background-color: $background-lighter;
|
||||||
|
border-color: $background-darker;
|
||||||
|
color: $text-color;
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
24
resources/scss/_markdown.scss
vendored
Normal file
24
resources/scss/_markdown.scss
vendored
Normal file
@ -0,0 +1,24 @@
|
|||||||
|
.markdown-editor {
|
||||||
|
width: 100%;
|
||||||
|
|
||||||
|
.input {
|
||||||
|
resize: vertical;
|
||||||
|
}
|
||||||
|
|
||||||
|
.submit {
|
||||||
|
padding: .5rem;
|
||||||
|
border-radius: 5px;
|
||||||
|
}
|
||||||
|
|
||||||
|
.popup {
|
||||||
|
position: absolute;
|
||||||
|
background-color: $background-lighter;
|
||||||
|
border: 2px solid $background-darker;
|
||||||
|
color: $text-color;
|
||||||
|
padding: 1rem;
|
||||||
|
}
|
||||||
|
|
||||||
|
.render {
|
||||||
|
width: 100%;
|
||||||
|
}
|
||||||
|
}
|
||||||
2
resources/scss/app.scss
vendored
2
resources/scss/app.scss
vendored
@ -17,3 +17,5 @@
|
|||||||
@import "footer";
|
@import "footer";
|
||||||
@import "home";
|
@import "home";
|
||||||
@import "pagination";
|
@import "pagination";
|
||||||
|
@import "markdown";
|
||||||
|
@import "blogedit";
|
||||||
|
|||||||
Loading…
Reference in New Issue
Block a user