Add (new post)

This commit is contained in:
2023-07-29 02:20:51 +08:00
parent cb371fae26
commit 58b939f72e
26 changed files with 3754 additions and 62 deletions

View File

@@ -1,4 +1,57 @@
import "@tabler/core/src/js/tabler.js";
import '@tabler/core/src/js/tabler.js';
import "./bootstrap";
import './bootstrap';
import { createApp, defineAsyncComponent } from "vue";
import AdminApp from "@/vue/AdminApp.vue";
const app = createApp({ AdminApp });
const vueComponents = import.meta.glob("@/vue/**/*.vue", {
eager: false,
});
console.log(vueComponents);
import.meta.glob(["../images/**", "../fonts/**"]);
import { createPinia } from "pinia";
app.use(createPinia());
import axios from "./plugins/axios";
import VueAxios from "vue-axios";
app.use(VueAxios, axios);
import auth from "./plugins/auth";
app.use(auth);
import eventBus from "./plugins/event-bus";
app.use(eventBus);
import Vue3Toastify from "vue3-toastify";
import "../css/toastify.css";
app.use(Vue3Toastify);
import { Ziggy as ZiggyRoute } from "./ziggy";
import { ZiggyVue } from "ziggy-js/dist/vue";
app.use(ZiggyVue, ZiggyRoute);
window.Ziggy = ZiggyRoute;
Object.entries({ ...vueComponents }).forEach(([path, definition]) => {
// Get name of component, based on filename
// "./components/Fruits.vue" will become "Fruits"
const componentName = path
.split("/")
.pop()
.replace(/\.\w+$/, "")
.replace(/([a-z])([A-Z])/g, "$1-$2")
.toLowerCase();
// console.log(componentName);
// console.log(typeof definition);
// Register component on this Vue instance
app.component(componentName, defineAsyncComponent(definition));
});
app.mount("#app");

View File

@@ -0,0 +1,11 @@
import { useAuthStore } from "@/stores/useAuth";
export default {
install: ({ config }) => {
config.globalProperties.$auth = useAuthStore();
if (useAuthStore().loggedIn) {
useAuthStore().ftechUser();
}
},
};

View File

@@ -0,0 +1,71 @@
import { useErrorStore } from "../stores/useError";
import axios from "axios";
import Cookies from "js-cookie";
axios.defaults.headers.common["Authorization"] = localStorage.getItem("token");
axios.defaults.withCredentials = true;
axios.defaults.headers.common["X-Requested-With"] = "XMLHttpRequest";
const setCSRFToken = () => {
return axios.get("/sanctum/csrf-cookie"); // resolves to '/api/csrf-cookie'.
};
// Add a request interceptor
axios.interceptors.request.use(
function (config) {
// Do something before request is sent
useErrorStore().$reset();
if (!Cookies.get("XSRF-TOKEN")) {
return setCSRFToken().then((response) => config);
}
return config;
},
function (error) {
// Do something with request error
return Promise.reject(error);
}
);
// Add a response interceptor
axios.interceptors.response.use(
function (response) {
// console.warn("axios.interceptors.response");
// console.warn(response);
if (response?.data?.data?.csrf_token?.length > 0) {
Cookies.set("XSRF-TOKEN", response.data.data.csrf_token);
} else if (response?.data?.data?.token?.length > 0) {
Cookies.set("XSRF-TOKEN", response.data.data.csrf_token);
}
// Any status code that lie within the range of 2xx cause this function to trigger
// Do something with response data
return response;
},
function (error) {
// Any status codes that falls outside the range of 2xx cause this function to trigger
// Do something with response error
switch (error.response.status) {
case 401:
localStorage.removeItem("token");
window.location.reload();
break;
case 403:
case 404:
console.error("404");
break;
case 422:
useErrorStore().$state = error.response.data;
break;
default:
console.log(error.response.data);
}
return Promise.reject(error);
}
);
export default axios;

View File

@@ -0,0 +1,7 @@
import mitt from "mitt";
export default {
install: (app, options) => {
app.config.globalProperties.$eventBus = mitt();
},
};

View File

