Setup admin pages for editing and creating blog articles

This commit is contained in:
Daniel_I_Am 2021-09-03 21:51:13 +02:00
parent 16786bac64
commit f7e4a5051e
8 changed files with 418 additions and 9 deletions

View File

@ -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("![", `](${event.target.result})`);
});
fr.readAsDataURL(image);
} else {
if (url.length > 0) {
this.insertTag("![", `](${url})`);
}
}
},
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>

View File

@ -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 },
];

View File

@ -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>

View 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>

View File

@ -120,3 +120,9 @@ section.blog-popular {
}
}
}
.blog-links {
&> :not(:first-child) {
margin-left: 1rem;
}
}

74
resources/scss/_blogedit.scss vendored Normal file
View 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
View 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%;
}
}

View File

@ -17,3 +17,5 @@
@import "footer";
@import "home";
@import "pagination";
@import "markdown";
@import "blogedit";