Setup admin pages for editing and creating blog articles
This commit is contained in:
parent
16786bac64
commit
f7e4a5051e
@ -1,20 +1,72 @@
|
||||
<template>
|
||||
<textarea v-model="markdown" cols="30" rows="10"></textarea>
|
||||
<div ref="target" v-html="html"></div>
|
||||
<div class="markdown-editor">
|
||||
<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>
|
||||
|
||||
<script>
|
||||
import { debounce } from "../helpers";
|
||||
import { nextTick } from "vue";
|
||||
|
||||
export default {
|
||||
data() {
|
||||
return {
|
||||
edited: false,
|
||||
markdown: "",
|
||||
html: 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() {
|
||||
this.converter = new showdown.Converter();
|
||||
this.onChange = debounce(() => {
|
||||
@ -24,12 +76,118 @@ export default {
|
||||
methods: {
|
||||
onChangeHandler() {
|
||||
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: {
|
||||
markdown() {
|
||||
this.edited = true;
|
||||
this.$emit('update:markdown', this.markdown);
|
||||
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>
|
||||
|
||||
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');
|
||||
|
||||
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';
|
||||
|
||||
// TODO: Change base url to be `/admin`
|
||||
const routes = [
|
||||
{ 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 },
|
||||
];
|
||||
|
||||
|
||||
@ -1,15 +1,69 @@
|
||||
<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>
|
||||
|
||||
<script>
|
||||
import Container from "../components/Container.vue";
|
||||
import NotFound from "./NotFound";
|
||||
|
||||
export default {
|
||||
components: {
|
||||
NotFound,
|
||||
Container,
|
||||
},
|
||||
data() {
|
||||
return {
|
||||
loading: false,
|
||||
errorCode: null,
|
||||
error: null,
|
||||
title: null,
|
||||
date: null,
|
||||
summary: null,
|
||||
content: null,
|
||||
};
|
||||
},
|
||||
props: {
|
||||
'year': String|Number,
|
||||
'month': String|Number,
|
||||
'day': String|Number,
|
||||
'id': String|Number,
|
||||
'slug': String,
|
||||
id: String|Number,
|
||||
slug: String,
|
||||
admin: false,
|
||||
},
|
||||
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>
|
||||
|
||||
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 "home";
|
||||
@import "pagination";
|
||||
@import "markdown";
|
||||
@import "blogedit";
|
||||
|
||||
Loading…
Reference in New Issue
Block a user