@@ -0,0 +1,53 @@
import { defineStore } from "pinia";
import route from "ziggy-js/src/js/index";
import axios from "axios";
export const usePostStore = defineStore("postStore", {
state: () => ({
data: {
defaultLocaleSlug: "my",
countryLocales: [],
localeCategories: [],
},
}),
getters: {
defaultLocaleSlug(state) {
return state.data.defaultLocaleSlug;
},
countryLocales(state) {
return state.data.countryLocales;
},
localeCategories(state) {
return state.data.localeCategories;
},
},
actions: {
async fetchCountryLocales() {
try {
const response = await axios.get(route("api.admin.country-locales"));
console.log(response);
this.data.countryLocales = response.data.country_locales;
this.data.defaultLocaleSlug = response.data.default_locale_slug;
} catch (error) {
// alert(error);
console.log(error);
}
},
async fetchLocaleCategories(countryLocaleSlug) {
try {
const response = await axios.get(
route("api.admin.categories", {
country_locale_slug: countryLocaleSlug,
})
);
console.log(response);
this.data.localeCategories = response.data.categories;
} catch (error) {
// alert(error);
console.log(error);
}
},
},
});

View File

@@ -0,0 +1,44 @@
import { defineStore } from "pinia";
import axios from "axios";
export const useAuthStore = defineStore("auth", {
state: () => ({
loggedIn: localStorage.getItem("token") ? true : false,
user: null,
}),
getters: {},
actions: {
async login(credentials) {
await axios.get("sanctum/csrf-cookie");
const response = (await axios.post("api/login", credentials)).data;
if (response) {
const token = `Bearer ${response.token}`;
localStorage.setItem("token", token);
axios.defaults.headers.common["Authorization"] = token;
await this.ftechUser();
}
},
async logout() {
const response = (await axios.post("api/logout")).data;
if (response) {
localStorage.removeItem("token");
this.$reset();
}
},
async ftechUser() {
this.user = (await axios.get("api/me")).data;
this.loggedIn = true;
},
},
});

View File

@@ -0,0 +1,8 @@
import { defineStore } from "pinia";
export const useErrorStore = defineStore("error", {
state: () => ({
message: null,
errors: {},
}),
});

View File

@@ -0,0 +1,9 @@
<template>
<div></div>
</template>
<script>
export default {
name: "App",
};
</script>
<style></style>

View File

@@ -0,0 +1,127 @@
<template>
<div>
<div class="card">
<div class="card-body ratio ratio-21x9 bg-dark overflow-hidden">
<div
class="d-flex justify-content-center text-center rounded-2"
:style="bgStyle"
></div>
<div
class="position-absolute w-100 h-100 d-flex justify-content-center text-center"
>
<div v-if="isUploading" class="align-self-center">
<div class="spinner-border text-light" role="status">
<span class="visually-hidden">Loading...</span>
</div>
</div>
<div v-else class="align-self-center">
<input
type="file"
@change="handleFileChange"
accept="image/*"
ref="fileInput"
style="display: none"
/>
<button class="btn btn-primary" @click="openFileInput">
{{ getButtonName }}
</button>
</div>
</div>
</div>
</div>
</div>
</template>
<script>
import axios from "axios";
export default {
name: "NativeImageBlock",
props: {
apiUrl: {
type: String,
default: "https://productalert.test/api/admin/image/upload",
},
},
data: () => ({
isUploading: false,
imgSrc: null,
placeholderSrc: "https://placekitten.com/g/2100/900",
}),
computed: {
getButtonName() {
if (this.imgSrc != null && this.imgSrc?.length > 0) {
return "Change featured image";
} else {
return "Upload featured image";
}
},
getBlurPx() {
return this.imgSrc ? 0 : 12;
},
bgStyle() {
return {
backgroundImage: `url(${this.getImgSrc})`,
backgroundPosition: "center",
backgroundSize: "cover",
filter: `blur(${this.getBlurPx}px)`,
webkitFilter: `blur(${this.getBlurPx}px)`,
};
},
getImgSrc() {
if (this.imgSrc != null && this.imgSrc?.length > 0) {
return this.imgSrc;
}
return this.placeholderSrc;
},
},
methods: {
openFileInput() {
this.$refs.fileInput.click();
},
handleFileChange(event) {
const file = event.target.files[0];
if (file) {
this.uploadImage(file);
}
},
uploadImage(file) {
this.isUploading = true;
const formData = new FormData();
formData.append("file", file);
axios
.post(this.apiUrl, formData, {
headers: {
"Content-Type": "multipart/form-data",
},
})
.then((response) => {
if (
response.data.success === 1 &&
response.data.file &&
response.data.file.url
) {
this.imgSrc = response.data.file.url;
this.$emit("saved", response.data.file.url);
} else {
console.error("Image upload failed. Invalid response format.");
}
})
.catch((error) => {
console.error("Image upload failed:", error.response); // Log the full response
})
.finally(() => {
this.isUploading = false;
});
},
},
mounted() {
this.isUploading = false;
},
};
</script>
<style scoped>
/* Add your custom styles here */
</style>

View File

@@ -0,0 +1,311 @@
<template>
<div>
<div class="row justify-content-center">
<div class="col-9" style="max-width: 700px">
<div class="mb-3">
<div class="form-floating">
<input
v-model="post.title"
type="text"
class="form-control"
placeholder="Post title"
/>
<label>Write a SEO post title</label>
</div>
<small>
<span class="text-secondary">{{ getPostFullUrl }}</span>
</small>
</div>
<div class="form-floating mb-3">
<textarea
v-model="post.excerpt"
class="form-control"
style="min-height: 150px"
placeholder="Enter a post excerpt/summary"
></textarea>
<label
>Write a simple excerpt to convince & entice users to view this
post!</label
>
</div>
<native-image-block
class="mb-3"
:input-image="post.featured_image"
@saved="imageSaved"
></native-image-block>
<div class="card">
<div class="card-body">
<vue-editor-js
v-on:saved="editorSaved"
:config="config"
:initialized="onInitialized"
/>
</div>
</div>
</div>
<div class="col-3">
<div class="d-grid mb-2">
<select
class="form-select mb-2"
aria-label="Default select example"
v-on:change="statusChanged"
>
<option
v-for="item in status"
v-bind:key="item"
:selected="item == post.status"
:value="item"
>
Post Status: {{ item }}
</option>
</select>
<button @click="checkAndSave" class="btn btn-primary">
Save as {{ post.status }}
</button>
</div>
<div class="card mb-2">
<div class="card-header fw-bold">Country Locality</div>
<div class="card-body">
<select class="form-select" v-on:change="localeChanged">
<option
v-for="item in countryLocales"
v-bind:key="item.id"
:value="item.slug"
:selected="item.slug == post.locale_slug"
>
{{ item.name }}
</option>
</select>
</div>
</div>
<div class="card mb-2">
<div class="card-header fw-bold">Categories</div>
<div class="card-body">
<div
class="py-1"
v-for="item in localeCategories"
v-bind:key="item.id"
>
<label>
<input
type="radio"
:id="item.id"
:value="item.id"
v-model="post.categories"
/>
{{ item.name }}
</label>
</div>
</div>
</div>
</div>
</div>
</div>
</template>
<script>
import VueEditorJs from "./VueEditorJs.vue";
import List from "@editorjs/list";
import Header from "@editorjs/header";
import { mapActions, mapState } from "pinia";
import { usePostStore } from "@/stores/postStore.js";
export default {
components: { VueEditorJs, List, Header },
data() {
return {
post: {
title: "",
slug: "",
excerpt: "",
author_id: null,
featured: false,
featured_image: null,
body: {
time: 1591362820044,
blocks: [],
version: "2.25.0",
},
locale_slug: null,
locale_id: null,
status: "draft",
categories: null,
},
status: ["publish", "future", "draft", "private", "trash"],
config: {
placeholder: "Write something (ノ◕ヮ◕)ノ*:・゚✧",
tools: {
header: {
class: Header,
config: {
placeholder: "Enter a header",
levels: [2, 3, 4],
defaultLevel: 3,
},
},
list: {
class: List,
inlineToolbar: true,
},
},
onReady: () => {},
onChange: (args) => {},
data: {
time: 1591362820044,
blocks: [],
version: "2.25.0",
},
},
};
},
watch: {
"post.title": {
deep: true,
handler(after, before) {
this.post.slug = this.slugify(after);
},
},
},
computed: {
...mapState(usePostStore, [
"countryLocales",
"localeCategories",
"defaultLocaleSlug",
]),
getPostFullUrl() {
if (this.post.slug?.length > 0) {
return (
"https://productalert.co/" +
this.post.locale_slug +
"/posts/" +
this.post.slug
);
}
return (
"https://productalert.co/" +
this.post.locale_slug +
"/posts/enter-a-post-title-to-autogen-slug"
);
},
},
methods: {
...mapActions(usePostStore, [
"fetchCountryLocales",
"fetchLocaleCategories",
]),
checkAndSave() {
let errors = [];
if (!(this.post.title?.length > 0)) {
errors.push("post title");
}
if (!(this.post.slug?.length > 0)) {
errors.push("post slug");
}
if (!(this.post.excerpt?.length > 0)) {
errors.push("post excerpt");
}
if (!(this.post.featured_image?.length > 0)) {
errors.push("post featured image");
}
if (!(this.post.body.blocks?.length > 0)) {
errors.push("Post body");
}
if (
!(this.post.locale_slug?.length > 0) ||
!(this.post.locale_id != null)
) {
errors.push("Country locality");
}
if (!(this.post.categories != null)) {
errors.push("Category");
}
if (errors.length > 0) {
alert("HAIYA many errors! pls fix " + errors.join(", "));
} else {
this.savePost();
}
},
savePost() {},
onInitialized(editor) {},
imageSaved(src) {
this.post.featured_image = src;
},
editorSaved(payload) {
this.post.body = payload;
},
statusChanged(e) {
this.post.status = e.target.value;
},
localeChanged(e) {
this.post.locale_slug = e.target.value;
this.post.locale_id = this.getLocaleIdBySlug(e.target.value);
this.post.categories = [];
setTimeout(
function () {
this.fetchLocaleCategories(this.post.locale_slug);
}.bind(this),
100
);
},
setDefaultLocale() {
if (this.post.locale_slug == null || this.post.locale_slug == "") {
this.post.locale_slug = this.defaultLocaleSlug;
this.post.locale_id = this.getLocaleIdBySlug(this.defaultLocaleSlug);
}
},
getLocaleIdBySlug(slug) {
for (const [key, _item] of Object.entries(this.countryLocales)) {
if (_item.slug == slug) {
return _item.id;
}
}
return null;
},
slugify: function (title) {
var slug = "";
// Change to lower case
var titleLower = title.toLowerCase();
// Replace characters that are not alphabets (a-z), digits (0-9), or spaces with an empty string
slug = titleLower.replace(/[^a-z0-9\s]/g, "");
// Replace consecutive spaces with a single space
slug = slug.replace(/\s+/g, " ");
// Trim any leading or trailing spaces
slug = slug.trim();
// Replace spaces with a single dash
slug = slug.replace(/\s+/g, "-");
return slug;
},
},
mounted() {
this.fetchCountryLocales().then(() => {
this.setDefaultLocale();
setTimeout(
function () {
this.fetchLocaleCategories(this.post.locale_slug);
}.bind(this),
100
);
});
},
};
</script>
<style></style>

View File

@@ -0,0 +1,109 @@
<template>
<div :id="holder" />
</template>
<script>
import EditorJS from "@editorjs/editorjs";
import { defineComponent, onMounted, reactive } from "vue";
export const PLUGINS = {
header: import("@editorjs/header"),
list: import("@editorjs/list"),
};
export default defineComponent({
name: "vue-editor-js",
props: {
holder: {
type: String,
default: () => "vue-editor-js",
require: true,
},
config: {
type: Object,
default: () => ({}),
require: true,
},
initialized: {
type: Function,
default: () => {},
},
},
setup: (props, context) => {
const state = reactive({ editor: null });
function initEditor(props) {
destroyEditor();
state.editor = new EditorJS({
holder: props.holder || "vue-editor-js",
...props.config,
onChange: (api, event) => {
saveEditor();
},
});
props.initialized(state.editor);
}
function destroyEditor() {
if (state.editor) {
state.editor.destroy();
state.editor = null;
}
}
function saveEditor() {
console.log("saveEditor");
if (state.editor) {
state.editor.save().then((data) => {
// Do what you want with the data here
console.log(data);
context.emit("saved", data);
});
}
}
onMounted((_) => initEditor(props));
return { props, state };
},
methods: {
useTools(props, config) {
const pluginKeys = Object.keys(PLUGINS);
const tools = { ...props.customTools };
if (pluginKeys.every((p) => !props[p])) {
pluginKeys.forEach((key) => (tools[key] = { class: PLUGINS[key] }));
Object.keys(config).forEach((key) => {
if (tools[key] !== undefined && tools[key] !== null) {
tools[key]["config"] = config[key];
}
});
return tools;
}
pluginKeys.forEach((key) => {
const prop = props[key];
if (!prop) {
return;
}
tools[key] = { class: PLUGINS[key] };
if (typeof prop === "object") {
const options = Object.assign({}, props[key]);
delete options["class"];
tools[key] = Object.assign(tools[key], options);
}
});
Object.keys(config).forEach((key) => {
if (tools[key] !== undefined && tools[key] !== null) {
tools[key]["config"] = config[key];
}
});
return tools;
},
},
});
</script>

7
resources/js/ziggy.js Normal file
View File

@@ -0,0 +1,7 @@
const Ziggy = {"url":"https:\/\/productalert.test","port":null,"defaults":{},"routes":{"debugbar.openhandler":{"uri":"_debugbar\/open","methods":["GET","HEAD"]},"debugbar.clockwork":{"uri":"_debugbar\/clockwork\/{id}","methods":["GET","HEAD"]},"debugbar.assets.css":{"uri":"_debugbar\/assets\/stylesheets","methods":["GET","HEAD"]},"debugbar.assets.js":{"uri":"_debugbar\/assets\/javascript","methods":["GET","HEAD"]},"debugbar.cache.delete":{"uri":"_debugbar\/cache\/{key}\/{tags?}","methods":["DELETE"]},"sanctum.csrf-cookie":{"uri":"sanctum\/csrf-cookie","methods":["GET","HEAD"]},"ignition.healthCheck":{"uri":"_ignition\/health-check","methods":["GET","HEAD"]},"ignition.executeSolution":{"uri":"_ignition\/execute-solution","methods":["POST"]},"ignition.updateConfig":{"uri":"_ignition\/update-config","methods":["POST"]},"api.auth.login.post":{"uri":"api\/login","methods":["POST"]},"api.auth.logout.post":{"uri":"api\/logout","methods":["POST"]},"api.admin.country-locales":{"uri":"api\/admin\/country-locales","methods":["GET","HEAD"]},"api.admin.categories":{"uri":"api\/admin\/categories\/{country_locale_slug}","methods":["GET","HEAD"]},"api.admin.upload.cloud.image":{"uri":"api\/admin\/image\/upload","methods":["GET","HEAD"]},"login":{"uri":"login","methods":["GET","HEAD"]},"logout":{"uri":"logout","methods":["POST"]},"register":{"uri":"register","methods":["GET","HEAD"]},"password.request":{"uri":"password\/reset","methods":["GET","HEAD"]},"password.email":{"uri":"password\/email","methods":["POST"]},"password.reset":{"uri":"password\/reset\/{token}","methods":["GET","HEAD"]},"password.update":{"uri":"password\/reset","methods":["POST"]},"password.confirm":{"uri":"password\/confirm","methods":["GET","HEAD"]},"dashboard":{"uri":"admin","methods":["GET","HEAD"]},"about":{"uri":"admin\/about","methods":["GET","HEAD"]},"users.index":{"uri":"admin\/users","methods":["GET","HEAD"]},"posts.manage":{"uri":"admin\/posts","methods":["GET","HEAD"]},"posts.manage.edit":{"uri":"admin\/posts\/edit\/{post_id}","methods":["GET","HEAD"]},"posts.manage.new":{"uri":"admin\/posts\/new","methods":["GET","HEAD"]},"profile.show":{"uri":"admin\/profile","methods":["GET","HEAD"]},"profile.update":{"uri":"admin\/profile","methods":["PUT"]},"home":{"uri":"\/","methods":["GET","HEAD"]},"home.country":{"uri":"{country}","methods":["GET","HEAD"]},"home.country.posts":{"uri":"{country}\/posts","methods":["GET","HEAD"]},"home.country.post":{"uri":"{country}\/posts\/{post_slug}","methods":["GET","HEAD"]},"home.country.category":{"uri":"{country}\/{category}","methods":["GET","HEAD"]}}};
if (typeof window !== 'undefined' && typeof window.Ziggy !== 'undefined') {
Object.assign(Ziggy.routes, window.Ziggy.routes);
}
export { Ziggy };