mirror of
https://github.com/mashirozx/sakura.git
synced 2024-12-12 09:54:35 +08:00
Add mobile compatibility
This commit is contained in:
parent
214689a41b
commit
d82fc5fb99
@ -100,7 +100,7 @@ class OptionController extends BaseController
|
||||
if ($hasNoDiff) {
|
||||
return [
|
||||
'code' => 'save_config_succeed',
|
||||
'message' => __('Configurations already up to date.', self::$text_domain),
|
||||
'message' => __('Configuration is already up to date.', self::$text_domain),
|
||||
];
|
||||
}
|
||||
|
||||
@ -108,13 +108,13 @@ class OptionController extends BaseController
|
||||
if (!$config) {
|
||||
return new WP_Error(
|
||||
'save_config_failure',
|
||||
__('Unable to save configuration.', self::$text_domain),
|
||||
__('Unable to save the configuration.', self::$text_domain),
|
||||
array('status' => 500)
|
||||
);
|
||||
} else {
|
||||
return [
|
||||
'code' => 'save_config_succeed',
|
||||
'message' => __('Configurations saved successfully.', self::$text_domain),
|
||||
'message' => __('Configuration saved successfully.', self::$text_domain),
|
||||
];
|
||||
}
|
||||
}
|
||||
|
@ -5,7 +5,7 @@
|
||||
define('SAKURA_VERSION', wp_get_theme()->get('Version'));
|
||||
define('SAKURA_TEXT_DOMAIN', wp_get_theme()->get('TextDomain'));
|
||||
|
||||
define('SAKURA_DEVEPLOMENT', true);
|
||||
define('SAKURA_DEVEPLOMENT', false);
|
||||
define('SAKURA_DEVEPLOMENT_HOST', 'http://127.0.0.1:9000');
|
||||
|
||||
// PHP loaders
|
||||
|
17
src/@types/declarations.d.ts
vendored
17
src/@types/declarations.d.ts
vendored
@ -53,7 +53,20 @@ interface WPPostAbstract {
|
||||
categories: [number?]
|
||||
categoriesMeta: { [key: string]: any }
|
||||
tags: [number?]
|
||||
tagsMeta: { [key: string]: any }
|
||||
tagsMeta: {
|
||||
[key: string]: {
|
||||
count: number
|
||||
description: string
|
||||
filter: string
|
||||
name: string
|
||||
parent: number
|
||||
slug: string
|
||||
taxonomy: string
|
||||
termGroup: number
|
||||
termId: number
|
||||
termTaxonomyId: number
|
||||
}
|
||||
}
|
||||
commentCount: number
|
||||
viewCount: number
|
||||
wordsCount: number
|
||||
@ -117,3 +130,5 @@ interface CommentStore {
|
||||
pagination: Pagination
|
||||
}
|
||||
}
|
||||
|
||||
declare type FetchingStatus = 'inite' | 'cached' | 'pending' | 'success' | 'error' | 'empty'
|
||||
|
15
src/App.vue
15
src/App.vue
@ -4,15 +4,19 @@
|
||||
<component :is="Component" :key="$route.fullPath"></component>
|
||||
</keep-alive>
|
||||
</router-view>
|
||||
<div class="messages__wrapper">
|
||||
<Messages position-y="bottom" position-x="left"></Messages>
|
||||
</div>
|
||||
</template>
|
||||
|
||||
<script lang="ts">
|
||||
import { defineComponent } from 'vue'
|
||||
import { init } from '@/store'
|
||||
import { useInjector } from '@/hooks'
|
||||
import Messages from '@/components/messages/Messages.vue'
|
||||
|
||||
export default defineComponent({
|
||||
name: 'App',
|
||||
components: { Messages },
|
||||
setup() {
|
||||
const { fetchWpJson } = useInjector(init)
|
||||
fetchWpJson()
|
||||
@ -21,5 +25,12 @@ export default defineComponent({
|
||||
</script>
|
||||
|
||||
<style lang="scss">
|
||||
@use '@/styles/index';
|
||||
@use '@/styles/global';
|
||||
|
||||
.messages__wrapper {
|
||||
position: fixed;
|
||||
bottom: 0;
|
||||
left: 0;
|
||||
z-index: 999999;
|
||||
}
|
||||
</style>
|
||||
|
@ -47,10 +47,10 @@
|
||||
</template>
|
||||
|
||||
<script lang="ts">
|
||||
import { defineComponent, ref, Ref, watch, onMounted, onBeforeUnmount } from 'vue'
|
||||
import { defineComponent, ref, Ref, watch } from 'vue'
|
||||
import { Swiper, SwiperSlide } from 'swiper/vue'
|
||||
import { Swiper as SwiperInterface } from 'swiper'
|
||||
import { useInjector, useState, useMessage } from '@/hooks'
|
||||
import { useInjector, useState, useMessage, useIntervalWatcher } from '@/hooks'
|
||||
import store from './store'
|
||||
import options from './options'
|
||||
import type { Option } from './options'
|
||||
@ -90,11 +90,7 @@ export default defineComponent({
|
||||
|
||||
const updateAutoHeight = (timeout = 0) => swiperRef.value?.updateAutoHeight(timeout)
|
||||
|
||||
// auto update height
|
||||
onMounted(() => {
|
||||
const timer = setInterval(() => updateAutoHeight(100), 100)
|
||||
onBeforeUnmount(() => clearInterval(timer))
|
||||
})
|
||||
useIntervalWatcher(() => updateAutoHeight(100), 100)
|
||||
|
||||
// messages
|
||||
const addMessage = useMessage()
|
||||
@ -213,7 +209,7 @@ export default defineComponent({
|
||||
flex-flow: row wrap;
|
||||
justify-content: flex-start;
|
||||
align-items: center;
|
||||
padding: 12px;
|
||||
padding: 0 12px 12px 12px;
|
||||
width: calc(100% - 24px);
|
||||
@include polyfills.flex-gap(12px, 'row wrap');
|
||||
}
|
||||
|
@ -1,6 +1,11 @@
|
||||
<template>
|
||||
<div class="option__container">
|
||||
<h3 class="column__wrapper--label"> {{ title }} </h3>
|
||||
<h3 class="column__wrapper--label">
|
||||
{{ title }}
|
||||
<span class="restore" :title="msg.restoreTitle" @click="handleRestoreEvent"
|
||||
><i class="fas fa-redo-alt"></i
|
||||
></span>
|
||||
</h3>
|
||||
<div class="column__wrapper--main">
|
||||
<div class="row__wrapper--option">
|
||||
<OutlinedInput
|
||||
@ -43,7 +48,8 @@
|
||||
|
||||
<script lang="ts">
|
||||
import { defineComponent, ref, watch } from 'vue'
|
||||
import { useInjector } from '@/hooks'
|
||||
import { cloneDeep } from 'lodash'
|
||||
import { useInjector, useIntl } from '@/hooks'
|
||||
import store from './store'
|
||||
import validator from './validator'
|
||||
import OutlinedInput from '@/components/inputs/OutlinedInput.vue'
|
||||
@ -60,9 +66,16 @@ export default defineComponent({
|
||||
},
|
||||
emits: [],
|
||||
setup(props, { emit }) {
|
||||
const intl = useIntl()
|
||||
const msg = {
|
||||
restoreTitle: intl.formatMessage({
|
||||
id: 'admin.restore.title',
|
||||
defaultMessage: 'Restore this option to default.',
|
||||
}),
|
||||
}
|
||||
const { namespace, type, title, desc, binds } = props.option
|
||||
const { config, updateOption } = useInjector(store)
|
||||
const optionResultRef = ref(config.value[namespace] ?? props.option.default)
|
||||
const optionResultRef = ref(config.value[namespace] ?? cloneDeep(props.option).default)
|
||||
|
||||
watch(
|
||||
optionResultRef,
|
||||
@ -74,7 +87,11 @@ export default defineComponent({
|
||||
{ immediate: true, deep: true }
|
||||
)
|
||||
|
||||
return { config, optionResultRef, type, title, desc, binds }
|
||||
const handleRestoreEvent = () => {
|
||||
optionResultRef.value = cloneDeep(props.option).default
|
||||
}
|
||||
|
||||
return { msg, config, optionResultRef, type, title, desc, binds, handleRestoreEvent }
|
||||
},
|
||||
})
|
||||
</script>
|
||||
@ -96,6 +113,19 @@ export default defineComponent({
|
||||
flex: 0 0 auto;
|
||||
width: 200px;
|
||||
padding-top: 15px;
|
||||
@media screen and (max-width: variables.$mobile-max-width) {
|
||||
margin-block-start: 0;
|
||||
margin-block-end: 0;
|
||||
padding-top: 0;
|
||||
}
|
||||
> .restore {
|
||||
padding-left: 6px;
|
||||
font-size: 0.8em;
|
||||
color: var(--mdc-theme-secondary, #1d2327);
|
||||
:hover {
|
||||
color: var(--mdc-theme-primary, #6200ee);
|
||||
}
|
||||
}
|
||||
}
|
||||
&--main {
|
||||
width: 100%;
|
||||
@ -112,6 +142,7 @@ export default defineComponent({
|
||||
&--desc {
|
||||
font-size: 14px;
|
||||
color: #646970;
|
||||
// margin-block-end: 0;
|
||||
}
|
||||
}
|
||||
}
|
||||
|
@ -139,14 +139,21 @@ export default defineComponent({
|
||||
(value) => {
|
||||
if (!props.multiple && value.length > 1) {
|
||||
selection.value = selection.value.slice(-1)
|
||||
console.log(selection.value.length)
|
||||
// console.log(selection.value.length)
|
||||
}
|
||||
console.log(selection.value)
|
||||
// console.log(selection.value)
|
||||
emit('update:selection', selection.value)
|
||||
},
|
||||
{ deep: true }
|
||||
)
|
||||
|
||||
watch(
|
||||
() => props.selection as { id: number; url: string }[],
|
||||
(selectionProp) => {
|
||||
selection.value = selectionProp
|
||||
}
|
||||
)
|
||||
|
||||
return { open, add, del, userInput, selection }
|
||||
},
|
||||
})
|
||||
@ -183,9 +190,6 @@ export default defineComponent({
|
||||
flex-flow: row wrap;
|
||||
justify-content: flex-start;
|
||||
@include polyfills.flex-gap(12px, 'row wrap');
|
||||
> .input__wrapper {
|
||||
flex: 0 0 auto;
|
||||
}
|
||||
}
|
||||
}
|
||||
&--preview {
|
||||
|
@ -1,9 +1,9 @@
|
||||
import { Ref } from 'vue'
|
||||
import { useState } from '@/hooks'
|
||||
import API from '@/api'
|
||||
import camelcaseKeys from 'camelcase-keys'
|
||||
import intl from '@/locales'
|
||||
import options, { Options } from './options'
|
||||
// import API from '@/api'
|
||||
// import camelcaseKeys from 'camelcase-keys'
|
||||
// import intl from '@/locales'
|
||||
// import options, { Options } from './options'
|
||||
import { cloneDeep } from 'lodash'
|
||||
|
||||
export interface OptionStore {
|
||||
|
@ -2,21 +2,25 @@
|
||||
<div class="card__container mdc-card mdc-elevation--z8" :type="$props.type" v-if="data">
|
||||
<div class="card__content mdc-card__primary-action" :ref="setContentRef">
|
||||
<div class="ripple__mask mdc-card__ripple"></div>
|
||||
<div class="thumbnail__wrapper" @click="handleViewPostDetailEvent">
|
||||
<Image
|
||||
class="image"
|
||||
:src="$props.data.featureImage.thumbnail"
|
||||
:alt="$props.data.title"
|
||||
placeholder="https://via.placeholder.com/1024x768"
|
||||
:draggable="false"
|
||||
/>
|
||||
<div class="thumbnail__wrapper">
|
||||
<Link :url="$props.data.link">
|
||||
<Image
|
||||
class="image"
|
||||
:src="$props.data.featureImage.thumbnail"
|
||||
:alt="$props.data.title"
|
||||
placeholder="https://via.placeholder.com/1024x768"
|
||||
:draggable="false"
|
||||
/>
|
||||
</Link>
|
||||
</div>
|
||||
<div class="details__wrapper">
|
||||
<div class="row__wrapper--date">
|
||||
<span><i class="far fa-clock"></i> {{ $props.data.publistTime }}</span>
|
||||
</div>
|
||||
<div class="row__wrapper--title" @click="handleViewPostDetailEvent">
|
||||
<span>{{ $props.data.title }}</span>
|
||||
<div class="row__wrapper--title">
|
||||
<Link :url="$props.data.link">
|
||||
<span>{{ $props.data.title }}</span>
|
||||
</Link>
|
||||
</div>
|
||||
<div class="row__wrapper--info">
|
||||
<div class="column__wrapper--read_count">
|
||||
@ -32,19 +36,20 @@
|
||||
<div class="row__wrapper--abstruct">
|
||||
<span>{{ $props.data.excerpt }} </span>
|
||||
</div>
|
||||
<!-- <div class="row__wrapper--tags">
|
||||
<div class="row__wrapper--tags" v-if="$props.data.tags.length > 0">
|
||||
<div class="tags__container">
|
||||
<div class="tag__wrapper" v-for="(tag, index) in tags" :key="index">
|
||||
<div class="tag yolk">
|
||||
<span class="text">{{ tag }}</span>
|
||||
</div>
|
||||
<div class="tag__wrapper" v-for="(tag, index) in $props.data.tags" :key="index">
|
||||
<Link :to="{ name: 'TagArchive', params: { tag: tag.slug } }">
|
||||
<NormalChip :context="tag.name"></NormalChip>
|
||||
</Link>
|
||||
</div>
|
||||
</div>
|
||||
</div> -->
|
||||
<!-- // TODO: use tags instead of button, button is useless! -->
|
||||
<div class="row__wrapper--button" @click="handleViewPostDetailEvent">
|
||||
</div>
|
||||
<div class="row__wrapper--button">
|
||||
<div class="button__wrapper">
|
||||
<NormalButton icon="fab fa-readme" :context="buttonContext"></NormalButton>
|
||||
<Link :url="$props.data.link">
|
||||
<NormalButton icon="fab fa-readme" :context="buttonContext"></NormalButton>
|
||||
</Link>
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
@ -55,11 +60,11 @@
|
||||
<script lang="ts">
|
||||
import { defineComponent, computed } from 'vue'
|
||||
import { useIntl, useRouter, useElementRef, useMDCRipple } from '@/hooks'
|
||||
import linkHandler from '@/utils/linkHandler'
|
||||
import NormalButton from '@/components/buttons/NormalButton.vue'
|
||||
import NormalChip from '@/components/chips/NormalChip.vue'
|
||||
|
||||
export default defineComponent({
|
||||
components: { NormalButton },
|
||||
components: { NormalButton, NormalChip },
|
||||
props: {
|
||||
data: { type: Object },
|
||||
type: { type: String, default: 'normal' }, // normal | reverse | mobile
|
||||
@ -76,13 +81,8 @@ export default defineComponent({
|
||||
defaultMessage: 'Read More',
|
||||
})
|
||||
|
||||
const handleViewPostDetailEvent = () => {
|
||||
linkHandler.handleClickLink({ url: props.data?.link ?? '', router, target: '_blank' })
|
||||
}
|
||||
|
||||
return {
|
||||
buttonContext,
|
||||
handleViewPostDetailEvent,
|
||||
setContentRef,
|
||||
}
|
||||
},
|
||||
@ -96,11 +96,11 @@ export default defineComponent({
|
||||
@use '@/styles/mixins/polyfills';
|
||||
|
||||
.card__container {
|
||||
// TODO: sizing in parent
|
||||
width: 780px;
|
||||
height: 300px;
|
||||
background: #ffffff;
|
||||
border-radius: 10px;
|
||||
user-select: none;
|
||||
.card__content {
|
||||
width: 100%;
|
||||
height: 100%;
|
||||
@ -151,7 +151,7 @@ export default defineComponent({
|
||||
}
|
||||
&--title {
|
||||
cursor: pointer;
|
||||
> span {
|
||||
span {
|
||||
line-height: 32px;
|
||||
font-size: large;
|
||||
font-weight: 700;
|
||||
@ -186,7 +186,7 @@ export default defineComponent({
|
||||
}
|
||||
}
|
||||
&--tags {
|
||||
max-height: 16px;
|
||||
max-height: 32px;
|
||||
overflow: hidden;
|
||||
align-items: flex-start;
|
||||
.tags__container {
|
||||
@ -200,7 +200,9 @@ export default defineComponent({
|
||||
flex-flow: row nowrap;
|
||||
justify-content: flex-start;
|
||||
align-items: center;
|
||||
@include tags.tag-style;
|
||||
.router-link {
|
||||
text-decoration: none;
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
|
@ -1,16 +1,20 @@
|
||||
<template>
|
||||
<div class="card__container">
|
||||
<div class="row__wrapper--thumbnail" @click="handleViewPostDetailEvent">
|
||||
<Image
|
||||
class="image"
|
||||
:src="$props.data.featureImage.thumbnail"
|
||||
:alt="$props.data.title"
|
||||
placeholder="https://via.placeholder.com/1024x768"
|
||||
:draggable="false"
|
||||
/>
|
||||
<div class="row__wrapper--thumbnail">
|
||||
<Link :url="$props.data.link">
|
||||
<Image
|
||||
class="image"
|
||||
:src="$props.data.featureImage.thumbnail"
|
||||
:alt="$props.data.title"
|
||||
placeholder="https://via.placeholder.com/1024x768"
|
||||
:draggable="false"
|
||||
/>
|
||||
</Link>
|
||||
</div>
|
||||
<div class="row__wrapper--title" @click="handleViewPostDetailEvent">
|
||||
<span>{{ $props.data.title }}</span>
|
||||
<div class="row__wrapper--title">
|
||||
<Link :url="$props.data.link">
|
||||
<span>{{ $props.data.title }}</span>
|
||||
</Link>
|
||||
</div>
|
||||
<div class="row__wrapper--statistics">
|
||||
<div class="column__wrapper--read_count">
|
||||
@ -26,16 +30,12 @@
|
||||
<div class="row__wrapper--abstract">
|
||||
<span>{{ $props.data.excerpt }} </span>
|
||||
</div>
|
||||
<div class="row__wrapper--tags">
|
||||
<div class="row__wrapper--tags" v-if="$props.data.tags.length > 0">
|
||||
<div class="tags__container">
|
||||
<div
|
||||
class="tag__wrapper"
|
||||
v-for="(tag, index) in ['vue', 'javascript', 'php', 'wordpress']"
|
||||
:key="index"
|
||||
>
|
||||
<div class="tag yolk">
|
||||
<span class="text">{{ tag }}</span>
|
||||
</div>
|
||||
<div class="tag__wrapper" v-for="(tag, index) in $props.data.tags" :key="index">
|
||||
<Link :to="{ name: 'TagArchive', params: { tag: tag.slug } }">
|
||||
<NormalChip :context="tag.name"></NormalChip>
|
||||
</Link>
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
@ -45,11 +45,10 @@
|
||||
<script lang="ts">
|
||||
import { defineComponent, computed } from 'vue'
|
||||
import { useIntl, useRouter } from '@/hooks'
|
||||
import linkHandler from '@/utils/linkHandler'
|
||||
import NormalButton from '@/components/buttons/NormalButton.vue'
|
||||
import NormalChip from '@/components/chips/NormalChip.vue'
|
||||
|
||||
export default defineComponent({
|
||||
components: { NormalButton },
|
||||
components: { NormalChip },
|
||||
props: {
|
||||
data: { type: Object /*, default: () => postMock*/ },
|
||||
type: { type: String, default: 'normal' }, // normal | reverse | mobile
|
||||
@ -63,13 +62,8 @@ export default defineComponent({
|
||||
defaultMessage: 'Read More',
|
||||
})
|
||||
|
||||
const handleViewPostDetailEvent = () => {
|
||||
linkHandler.handleClickLink({ url: props.data?.link ?? '', router, target: '_blank' })
|
||||
}
|
||||
|
||||
return {
|
||||
buttonContext,
|
||||
handleViewPostDetailEvent,
|
||||
}
|
||||
},
|
||||
})
|
||||
@ -77,7 +71,6 @@ export default defineComponent({
|
||||
|
||||
<style lang="scss" scoped>
|
||||
@use '@/styles/mixins/text';
|
||||
@use '@/styles/mixins/tags';
|
||||
@use '@/styles/mixins/polyfills';
|
||||
|
||||
.card__container {
|
||||
@ -86,6 +79,7 @@ export default defineComponent({
|
||||
flex-flow: column nowrap;
|
||||
justify-content: flex-start;
|
||||
align-items: center;
|
||||
user-select: none;
|
||||
@include polyfills.flex-gap(12px, 'column nowrap');
|
||||
> * {
|
||||
width: calc(100% - 24px);
|
||||
@ -95,7 +89,7 @@ export default defineComponent({
|
||||
width: 100%;
|
||||
}
|
||||
&--tags {
|
||||
max-height: 16px;
|
||||
max-height: 32px;
|
||||
overflow: hidden;
|
||||
align-items: flex-start;
|
||||
.tags__container {
|
||||
@ -109,7 +103,6 @@ export default defineComponent({
|
||||
flex-flow: row nowrap;
|
||||
justify-content: flex-start;
|
||||
align-items: center;
|
||||
@include tags.tag-style;
|
||||
}
|
||||
}
|
||||
}
|
||||
|
@ -42,9 +42,13 @@ export default defineComponent({
|
||||
}
|
||||
})
|
||||
|
||||
// watch(resultRef,result=>{
|
||||
// if()
|
||||
// })
|
||||
watch(
|
||||
() => props.result,
|
||||
(resultProp) => {
|
||||
resultRef.value = resultProp
|
||||
},
|
||||
{ deep: true }
|
||||
)
|
||||
|
||||
return { resultRef, isMax }
|
||||
},
|
||||
|
@ -1,15 +1,15 @@
|
||||
<template>
|
||||
<div class="single-content__wrapper" v-if="postData">
|
||||
<div class="featuer-image__wrapper">
|
||||
<div class="single-content__wrapper">
|
||||
<div class="featuer-image__wrapper" v-if="postData.publistTimeBrief">
|
||||
<FeatureImage :data="postData"></FeatureImage>
|
||||
</div>
|
||||
<div class="article__wrapper">
|
||||
<div class="article__wrapper" v-if="postData.content">
|
||||
<Article :content="postData.content"></Article>
|
||||
</div>
|
||||
<div class="content-loader__wrapper" v-show="postFetchStatus === 'fetching'">
|
||||
<div class="content-loader__wrapper" v-show="postFetchStatus === 'pending'">
|
||||
<BookLoader></BookLoader>
|
||||
</div>
|
||||
<div class="comment__wrapper">
|
||||
<div class="comment__wrapper" v-if="postId">
|
||||
<Comment :postId="postId"></Comment>
|
||||
</div>
|
||||
</div>
|
||||
@ -17,6 +17,7 @@
|
||||
|
||||
<script lang="ts">
|
||||
import { defineComponent, computed } from 'vue'
|
||||
import { isEmpty } from 'lodash'
|
||||
import contentHandler from './utils/contentHandler'
|
||||
import FeatureImage from './components/FeatureImage.vue'
|
||||
import Article from './components/Article.vue'
|
||||
@ -32,7 +33,8 @@ export default defineComponent({
|
||||
setup(props) {
|
||||
const { postData, postFetchStatus } = contentHandler(props)
|
||||
const postId = computed(() => {
|
||||
return postData.value?.id
|
||||
if (isEmpty(postData.value)) return false
|
||||
return (postData.value as Post)?.id
|
||||
})
|
||||
return { postData, postFetchStatus, postId }
|
||||
},
|
||||
@ -41,6 +43,7 @@ export default defineComponent({
|
||||
|
||||
<style lang="scss" scoped>
|
||||
@use '@/styles/mixins/tags';
|
||||
@use '@/styles/mixins/sizes';
|
||||
@use '@/styles/mixins/skeleton';
|
||||
.single-content__wrapper {
|
||||
width: 100%;
|
||||
@ -51,15 +54,11 @@ export default defineComponent({
|
||||
.featuer-image__wrapper {
|
||||
width: 100%;
|
||||
}
|
||||
.article__wrapper {
|
||||
width: 100%;
|
||||
max-width: 800px;
|
||||
padding-top: 24px;
|
||||
}
|
||||
.article__wrapper,
|
||||
.comment__wrapper {
|
||||
width: 100%;
|
||||
max-width: 800px;
|
||||
padding-top: 24px;
|
||||
width: calc(100% - 12px * 2);
|
||||
max-width: #{sizes.$post-main-content-max-width}; // 800px
|
||||
padding: 24px 12px 0 12px;
|
||||
}
|
||||
}
|
||||
</style>
|
||||
|
@ -31,17 +31,7 @@
|
||||
</div>
|
||||
<div class="flex-box">
|
||||
<div class="column__wrapper--publish">
|
||||
<span>{{ $props.data.publistTime }}</span>
|
||||
</div>
|
||||
</div>
|
||||
<div class="flex-box">
|
||||
<div class="column__wrapper--words">
|
||||
<span>{{ $props.data.wordCount }}</span>
|
||||
</div>
|
||||
</div>
|
||||
<div class="flex-box">
|
||||
<div class="column__wrapper--reads">
|
||||
<span>{{ $props.data.readCount }}</span>
|
||||
<span>{{ $props.data.publistTimeBrief }}</span>
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
@ -74,6 +64,7 @@ export default defineComponent({
|
||||
|
||||
<style lang="scss" scoped>
|
||||
@use '@/styles/mixins/text';
|
||||
@use '@/styles/mixins/sizes';
|
||||
@use '@/styles/mixins/polyfills';
|
||||
.feature-image__container {
|
||||
width: 100%;
|
||||
@ -89,20 +80,21 @@ export default defineComponent({
|
||||
width: 100%;
|
||||
height: 100%;
|
||||
z-index: -1;
|
||||
&--image {
|
||||
}
|
||||
// &--image {
|
||||
// }
|
||||
&--pattern {
|
||||
background: yellowgreen;
|
||||
}
|
||||
}
|
||||
.post-info__wrapper {
|
||||
width: 100%;
|
||||
max-width: 800px;
|
||||
max-width: #{sizes.$post-main-content-max-width}; // 800px
|
||||
padding-bottom: 24px;
|
||||
display: flex;
|
||||
flex-flow: column nowrap;
|
||||
justify-content: flex-end;
|
||||
align-items: flex-start;
|
||||
margin: 0 12px;
|
||||
> * {
|
||||
display: flex;
|
||||
flex-flow: row nowrap;
|
||||
@ -116,8 +108,8 @@ export default defineComponent({
|
||||
line-height: 48px;
|
||||
font-size: xx-large;
|
||||
color: #ffffff;
|
||||
// @include text.line-number-limit(1);
|
||||
// @include text.text-shadow-offset;
|
||||
@include text.line-number-limit(4);
|
||||
@include text.text-shadow-offset;
|
||||
}
|
||||
}
|
||||
&--info {
|
||||
@ -164,14 +156,10 @@ export default defineComponent({
|
||||
margin-right: 6px;
|
||||
}
|
||||
}
|
||||
&--author {
|
||||
}
|
||||
&--publish {
|
||||
}
|
||||
&--words {
|
||||
}
|
||||
&--reads {
|
||||
}
|
||||
// &--author {
|
||||
// }
|
||||
// &--publish {
|
||||
// }
|
||||
}
|
||||
}
|
||||
}
|
||||
|
@ -24,12 +24,13 @@
|
||||
</template>
|
||||
|
||||
<script lang="ts">
|
||||
import { defineComponent, watch, computed, toRefs, onMounted, nextTick, ref } from 'vue'
|
||||
import { defineComponent, watch, computed, toRefs, onMounted, nextTick, ref, Comment } from 'vue'
|
||||
import { cloneDeep } from 'lodash'
|
||||
import camelcaseKeys from 'camelcase-keys'
|
||||
import { useInjector, useState, useRoute } from '@/hooks'
|
||||
import { useInjector, useState, useRoute, useMessage, useIntl } from '@/hooks'
|
||||
import { comments } from '@/store'
|
||||
import API from '@/api'
|
||||
import axiosErrorHandler from '@/utils/axiosErrorHandler'
|
||||
import CommentList from './CommentList.vue'
|
||||
import Pagination from '@/components/pagination/Pagination.vue'
|
||||
import Composer from './Composer.vue'
|
||||
@ -40,6 +41,8 @@ export default defineComponent({
|
||||
postId: Number,
|
||||
},
|
||||
setup(props) {
|
||||
const addMessage = useMessage()
|
||||
const intl = useIntl()
|
||||
const route = useRoute()
|
||||
// const commentPagination = {
|
||||
// hash: route.hash, // TODO: support nested
|
||||
@ -51,7 +54,7 @@ export default defineComponent({
|
||||
const [page, setPage] = useState(1)
|
||||
const [perPage, setPerpage] = useState(10)
|
||||
const [totalPage, setTotalPage] = useState(1)
|
||||
const [commentData, setCommentData] = useState([])
|
||||
const [commentData, setCommentData] = useState([] as Comment[])
|
||||
|
||||
const namespace = computed(() => `comment-for-post-${postId.value}`)
|
||||
|
||||
@ -92,18 +95,27 @@ export default defineComponent({
|
||||
API.Sakura.v1
|
||||
.createComment({ authorEmail, authorName, authorUrl, content, parent, post })
|
||||
.then((res) => {
|
||||
const _commentData = cloneDeep(commentData.value)
|
||||
const _commentData = cloneDeep(commentData.value) as Comment[]
|
||||
_commentData.push(camelcaseKeys(res.data))
|
||||
setCommentData(_commentData)
|
||||
console.log(res.data, commentData.value)
|
||||
// console.log(res.data, commentData.value)
|
||||
addMessage({
|
||||
type: 'success',
|
||||
title: intl.formatMessage({
|
||||
id: 'messages.comment.submit.success',
|
||||
defaultMessage: 'Comment post successfully.',
|
||||
}),
|
||||
})
|
||||
composerRef.value?.clearInputContent()
|
||||
})
|
||||
.catch((error) => {
|
||||
if (error.response) {
|
||||
console.error(error.response)
|
||||
} else {
|
||||
console.error(error)
|
||||
}
|
||||
const titleMsg = intl.formatMessage({
|
||||
id: 'messages.comment.submit.error',
|
||||
defaultMessage: 'Comment post failure.',
|
||||
})
|
||||
const errorMsg = axiosErrorHandler(error).msg
|
||||
console.log(errorMsg)
|
||||
addMessage({ type: 'error', title: titleMsg, detail: errorMsg, closeTimeout: 0 })
|
||||
})
|
||||
}
|
||||
|
||||
|
@ -11,38 +11,40 @@
|
||||
></OutlinedTextarea>
|
||||
</div>
|
||||
<div class="row__wrapper--profile">
|
||||
<div class="column__wrapper--avatar">
|
||||
<div class="avatar__wrapper mdc-elevation--z1">
|
||||
<Image :src="avatar" placeholder="" :avatar="false" alt="" :draggable="false"></Image>
|
||||
<div class="flex-box">
|
||||
<div class="column__wrapper--avatar">
|
||||
<div class="avatar__wrapper mdc-elevation--z1">
|
||||
<Image :src="avatar" placeholder="" :avatar="false" alt="" :draggable="false"></Image>
|
||||
</div>
|
||||
<div class="icon__wrapper avatar__wrapper mdc-elevation--z2">
|
||||
<span class="gravatar">
|
||||
<!-- <i class="fab fa-qq"></i> -->
|
||||
<i class="fab fa-google"></i>
|
||||
</span>
|
||||
</div>
|
||||
</div>
|
||||
<div class="icon__wrapper avatar__wrapper mdc-elevation--z2">
|
||||
<span class="gravatar">
|
||||
<!-- <i class="fab fa-qq"></i> -->
|
||||
<i class="fab fa-google"></i>
|
||||
</span>
|
||||
<div class="column__wrapper--input username">
|
||||
<OutlinedInput
|
||||
v-model:content="inputAuthorName"
|
||||
leadingIcon="fas fa-user"
|
||||
:label="messages.nickname"
|
||||
></OutlinedInput>
|
||||
</div>
|
||||
<div class="column__wrapper--input">
|
||||
<OutlinedInput
|
||||
v-model:content="inputAuthorEmail"
|
||||
leadingIcon="fas fa-envelope"
|
||||
:label="messages.email"
|
||||
@blur="handleEmailInputBlurEvent"
|
||||
></OutlinedInput>
|
||||
</div>
|
||||
<div class="column__wrapper--input">
|
||||
<OutlinedInput
|
||||
v-model:content="inputAuthorUrl"
|
||||
leadingIcon="fas fa-home"
|
||||
:label="messages.link"
|
||||
></OutlinedInput>
|
||||
</div>
|
||||
</div>
|
||||
<div class="column__wrapper--input">
|
||||
<OutlinedInput
|
||||
v-model:content="inputAuthorName"
|
||||
leadingIcon="fas fa-user"
|
||||
:label="messages.nickname"
|
||||
></OutlinedInput>
|
||||
</div>
|
||||
<div class="column__wrapper--input">
|
||||
<OutlinedInput
|
||||
v-model:content="inputAuthorEmail"
|
||||
leadingIcon="fas fa-envelope"
|
||||
:label="messages.email"
|
||||
@blur="handleEmailInputBlurEvent"
|
||||
></OutlinedInput>
|
||||
</div>
|
||||
<div class="column__wrapper--input">
|
||||
<OutlinedInput
|
||||
v-model:content="inputAuthorUrl"
|
||||
leadingIcon="fas fa-home"
|
||||
:label="messages.link"
|
||||
></OutlinedInput>
|
||||
</div>
|
||||
</div>
|
||||
<!-- <div class="row__wrapper--options"></div> -->
|
||||
@ -164,47 +166,70 @@ export default defineComponent({
|
||||
}
|
||||
}
|
||||
&--profile {
|
||||
display: flex;
|
||||
flex-flow: row nowrap;
|
||||
justify-content: space-between;
|
||||
align-items: center;
|
||||
@include polyfills.flex-gap(12px, 'row nowrap');
|
||||
.column__wrapper {
|
||||
&--avatar {
|
||||
flex: 0 0 auto;
|
||||
position: relative;
|
||||
> .avatar__wrapper {
|
||||
width: 56px;
|
||||
height: 56px;
|
||||
border-radius: 50%;
|
||||
overflow: hidden;
|
||||
}
|
||||
> .icon__wrapper {
|
||||
position: absolute;
|
||||
right: 0;
|
||||
bottom: 0;
|
||||
width: 20px;
|
||||
height: 20px;
|
||||
background: #03a9f4;
|
||||
border-radius: 50%;
|
||||
display: flex;
|
||||
align-items: center;
|
||||
justify-content: center;
|
||||
span {
|
||||
width: 12px;
|
||||
height: 12px;
|
||||
color: #fff;
|
||||
line-height: 12px;
|
||||
font-size: small;
|
||||
&.gravatar {
|
||||
transform: rotate(270deg);
|
||||
> .flex-box {
|
||||
position: relative;
|
||||
display: flex;
|
||||
flex-flow: row nowrap;
|
||||
justify-content: space-between;
|
||||
align-items: center;
|
||||
@include polyfills.flex-gap(12px, 'row nowrap');
|
||||
@media screen and (max-width: 800px) {
|
||||
flex-flow: column nowrap;
|
||||
@include polyfills.flex-gap-unset('row nowrap');
|
||||
@include polyfills.flex-gap(12px, 'column nowrap');
|
||||
}
|
||||
.column__wrapper {
|
||||
&--avatar {
|
||||
flex: 0 0 auto;
|
||||
position: relative;
|
||||
@media screen and (max-width: 800px) {
|
||||
position: absolute;
|
||||
top: 0;
|
||||
right: 0;
|
||||
transform: scale(0.8);
|
||||
}
|
||||
> .avatar__wrapper {
|
||||
width: 56px;
|
||||
height: 56px;
|
||||
border-radius: 50%;
|
||||
overflow: hidden;
|
||||
}
|
||||
> .icon__wrapper {
|
||||
position: absolute;
|
||||
right: 0;
|
||||
bottom: 0;
|
||||
width: 20px;
|
||||
height: 20px;
|
||||
background: #03a9f4;
|
||||
border-radius: 50%;
|
||||
display: flex;
|
||||
align-items: center;
|
||||
justify-content: center;
|
||||
span {
|
||||
width: 12px;
|
||||
height: 12px;
|
||||
color: #fff;
|
||||
line-height: 12px;
|
||||
font-size: small;
|
||||
&.gravatar {
|
||||
transform: rotate(270deg);
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
&--input {
|
||||
flex: 1 1 auto;
|
||||
width: 100%;
|
||||
@media screen and (max-width: 800px) {
|
||||
&.username {
|
||||
::v-deep() {
|
||||
.mdc-text-field__input {
|
||||
width: calc(100% - 80px);
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
&--input {
|
||||
flex: 1 1 auto;
|
||||
width: 100%;
|
||||
}
|
||||
}
|
||||
}
|
||||
|
@ -1,17 +1,22 @@
|
||||
import { computed, onMounted, Ref, nextTick } from 'vue'
|
||||
import { useInjector, useState, useIntl, useRoute } from '@/hooks'
|
||||
import { posts } from '@/store'
|
||||
import { isEmpty } from 'lodash'
|
||||
import { useInjector, useState, useIntl, useRoute, useMessage, useCommonMessages } from '@/hooks'
|
||||
import { posts, messages } from '@/store'
|
||||
import { GetPostParams, GetPageParams } from '@/api/Wp/v2' // interfaces
|
||||
import postFilter from '@/utils/filters/postFilter'
|
||||
|
||||
export type Content = Post | {}
|
||||
|
||||
export default function setup(props: {
|
||||
readonly singleType?: string | undefined
|
||||
readonly pageType?: string | undefined
|
||||
}) {
|
||||
const intl = useIntl()
|
||||
const addMessage = useMessage()
|
||||
const commonMessages = useCommonMessages()
|
||||
const route = useRoute()
|
||||
const [fetchStatus, setFetchStatus] = useState('fetching')
|
||||
const [content, setContent]: [Ref<Post>, (attr: any) => any] = useState(null)
|
||||
const [fetchStatus, setFetchStatus] = useState('inite' as FetchingStatus)
|
||||
const [content, setContent] = useState({} as Content)
|
||||
const {
|
||||
postsStore,
|
||||
fetchPost,
|
||||
@ -19,13 +24,22 @@ export default function setup(props: {
|
||||
getPostsList,
|
||||
}: { postsStore: Ref<PostStore>; [key: string]: any } = useInjector(posts) // TODO: fix useInjector return type
|
||||
|
||||
// TODO: [bug] https://github.com/mashirozx/sakura-next/issues/148
|
||||
// console.log('[DEBUG]', route.params)
|
||||
// addMessage({
|
||||
// title: '[DEBUG] contentHandler',
|
||||
// detail: JSON.stringify(route.params),
|
||||
// type: 'info',
|
||||
// closeTimeout: 0,
|
||||
// })
|
||||
|
||||
const { slug, postId, postname } = route.params
|
||||
const isSingle = props.singleType ? true : false
|
||||
const namespace = isSingle ? `single-${postId || postname}` : `page-${slug}`
|
||||
|
||||
// get parsed post content
|
||||
const data = computed(() =>
|
||||
content.value ? postFilter(content.value, isSingle ? 'single' : 'page') : null
|
||||
!isEmpty(content.value) ? postFilter(content.value as Post, isSingle ? 'single' : 'page') : {}
|
||||
)
|
||||
|
||||
const defaultFetchOpts: GetPostParams | GetPageParams = { page: 1, perPage: 1 }
|
||||
@ -33,42 +47,62 @@ export default function setup(props: {
|
||||
if (postId) {
|
||||
defaultFetchOpts['include'] = Number(postId)
|
||||
} else if (postname) {
|
||||
defaultFetchOpts['slug'] = postname
|
||||
defaultFetchOpts['slug'] = postname as string
|
||||
} else if (slug) {
|
||||
defaultFetchOpts['slug'] = slug
|
||||
defaultFetchOpts['slug'] = slug as string
|
||||
} else if (isSingle) {
|
||||
throw new Error(
|
||||
intl.formatMessage(
|
||||
{
|
||||
id: 'messages.wordpress.permalink.shouldIncludeFieldsInSingle',
|
||||
defaultMessage:
|
||||
'WordPress permalink should include at least one of %post_id%, %postname%.\nYou may set them here: {baseUrl}/wp-admin/options-permalink.php',
|
||||
},
|
||||
{
|
||||
baseUrl: window.location.origin,
|
||||
}
|
||||
)
|
||||
// TODO: should wait router https://github.com/mashirozx/sakura-next/issues/148
|
||||
const errorMsg = intl.formatMessage(
|
||||
{
|
||||
id: 'messages.wordpress.permalink.shouldIncludeFieldsInSingle',
|
||||
defaultMessage:
|
||||
'WordPress permalink should include at least one of %post_id%, %postname%.\nYou may set them here: {baseUrl}/wp-admin/options-permalink.php',
|
||||
},
|
||||
{
|
||||
baseUrl: window.location.origin,
|
||||
}
|
||||
)
|
||||
addMessage({
|
||||
title: commonMessages.javascriptErrorTitle,
|
||||
detail: errorMsg,
|
||||
type: 'error',
|
||||
closeTimeout: 0,
|
||||
})
|
||||
console.error(errorMsg)
|
||||
} else {
|
||||
throw new Error(
|
||||
intl.formatMessage({
|
||||
id: 'messages.wordpress.permalink.shouldIncludeFieldsInPost',
|
||||
defaultMessage: 'WordPress pages should use %slug% as the permalink.',
|
||||
})
|
||||
)
|
||||
const errorMsg = intl.formatMessage({
|
||||
id: 'messages.wordpress.permalink.shouldIncludeFieldsInPost',
|
||||
defaultMessage: 'WordPress pages should use %slug% as the permalink.',
|
||||
})
|
||||
addMessage({
|
||||
title: commonMessages.javascriptErrorTitle,
|
||||
detail: errorMsg,
|
||||
type: 'error',
|
||||
closeTimeout: 0,
|
||||
})
|
||||
console.error(errorMsg)
|
||||
}
|
||||
|
||||
const fetchContent = () => {
|
||||
const fetchOption = isSingle ? fetchPost : fetchPage
|
||||
setFetchStatus('fetching')
|
||||
if (fetchStatus.value !== 'cached') {
|
||||
setFetchStatus('pending')
|
||||
}
|
||||
fetchOption({
|
||||
state: postsStore,
|
||||
namespace,
|
||||
opts: { ...defaultFetchOpts },
|
||||
}).then(() => {
|
||||
getContent()
|
||||
setFetchStatus('done')
|
||||
addMessage,
|
||||
})
|
||||
.then(() => {
|
||||
window.setTimeout(() => {
|
||||
getContent()
|
||||
setFetchStatus('success')
|
||||
}, 500)
|
||||
})
|
||||
.catch(() => {
|
||||
setFetchStatus('error')
|
||||
})
|
||||
}
|
||||
|
||||
const getContent = () => {
|
||||
@ -84,8 +118,17 @@ export default function setup(props: {
|
||||
onMounted(() => {
|
||||
nextTick(() => {
|
||||
getContent()
|
||||
// setFetchStatus('refreshing')
|
||||
// TODO: use a transparent mask (or just a popup) to show: 'refeshing content', when it fails or timeout, show popup. If the postsStore is empty, show BookLoader. In other words, BookLoader should only be displayed when real fetching API.
|
||||
if ((content.value as Post)?.content) {
|
||||
setFetchStatus('cached')
|
||||
const msg = intl.formatMessage({
|
||||
id: 'messages.postContent.cache.found',
|
||||
defaultMessage: 'Fetching the latest post content...',
|
||||
})
|
||||
addMessage({
|
||||
type: 'info',
|
||||
title: msg,
|
||||
})
|
||||
}
|
||||
})
|
||||
fetchContent()
|
||||
})
|
||||
|
52
src/components/link/Link.vue
Normal file
52
src/components/link/Link.vue
Normal file
@ -0,0 +1,52 @@
|
||||
<template>
|
||||
<div v-if="$props.url === null">
|
||||
<slot></slot>
|
||||
</div>
|
||||
<router-link v-else-if="to" :to="to">
|
||||
<slot></slot>
|
||||
</router-link>
|
||||
<a v-else href="https://google.com" target="_blank">
|
||||
<slot></slot>
|
||||
</a>
|
||||
</template>
|
||||
|
||||
<script lang="ts">
|
||||
import { defineComponent, computed } from 'vue'
|
||||
import type { RouteLocationRaw } from 'vue-router'
|
||||
import type { RouterLinkTo } from './types'
|
||||
import linkHandler from '@/utils/linkHandler'
|
||||
|
||||
export default defineComponent({
|
||||
props: {
|
||||
url: String,
|
||||
routerName: String,
|
||||
routerParams: Object,
|
||||
routerPath: String,
|
||||
routerQuery: Object,
|
||||
to: Object,
|
||||
},
|
||||
setup(props) {
|
||||
const to = computed(() => {
|
||||
if (props.to) return props.to as RouteLocationRaw
|
||||
const _to: RouterLinkTo = {}
|
||||
if (props.url && linkHandler.isInternal(props.url || '')) {
|
||||
_to['path'] = linkHandler.internalLinkRouterPath(props.url)
|
||||
return _to
|
||||
} else if (props.routerName || props.routerPath) {
|
||||
if (props.routerName) {
|
||||
_to['name'] = props.routerName
|
||||
} else {
|
||||
_to['path'] = props.routerPath
|
||||
_to['params'] = props.routerParams ?? undefined
|
||||
}
|
||||
_to['query'] = props.routerQuery ?? undefined
|
||||
return _to
|
||||
} else {
|
||||
return false
|
||||
}
|
||||
})
|
||||
|
||||
return { to }
|
||||
},
|
||||
})
|
||||
</script>
|
6
src/components/link/types.ts
Normal file
6
src/components/link/types.ts
Normal file
@ -0,0 +1,6 @@
|
||||
export interface RouterLinkTo {
|
||||
path?: string
|
||||
name?: string
|
||||
params?: { [key: string]: any }
|
||||
query?: { [key: string]: any }
|
||||
}
|
@ -7,7 +7,7 @@
|
||||
:type="index % 2 ? 'normal' : 'reverse'"
|
||||
></PostThumbCardIndex>
|
||||
</div>
|
||||
<div class="loader__wrapper" v-show="fetchStatus === 'fetching'">
|
||||
<div class="loader__wrapper" v-show="fetchStatus === 'pending'">
|
||||
<BookLoader></BookLoader>
|
||||
</div>
|
||||
<div class="last-page__wrapper" v-show="isTheLastPage">no more</div>
|
||||
@ -17,7 +17,14 @@
|
||||
|
||||
<script lang="ts">
|
||||
import { defineComponent, computed, onMounted, Ref } from 'vue'
|
||||
import { useInjector, useState, useElementRef, useReachElementSide } from '@/hooks'
|
||||
import {
|
||||
useInjector,
|
||||
useState,
|
||||
useElementRef,
|
||||
useReachElementSide,
|
||||
useMessage,
|
||||
useIntl,
|
||||
} from '@/hooks'
|
||||
import { posts } from '@/store'
|
||||
import PostThumbCardIndex from '@/components/cards/postThumbCards/PostThumbCardIndex.vue'
|
||||
import BookLoader from '@/components/loader/BookLoader.vue'
|
||||
@ -37,8 +44,10 @@ export default defineComponent({
|
||||
},
|
||||
},
|
||||
setup(props) {
|
||||
const addMessage = useMessage()
|
||||
const intl = useIntl()
|
||||
const [listContainerRef, setListContainerRef] = useElementRef()
|
||||
const [fetchStatus, setFetchStatus] = useState('fetching')
|
||||
const [fetchStatus, setFetchStatus] = useState('inite' as FetchingStatus)
|
||||
const [currentPage, setCurrentPage] = useState(props.page)
|
||||
const [postList, setPostList]: [Ref<Post[]>, (attr: any) => any] = useState([] as Post[])
|
||||
const {
|
||||
@ -66,7 +75,9 @@ export default defineComponent({
|
||||
}
|
||||
|
||||
const fetch = async () => {
|
||||
setFetchStatus('fetching')
|
||||
if (fetchStatus.value !== 'cached') {
|
||||
setFetchStatus('pending')
|
||||
}
|
||||
fetchPost({
|
||||
state: postsStore,
|
||||
namespace: props.namespace,
|
||||
@ -76,10 +87,15 @@ export default defineComponent({
|
||||
context: 'embed',
|
||||
...props.fetchParameters,
|
||||
},
|
||||
}).then(() => {
|
||||
get()
|
||||
setFetchStatus('done')
|
||||
addMessage,
|
||||
})
|
||||
.then(() => {
|
||||
get()
|
||||
setFetchStatus('success')
|
||||
})
|
||||
.catch(() => {
|
||||
setFetchStatus('error')
|
||||
})
|
||||
}
|
||||
|
||||
const next = () => {
|
||||
@ -108,11 +124,21 @@ export default defineComponent({
|
||||
})
|
||||
|
||||
onMounted(() => {
|
||||
// this will only work when set to cache post store
|
||||
window.setTimeout(() => {
|
||||
get()
|
||||
// setFetchStatus('done')
|
||||
// TODO: use a transparent mask (or just a popup) to show: 'refeshing content', when it fails or timeout, show popup. If the postsStore is empty, show BookLoader. In other words, BookLoader should only be displayed when real fetching API.
|
||||
}, 500) // postsStore injection may not be OK when mounted
|
||||
if (postList.value.length > 0) {
|
||||
setFetchStatus('cached')
|
||||
const msg = intl.formatMessage({
|
||||
id: 'messages.postList.cache.found',
|
||||
defaultMessage: 'Fetching the latest post list...',
|
||||
})
|
||||
addMessage({
|
||||
type: 'info',
|
||||
title: msg,
|
||||
})
|
||||
}
|
||||
}, 0) // postsStore injection may not be OK when mounted
|
||||
fetch()
|
||||
})
|
||||
|
||||
|
@ -141,20 +141,20 @@ export default defineComponent({
|
||||
|
||||
$i: 2;
|
||||
@while $i < 6 {
|
||||
$delay: $i * 15 - 30;
|
||||
$delay: $i * 15% - 30%;
|
||||
@keyframes page-#{$i} {
|
||||
#{0 + $delay}% {
|
||||
#{0% + $delay} {
|
||||
transform: rotateY(180deg);
|
||||
opacity: 0;
|
||||
}
|
||||
#{20 + $delay}% {
|
||||
#{20% + $delay} {
|
||||
opacity: 1;
|
||||
}
|
||||
#{35 + $delay}%,
|
||||
#{35% + $delay},
|
||||
100% {
|
||||
opacity: 0;
|
||||
}
|
||||
#{50 + $delay}%,
|
||||
#{50% + $delay},
|
||||
100% {
|
||||
transform: rotateY(0deg);
|
||||
}
|
||||
|
@ -11,14 +11,6 @@
|
||||
<div class="title">
|
||||
<span>{{ $props.message.title }}</span>
|
||||
</div>
|
||||
<div
|
||||
class="detailed"
|
||||
:style="{ height: shouldShowDetail ? `${expandContentHeight}px` : '0px' }"
|
||||
>
|
||||
<div :class="['content', { show: shouldShowDetail }]" :ref="setExpandContentRef">
|
||||
<span>{{ $props.message.detail }}</span>
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
<div
|
||||
v-if="$props.message.detail"
|
||||
@ -32,6 +24,16 @@
|
||||
<i class="fas fa-times-circle"></i>
|
||||
</div>
|
||||
</div>
|
||||
<div class="row__wrapper--detail">
|
||||
<div
|
||||
class="detailed"
|
||||
:style="{ maxHeight: shouldShowDetail ? `${expandContentHeight}px` : '0px' }"
|
||||
>
|
||||
<div class="content" :ref="setExpandContentRef">
|
||||
<span>{{ $props.message.detail }}</span>
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
@ -114,6 +116,7 @@ export default defineComponent({
|
||||
<style lang="scss" scoped>
|
||||
@use "sass:color";
|
||||
@use '@/styles/mixins/polyfills';
|
||||
@use '@/styles/mixins/text';
|
||||
|
||||
.item__container {
|
||||
--text-color: #3c434a;
|
||||
@ -131,13 +134,14 @@ export default defineComponent({
|
||||
&[type='error'] {
|
||||
--highlight-color: #f93154; // danger
|
||||
}
|
||||
width: var(--width);
|
||||
width: var(--msg-width);
|
||||
background: var(--background-color);
|
||||
border-left: 3px solid var(--highlight-color, #757575);
|
||||
> .item__content {
|
||||
width: calc(100% - 24px);
|
||||
padding: 12px;
|
||||
> .flex-box {
|
||||
width: 100%;
|
||||
display: flex;
|
||||
flex-flow: row nowrap;
|
||||
align-items: space-between;
|
||||
@ -157,16 +161,18 @@ export default defineComponent({
|
||||
display: flex;
|
||||
flex-flow: column nowrap;
|
||||
align-items: flex-start;
|
||||
// overflow-wrap: anywhere;
|
||||
> .row__wrapper {
|
||||
&--title {
|
||||
width: 100%;
|
||||
display: flex;
|
||||
flex-flow: row nowrap;
|
||||
justify-content: space-between;
|
||||
align-items: flex-start;
|
||||
@include polyfills.flex-gap(12px, 'row nowrap');
|
||||
width: calc(100% + 12px);
|
||||
> * span {
|
||||
line-height: 16px;
|
||||
@include text.word-break;
|
||||
}
|
||||
> .title__content {
|
||||
&--message {
|
||||
@ -177,35 +183,36 @@ export default defineComponent({
|
||||
color: var(--text-color);
|
||||
}
|
||||
}
|
||||
> .detailed {
|
||||
transition: height 0.5s cubic-bezier(0, 0, 0.3, 1);
|
||||
overflow: hidden;
|
||||
> .content {
|
||||
padding-top: 6px;
|
||||
width: 100%;
|
||||
height: auto;
|
||||
transform: scaleY(0);
|
||||
transform-origin: top;
|
||||
transition: transform 0.5s cubic-bezier(0, 0, 0.3, 1);
|
||||
&.show {
|
||||
transform: scaleY(1);
|
||||
}
|
||||
span {
|
||||
color: var(--text-color-lighter-30);
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
&--collapse {
|
||||
flex: 0 0 auto;
|
||||
transform: scaleY(1);
|
||||
transition: transform 0.5s cubic-bezier(0, 0, 0.3, 1);
|
||||
cursor: pointer;
|
||||
&.reverse {
|
||||
transform: scaleY(-1);
|
||||
}
|
||||
}
|
||||
&--close {
|
||||
flex: 0 0 auto;
|
||||
cursor: pointer;
|
||||
margin-right: 0;
|
||||
}
|
||||
}
|
||||
}
|
||||
&--detail {
|
||||
> .detailed {
|
||||
width: 100%;
|
||||
max-height: 0;
|
||||
transition: max-height 0.3s ease-in-out;
|
||||
overflow: hidden;
|
||||
> .content {
|
||||
padding-top: 6px;
|
||||
width: 100%;
|
||||
span {
|
||||
color: var(--text-color-lighter-30);
|
||||
@include text.word-break;
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
|
@ -20,7 +20,7 @@ export default defineComponent({
|
||||
props: {
|
||||
positionX: { type: String, default: 'right' }, // left center right
|
||||
positionY: { type: String, default: 'top' }, // top bottom
|
||||
width: { type: String, default: '380px' },
|
||||
// width: { type: String, default: '380px' },
|
||||
},
|
||||
setup(props) {
|
||||
const { messageList } = useInjector(messages)
|
||||
@ -43,7 +43,7 @@ export default defineComponent({
|
||||
'--to-70': props.positionX === 'right' ? '100%' : '-100%',
|
||||
'--to-100': props.positionX === 'right' ? '100%' : '-100%',
|
||||
'--absolute-fix': props.positionY === 'bottom' ? '-100%' : '0',
|
||||
'--width': props.width,
|
||||
// '--width': props.width,
|
||||
}
|
||||
})
|
||||
|
||||
@ -54,7 +54,17 @@ export default defineComponent({
|
||||
|
||||
<style lang="scss" scoped>
|
||||
.messages__container {
|
||||
width: calc(var(--width) + 12px);
|
||||
--msg-width: var(--message-width, 380px);
|
||||
@media screen and (max-width: 500px) {
|
||||
--msg-width: var(--message-width, 300px);
|
||||
}
|
||||
@media screen and (max-width: 400px) {
|
||||
--msg-width: var(--message-width, 250px);
|
||||
}
|
||||
@media screen and (max-width: 360px) {
|
||||
--msg-width: var(--message-width, 80vw);
|
||||
}
|
||||
width: calc(var(--msg-width) + 12px);
|
||||
.message__wrapper {
|
||||
padding: 6px;
|
||||
}
|
||||
|
@ -36,7 +36,7 @@ export default defineComponent({
|
||||
.map((item, index) => index === props.result)
|
||||
)
|
||||
|
||||
// TODO: watcher's bug on deep mode: https://github.com/vuejs/vue/issues/2164
|
||||
// watcher's bug on deep mode: https://github.com/vuejs/vue/issues/2164
|
||||
const cacheArrayRef = computed(() => cloneDeep(arrayRef.value))
|
||||
|
||||
watch(
|
||||
@ -76,6 +76,14 @@ export default defineComponent({
|
||||
.map((item, index) => index === props.result))
|
||||
)
|
||||
|
||||
watch(
|
||||
() => props.result,
|
||||
(resultProp) => {
|
||||
arrayRef.value = cloneDeep(arrayRef.value).map((item) => false)
|
||||
if (resultProp !== NaN) arrayRef.value[resultProp] = true
|
||||
}
|
||||
)
|
||||
|
||||
return { arrayRef }
|
||||
},
|
||||
})
|
||||
|
@ -6,7 +6,6 @@
|
||||
role="switch"
|
||||
:aria-checked="checked"
|
||||
:ref="setElRef"
|
||||
@click="handleChange"
|
||||
>
|
||||
<div class="mdc-switch__track"></div>
|
||||
<div class="mdc-switch__handle-track">
|
||||
@ -35,7 +34,7 @@
|
||||
<script lang="ts">
|
||||
import { defineComponent, ref, watch } from 'vue'
|
||||
import uniqueHash from '@/utils/uniqueHash'
|
||||
import { useElementRef } from '@/hooks'
|
||||
import { useElementRef, useIntervalWatcher } from '@/hooks'
|
||||
import useMDCSwitch from '@/hooks/mdc/useMDCSwitch'
|
||||
|
||||
export default defineComponent({
|
||||
@ -54,17 +53,17 @@ export default defineComponent({
|
||||
|
||||
const checked = ref(props.checked)
|
||||
|
||||
const handleChange = () => {
|
||||
if (MDCSwitchRef.value) {
|
||||
checked.value = !MDCSwitchRef.value.selected
|
||||
useIntervalWatcher(() => {
|
||||
if (MDCSwitchRef.value && MDCSwitchRef.value.selected !== checked.value) {
|
||||
checked.value = MDCSwitchRef.value.selected
|
||||
}
|
||||
}
|
||||
}, 100)
|
||||
|
||||
watch(
|
||||
() => props.checked,
|
||||
(value) => {
|
||||
checked.value = value
|
||||
if (MDCSwitchRef.value) MDCSwitchRef.value.selected = !value
|
||||
if (MDCSwitchRef.value) MDCSwitchRef.value.selected = value
|
||||
},
|
||||
{ immediate: true }
|
||||
)
|
||||
@ -87,7 +86,7 @@ export default defineComponent({
|
||||
|
||||
watch(checked, (value) => emit('update:checked', value))
|
||||
|
||||
return { id, setElRef, handleChange, checked }
|
||||
return { id, setElRef, checked }
|
||||
},
|
||||
})
|
||||
</script>
|
||||
|
@ -8,8 +8,11 @@ import useReachElementSide from './useReachElementSide'
|
||||
import { useElementRef, useElementRefs } from './useElementRef'
|
||||
import useOffsetDistance from './useOffsetDistance'
|
||||
import useMDCRipple from './mdc/useMDCRipple'
|
||||
import useMessage from './useMessage'
|
||||
import useMessage, { useCommonMessages } from './useMessage'
|
||||
import useTypewriterEffect from './useTypewriterEffect'
|
||||
import useIntervalWatcher from './useIntervalWatcher'
|
||||
import useKeepAliveWindowScrollTop from './useKeepAliveWindowScrollTop'
|
||||
import useWindowScrollLock from './useWindowScrollLock'
|
||||
|
||||
export {
|
||||
useState,
|
||||
@ -29,5 +32,9 @@ export {
|
||||
useElementRefs,
|
||||
useOffsetDistance,
|
||||
useMessage,
|
||||
useCommonMessages,
|
||||
useTypewriterEffect,
|
||||
useIntervalWatcher,
|
||||
useKeepAliveWindowScrollTop,
|
||||
useWindowScrollLock,
|
||||
}
|
||||
|
18
src/hooks/useIntervalWatcher.ts
Normal file
18
src/hooks/useIntervalWatcher.ts
Normal file
@ -0,0 +1,18 @@
|
||||
import { onMounted, onUnmounted, onActivated, onDeactivated } from 'vue'
|
||||
export default function useIntervalWatcher(func: () => void, interval = 100): void {
|
||||
let timer = NaN
|
||||
const addWatcher = () => {
|
||||
if (timer) return
|
||||
timer = window.setInterval(func, interval)
|
||||
}
|
||||
const removeWatcher = () => {
|
||||
if (!timer) return
|
||||
window.clearInterval(timer)
|
||||
timer = NaN
|
||||
}
|
||||
|
||||
onMounted(() => addWatcher())
|
||||
onActivated(() => addWatcher())
|
||||
onUnmounted(() => removeWatcher())
|
||||
onDeactivated(() => removeWatcher())
|
||||
}
|
27
src/hooks/useKeepAliveWindowScrollTop.ts
Normal file
27
src/hooks/useKeepAliveWindowScrollTop.ts
Normal file
@ -0,0 +1,27 @@
|
||||
import { onDeactivated, watch, onActivated } from 'vue'
|
||||
import { useWindowScroll } from '@vueuse/core'
|
||||
import { useState } from '@/hooks'
|
||||
|
||||
export default function () {
|
||||
const { scrollTop, scrollLeft } = (function () {
|
||||
const { x, y } = useWindowScroll()
|
||||
return { scrollTop: y, scrollLeft: x }
|
||||
})()
|
||||
|
||||
const [scrollTopCache, setScrollTopCache] = useState(0)
|
||||
const [isScrollTopSet, setIsScrollTopSet] = useState(false)
|
||||
|
||||
watch(scrollTop, (value) => {
|
||||
if (!isScrollTopSet.value) return
|
||||
setScrollTopCache(value)
|
||||
})
|
||||
|
||||
onActivated(() => {
|
||||
window.scrollTo(scrollLeft.value ?? 0, scrollTopCache.value)
|
||||
setIsScrollTopSet(true)
|
||||
})
|
||||
|
||||
onDeactivated(() => {
|
||||
setIsScrollTopSet(false)
|
||||
})
|
||||
}
|
@ -1,16 +1,24 @@
|
||||
import type { Ref } from 'vue'
|
||||
import { useInjector } from '@/hooks'
|
||||
import { useInjector, useIntl } from '@/hooks'
|
||||
import { messages } from '@/store'
|
||||
import type { Message, MessageOptions } from '@/store/messages'
|
||||
|
||||
export default function useMessage() {
|
||||
const {
|
||||
messageList,
|
||||
addMessage,
|
||||
}: {
|
||||
messageList: Ref<Message[]>
|
||||
addMessage: (state: Ref<Message[]>, options: MessageOptions) => void
|
||||
} = useInjector(messages)
|
||||
/**
|
||||
* deprecated
|
||||
*/
|
||||
export interface UseMessageInjecter {
|
||||
messageList: Ref<Message[]>
|
||||
addMessage: (state: Ref<Message[]>, options: MessageOptions) => void
|
||||
}
|
||||
|
||||
/**
|
||||
* @param useMessageInjecter (deprecated)
|
||||
* @returns
|
||||
*/
|
||||
export default function useMessage(useMessageInjecter?: UseMessageInjecter) {
|
||||
const { messageList, addMessage }: UseMessageInjecter = useMessageInjecter
|
||||
? useMessageInjecter
|
||||
: useInjector(messages)
|
||||
|
||||
const _addMessage = (options: MessageOptions) => {
|
||||
addMessage(messageList, options)
|
||||
@ -18,3 +26,13 @@ export default function useMessage() {
|
||||
|
||||
return _addMessage
|
||||
}
|
||||
|
||||
export const useCommonMessages = () => {
|
||||
const intl = useIntl()
|
||||
return {
|
||||
javascriptErrorTitle: intl.formatMessage({
|
||||
id: 'messages.commonMessages.javascriptErrorTitle',
|
||||
defaultMessage: 'Opps, something when wrong!',
|
||||
}),
|
||||
}
|
||||
}
|
||||
|
29
src/hooks/useWindowScrollLock.ts
Normal file
29
src/hooks/useWindowScrollLock.ts
Normal file
@ -0,0 +1,29 @@
|
||||
import { onUnmounted, onDeactivated } from 'vue'
|
||||
import getScrollbarWidth from '@/utils/getScrollbarWidth'
|
||||
export default function () {
|
||||
const removeScrollLock = () => {
|
||||
const body = document.querySelector('body')
|
||||
// TODO: add a fake scroll bar element
|
||||
if (body instanceof HTMLElement) {
|
||||
body.style.overflow = 'auto'
|
||||
body.style.width = '100%'
|
||||
}
|
||||
}
|
||||
const addScrollLock = () => {
|
||||
const body = document.querySelector('body')
|
||||
if (body instanceof HTMLElement) {
|
||||
body.style.overflow = 'hidden'
|
||||
body.style.width = `calc(100% - ${String(getScrollbarWidth())}px)`
|
||||
}
|
||||
}
|
||||
|
||||
onUnmounted(() => {
|
||||
removeScrollLock()
|
||||
})
|
||||
|
||||
onDeactivated(() => {
|
||||
removeScrollLock()
|
||||
})
|
||||
|
||||
return [removeScrollLock, addScrollLock]
|
||||
}
|
@ -36,9 +36,13 @@
|
||||
|
||||
<script lang="ts">
|
||||
import { defineComponent, computed, onUnmounted, onDeactivated } from 'vue'
|
||||
import { throttle, xor } from 'lodash'
|
||||
import { useState, useWindowResize } from '@/hooks'
|
||||
import getScrollbarWidth from '@/utils/getScrollbarWidth'
|
||||
import { throttle } from 'lodash'
|
||||
import {
|
||||
useState,
|
||||
useWindowResize,
|
||||
useKeepAliveWindowScrollTop,
|
||||
useWindowScrollLock,
|
||||
} from '@/hooks'
|
||||
import Header from '@/layouts/components/header/Header.vue'
|
||||
import Footer from '@/layouts/components/footer/Footer.vue'
|
||||
import HeaderMobile from '@/layouts/components/header/HeaderMobile.vue'
|
||||
@ -49,25 +53,13 @@ export default defineComponent({
|
||||
components: { Header, Footer, HeaderMobile, NavDrawer },
|
||||
props: { headerPlaceholder: { type: Boolean, default: true } },
|
||||
setup() {
|
||||
useKeepAliveWindowScrollTop()
|
||||
const windowSize = useWindowResize()
|
||||
const isMobile = computed(() => windowSize.value.innerWidth <= 600)
|
||||
const [shouldDrawerOpen, setShouldDrawerOpen] = useState(false)
|
||||
|
||||
const removeScrollLock = () => {
|
||||
const body = document.querySelector('body')
|
||||
// TODO: add a fake scroll bar element
|
||||
if (body instanceof HTMLElement) {
|
||||
body.style.overflow = 'auto'
|
||||
body.style.width = '100%'
|
||||
}
|
||||
}
|
||||
const addScrollLock = () => {
|
||||
const body = document.querySelector('body')
|
||||
if (body instanceof HTMLElement) {
|
||||
body.style.overflow = 'hidden'
|
||||
body.style.width = `calc(100% - ${String(getScrollbarWidth())}px)`
|
||||
}
|
||||
}
|
||||
const [removeScrollLock, addScrollLock] = useWindowScrollLock()
|
||||
|
||||
const toggleDrawer = throttle(
|
||||
() => {
|
||||
setShouldDrawerOpen(!shouldDrawerOpen.value)
|
||||
@ -76,14 +68,6 @@ export default defineComponent({
|
||||
} else {
|
||||
removeScrollLock()
|
||||
}
|
||||
// const body = document.querySelector('body')
|
||||
// if (body instanceof HTMLElement) {
|
||||
// body.style.overflow = xor(['hidden', 'auto'], [body.style.overflow])[0]
|
||||
// body.style.width = xor(
|
||||
// [`calc(100% - ${String(getScrollbarWidth())}px)`, '100%'],
|
||||
// [body.style.width]
|
||||
// )[0]
|
||||
// }
|
||||
},
|
||||
500,
|
||||
{
|
||||
@ -101,12 +85,10 @@ export default defineComponent({
|
||||
|
||||
onUnmounted(() => {
|
||||
setShouldDrawerOpen(false)
|
||||
removeScrollLock()
|
||||
})
|
||||
|
||||
onDeactivated(() => {
|
||||
setShouldDrawerOpen(false)
|
||||
removeScrollLock()
|
||||
})
|
||||
|
||||
return { isMobile, handleMDrawerToggleEvent, shouldDrawerOpen, handleClickFakeAfterEvent }
|
||||
@ -115,6 +97,11 @@ export default defineComponent({
|
||||
</script>
|
||||
|
||||
<style lang="scss" scoped>
|
||||
@use '@/styles/app';
|
||||
::v-deep() {
|
||||
@include app.global;
|
||||
}
|
||||
|
||||
$drawer-width: 260px;
|
||||
.page {
|
||||
position: relative;
|
||||
|
@ -29,7 +29,7 @@
|
||||
<NavItem
|
||||
:context="parent.title"
|
||||
:prefix="parent.icon"
|
||||
:url="parent.child.length > 0 ? '' : parent.url"
|
||||
:url="parent.child.length > 0 ? null : parent.url"
|
||||
:suffix="parent.child.length > 0 ? 'fas fa-chevron-down' : ''"
|
||||
></NavItem>
|
||||
</div>
|
||||
@ -175,6 +175,18 @@ export default defineComponent({
|
||||
height: 36px;
|
||||
background: rgba(2, 1, 1, 0);
|
||||
transition: all 0.3s;
|
||||
::v-deep() {
|
||||
.nav-item__content {
|
||||
.icon--suffix {
|
||||
transform: scale(0.6);
|
||||
transform-origin: right;
|
||||
i {
|
||||
transform: rotate(0deg);
|
||||
transition: all 0.2s;
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
&--child {
|
||||
max-height: 0;
|
||||
@ -193,6 +205,15 @@ export default defineComponent({
|
||||
.ul__content {
|
||||
&--tag {
|
||||
background: rgba(2, 1, 1, 0.05);
|
||||
::v-deep() {
|
||||
.nav-item__content {
|
||||
.icon--suffix {
|
||||
i {
|
||||
transform: rotate(-180deg);
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
&--child {
|
||||
max-height: var(--collapse-height);
|
||||
|
@ -1,16 +1,18 @@
|
||||
<template>
|
||||
<div class="nav-item__container mdc-list-item" :ref="setContainerRef" @click="handleClickEvent">
|
||||
<div class="mdc-list-item__ripple"></div>
|
||||
<span class="nav-item__content mdc-list-item__text">
|
||||
<span class="icon icon--prefix" v-if="prefix">
|
||||
<i :class="prefix"></i>
|
||||
<Link class="link__container" :url="$props.url">
|
||||
<div class="nav-item__container mdc-list-item" :ref="setContainerRef">
|
||||
<div class="mdc-list-item__ripple"></div>
|
||||
<span class="nav-item__content mdc-list-item__text">
|
||||
<span class="icon icon--prefix" v-if="prefix">
|
||||
<i :class="prefix"></i>
|
||||
</span>
|
||||
<span class="context">{{ context }}</span>
|
||||
<span class="icon icon--suffix" v-if="suffix">
|
||||
<i :class="suffix"></i>
|
||||
</span>
|
||||
</span>
|
||||
<span class="context">{{ context }}</span>
|
||||
<span class="icon icon--suffix" v-if="suffix">
|
||||
<i :class="suffix"></i>
|
||||
</span>
|
||||
</span>
|
||||
</div>
|
||||
</div>
|
||||
</Link>
|
||||
</template>
|
||||
|
||||
<script lang="ts">
|
||||
@ -44,34 +46,38 @@ export default defineComponent({
|
||||
</script>
|
||||
|
||||
<style lang="scss" scoped>
|
||||
.nav-item__container {
|
||||
.link__container {
|
||||
height: 100%;
|
||||
width: 100%;
|
||||
cursor: pointer;
|
||||
position: relative;
|
||||
&.mdc-list-item {
|
||||
padding-left: 0;
|
||||
padding-right: 0;
|
||||
}
|
||||
.nav-item__content {
|
||||
width: 100%;
|
||||
.nav-item__container {
|
||||
height: 100%;
|
||||
display: flex;
|
||||
flex-flow: row nowrap;
|
||||
justify-content: center;
|
||||
align-items: center;
|
||||
padding: 0 24px;
|
||||
span {
|
||||
color: #5f6368;
|
||||
font-weight: 500;
|
||||
white-space: nowrap;
|
||||
width: 100%;
|
||||
cursor: pointer;
|
||||
position: relative;
|
||||
&.mdc-list-item {
|
||||
padding-left: 0;
|
||||
padding-right: 0;
|
||||
}
|
||||
.icon {
|
||||
&--prefix {
|
||||
padding-right: 12px;
|
||||
.nav-item__content {
|
||||
width: 100%;
|
||||
height: 100%;
|
||||
display: flex;
|
||||
flex-flow: row nowrap;
|
||||
justify-content: center;
|
||||
align-items: center;
|
||||
padding: 0 24px;
|
||||
span {
|
||||
color: #5f6368;
|
||||
font-weight: 500;
|
||||
white-space: nowrap;
|
||||
}
|
||||
&--suffix {
|
||||
padding-left: 12px;
|
||||
.icon {
|
||||
&--prefix {
|
||||
padding-right: 12px;
|
||||
}
|
||||
&--suffix {
|
||||
padding-left: 12px;
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
|
@ -9,6 +9,7 @@ import { auth, init, posts, comments, messages } from './store'
|
||||
import { intlPlugin } from './locales'
|
||||
import UiIcon from '@/components/icon/UiIcon.vue'
|
||||
import Image from '@/components/image/Image.vue'
|
||||
import Link from '@/components/link/Link.vue'
|
||||
|
||||
const theWindow = window as any
|
||||
theWindow.router = router
|
||||
@ -20,4 +21,5 @@ app.use(intlPlugin)
|
||||
app.use(VueSvgIconPlugin, { tagName: 'svg-icon' })
|
||||
app.component('UiIcon', UiIcon)
|
||||
app.component('Image', Image)
|
||||
app.component('Link', Link)
|
||||
app.mount('#app')
|
||||
|
@ -15,8 +15,9 @@ interface FetchParams {
|
||||
|
||||
export default function comments(): object {
|
||||
const defaultCommentStore: CommentStore = {}
|
||||
const [commentStore, setCommentStore]: [Ref<CommentStore>, (arg: CommentStore) => void] =
|
||||
usePersistedState('commentStore', defaultCommentStore)
|
||||
const [commentStore, setCommentStore] = false
|
||||
? usePersistedState('commentStore', defaultCommentStore)
|
||||
: useState(defaultCommentStore)
|
||||
|
||||
const resHandler = (
|
||||
state: FetchParams['state'],
|
||||
|
@ -1,4 +1,3 @@
|
||||
import { Ref } from 'vue'
|
||||
import { cloneDeep, remove } from 'lodash'
|
||||
import { useState } from '@/hooks'
|
||||
import uniqueHash from '@/utils/uniqueHash'
|
||||
@ -31,7 +30,7 @@ export default function msg(): object {
|
||||
return
|
||||
}
|
||||
|
||||
const closeTimeout = options.closeTimeout || 3000
|
||||
const closeTimeout = options.closeTimeout || 6000
|
||||
|
||||
setTimeout(() => removeMessage(state, id), closeTimeout)
|
||||
}
|
||||
|
@ -1,5 +1,6 @@
|
||||
import { Ref } from 'vue'
|
||||
import type { Ref } from 'vue'
|
||||
import { usePersistedState, useState } from '@/hooks'
|
||||
import type { MessageOptions } from '@/store/messages'
|
||||
import camelcaseKeys from 'camelcase-keys'
|
||||
import { AxiosResponse } from 'axios' // interface
|
||||
import { cloneDeep } from 'lodash'
|
||||
@ -7,11 +8,14 @@ import API from '@/api'
|
||||
import { GetPostParams, GetPageParams } from '@/api/Wp/v2' // interface
|
||||
import { getPagination } from '@/utils/filters/paginationFilter'
|
||||
import logger from '@/utils/logger'
|
||||
import axiosErrorHandler from '@/utils/axiosErrorHandler'
|
||||
import intl from '@/locales'
|
||||
|
||||
interface FetchParams {
|
||||
export interface FetchParams {
|
||||
state: Ref<PostStore>
|
||||
namespace: string
|
||||
opts: GetPostParams | GetPageParams
|
||||
addMessage: (options: MessageOptions) => void
|
||||
}
|
||||
|
||||
export default function posts(): object {
|
||||
@ -24,7 +28,9 @@ export default function posts(): object {
|
||||
data: {},
|
||||
list: {},
|
||||
}
|
||||
const [postsStore, setPostsStore] = usePersistedState('postsStore', defaultStore)
|
||||
const [postsStore, setPostsStore] = false
|
||||
? usePersistedState('postsStore', defaultStore)
|
||||
: useState(defaultStore)
|
||||
|
||||
/**
|
||||
* Common method of handling API response of Array(WP_POST)
|
||||
@ -94,11 +100,8 @@ export default function posts(): object {
|
||||
/**
|
||||
* Fetch posts list from API /wp-json/wp/v2/posts
|
||||
* TODO: what's the correct type of readonly (state)?
|
||||
* @param state
|
||||
* @param type
|
||||
* @param axiosOptions
|
||||
*/
|
||||
const fetchPost = async ({ state, namespace, opts }: FetchParams) => {
|
||||
const fetchPost = async ({ state, namespace, opts, addMessage }: FetchParams) => {
|
||||
return new Promise((resolve, reject) => {
|
||||
API.Wp.v2
|
||||
.getPosts(opts as GetPostParams)
|
||||
@ -108,6 +111,12 @@ export default function posts(): object {
|
||||
})
|
||||
.catch((error) => {
|
||||
logger('error', error)
|
||||
const errorMsgTitle = intl.formatMessage({
|
||||
id: 'messages.posts.fetchPostError',
|
||||
defaultMessage: 'Failed to fetch post content.',
|
||||
})
|
||||
const errorMsg = axiosErrorHandler(error).msg
|
||||
addMessage({ type: 'error', title: errorMsgTitle, detail: errorMsg, closeTimeout: 0 })
|
||||
reject(error)
|
||||
})
|
||||
})
|
||||
@ -116,11 +125,8 @@ export default function posts(): object {
|
||||
/**
|
||||
* Fetch posts list from API /wp-json/wp/v2/posts
|
||||
* TODO: what's the correct type of readonly (state)?
|
||||
* @param state
|
||||
* @param type
|
||||
* @param axiosOptions
|
||||
*/
|
||||
const fetchPage = async ({ state, namespace, opts }: FetchParams) => {
|
||||
const fetchPage = async ({ state, namespace, opts, addMessage }: FetchParams) => {
|
||||
return new Promise((resolve, reject) => {
|
||||
API.Wp.v2
|
||||
.getPages(opts as GetPageParams)
|
||||
@ -130,6 +136,12 @@ export default function posts(): object {
|
||||
})
|
||||
.catch((error) => {
|
||||
logger('error', error)
|
||||
const errorMsgTitle = intl.formatMessage({
|
||||
id: 'messages.posts.fetchPageError',
|
||||
defaultMessage: 'Failed to fetch page content.',
|
||||
})
|
||||
const errorMsg = axiosErrorHandler(error).msg
|
||||
addMessage({ type: 'error', title: errorMsgTitle, detail: errorMsg, closeTimeout: 0 })
|
||||
reject(error)
|
||||
})
|
||||
})
|
||||
|
9
src/styles/_app.scss
Normal file
9
src/styles/_app.scss
Normal file
@ -0,0 +1,9 @@
|
||||
@mixin global {
|
||||
a,
|
||||
a:hover,
|
||||
a:focus,
|
||||
a:active {
|
||||
text-decoration: none;
|
||||
color: inherit;
|
||||
}
|
||||
}
|
@ -35,3 +35,41 @@
|
||||
@error "The second paramater $flex-flow is set to be '#{$flex-flow}', which is illegal.";
|
||||
}
|
||||
}
|
||||
|
||||
@mixin _flex-gap-unset($row: true) {
|
||||
$margin: 0;
|
||||
$transform: 0;
|
||||
@if $row {
|
||||
margin-left: $transform;
|
||||
margin-right: $transform;
|
||||
} @else {
|
||||
margin-top: $transform;
|
||||
margin-bottom: $transform;
|
||||
}
|
||||
> * {
|
||||
@if $row {
|
||||
margin-left: $margin;
|
||||
margin-right: $margin;
|
||||
} @else {
|
||||
margin-top: $margin;
|
||||
margin-bottom: $margin;
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
// unset flex-gap, used in @media screen width rules
|
||||
@mixin flex-gap-unset($flex-flow: 'row nowrap') {
|
||||
@if $flex-flow== 'row nowrap' or $flex-flow== 'row-reverse nowrap' {
|
||||
@include _flex-gap-unset(true);
|
||||
} @else if $flex-flow== 'column nowrap' or $flex-flow== 'column-reverse nowrap' {
|
||||
@include _flex-gap-unset(false);
|
||||
} @else if $flex-flow== 'row wrap' or $flex-flow== 'row-reverse wrap' {
|
||||
@include _flex-gap-unset(true);
|
||||
@include _flex-gap-unset(false);
|
||||
} @else if $flex-flow== 'column wrap' or $flex-flow== 'column-reverse wrap' {
|
||||
@include _flex-gap-unset(true);
|
||||
@include _flex-gap-unset(false);
|
||||
} @else {
|
||||
@error "The second paramater $flex-flow is set to be '#{$flex-flow}', which is illegal.";
|
||||
}
|
||||
}
|
||||
|
1
src/styles/mixins/_sizes.scss
Normal file
1
src/styles/mixins/_sizes.scss
Normal file
@ -0,0 +1 @@
|
||||
$post-main-content-max-width: 800px;
|
@ -11,3 +11,8 @@
|
||||
padding: 10px 10px;
|
||||
transform: translate(-10px, 10px);
|
||||
}
|
||||
|
||||
@mixin word-break {
|
||||
overflow: hidden;
|
||||
word-break: break-word;
|
||||
}
|
||||
|
13
src/utils/axiosErrorHandler.ts
Normal file
13
src/utils/axiosErrorHandler.ts
Normal file
@ -0,0 +1,13 @@
|
||||
export default function (error: any) {
|
||||
if (error.response) {
|
||||
return {
|
||||
type: 'Response Error',
|
||||
msg: error.response.data.message ?? error.response.data, // Standard WP_Rest_Error
|
||||
}
|
||||
} else {
|
||||
return {
|
||||
type: 'Request Error',
|
||||
msg: error.message, // eg. network error
|
||||
}
|
||||
}
|
||||
}
|
@ -1,5 +1,4 @@
|
||||
import intl from '@/locales'
|
||||
import timeFormater from '@/utils/timeFormater'
|
||||
import htmlStringInnerText from '@/utils/htmlStringInnerText'
|
||||
import camelcaseKeys from 'camelcase-keys'
|
||||
import publishTime from './publishTime'
|
||||
@ -9,6 +8,7 @@ export default function (post: Post, type: 'single' | 'page' | 'thumbList') {
|
||||
const title = post.title.rendered
|
||||
|
||||
const publistTime = publishTime(post.date)
|
||||
const publistTimeBrief = publishTime(post.date, true)
|
||||
|
||||
const readCount = intl.formatMessage(
|
||||
{
|
||||
@ -55,9 +55,12 @@ export default function (post: Post, type: 'single' | 'page' | 'thumbList') {
|
||||
const content = post.content ?? ''
|
||||
const link = post.link
|
||||
|
||||
const tags = post.tagsMeta ? camelcaseKeys(post.tagsMeta) : []
|
||||
|
||||
const data = {
|
||||
id,
|
||||
publistTime,
|
||||
publistTimeBrief,
|
||||
title,
|
||||
readCount,
|
||||
commentCount,
|
||||
@ -67,6 +70,7 @@ export default function (post: Post, type: 'single' | 'page' | 'thumbList') {
|
||||
author,
|
||||
content,
|
||||
link,
|
||||
tags,
|
||||
}
|
||||
|
||||
return data
|
||||
|
@ -1,22 +1,39 @@
|
||||
import intl from '@/locales'
|
||||
import timeFormater from '@/utils/timeFormater'
|
||||
|
||||
export default function (publishTime: string) {
|
||||
export default function (publishTime: string, brief = false) {
|
||||
const publistTimeDate = new timeFormater(publishTime)
|
||||
const publistTime = publistTimeDate.moreThanOneYear()
|
||||
? intl.formatMessage(
|
||||
{
|
||||
id: 'posts.postTimeOn',
|
||||
defaultMessage: 'Post on {publistTimeDate, date, long}',
|
||||
},
|
||||
{ publistTimeDate: publistTimeDate.getDate() }
|
||||
)
|
||||
: intl.formatMessage(
|
||||
{
|
||||
id: 'posts.postTimeSince',
|
||||
defaultMessage: 'Post {duration} ago',
|
||||
},
|
||||
{ duration: publistTimeDate.getReadableTimeFromNow() }
|
||||
)
|
||||
return publistTime
|
||||
if (brief) {
|
||||
return publistTimeDate.moreThanOneYear()
|
||||
? intl.formatMessage(
|
||||
{
|
||||
id: 'posts.postTimeOn.brief',
|
||||
defaultMessage: '{publistTimeDate, date, long}',
|
||||
},
|
||||
{ publistTimeDate: publistTimeDate.getDate() }
|
||||
)
|
||||
: intl.formatMessage(
|
||||
{
|
||||
id: 'posts.postTimeSince',
|
||||
defaultMessage: '{duration} ago',
|
||||
},
|
||||
{ duration: publistTimeDate.getReadableTimeFromNowBrief() }
|
||||
)
|
||||
} else {
|
||||
return publistTimeDate.moreThanOneYear()
|
||||
? intl.formatMessage(
|
||||
{
|
||||
id: 'posts.postTimeOn.full',
|
||||
defaultMessage: 'Post on {publistTimeDate, date, long}',
|
||||
},
|
||||
{ publistTimeDate: publistTimeDate.getDate() }
|
||||
)
|
||||
: intl.formatMessage(
|
||||
{
|
||||
id: 'posts.postTimeSince',
|
||||
defaultMessage: 'Post {duration} ago',
|
||||
},
|
||||
{ duration: publistTimeDate.getReadableTimeFromNow() }
|
||||
)
|
||||
}
|
||||
}
|
||||
|
@ -1,4 +1,4 @@
|
||||
import { Router } from 'vue-router'
|
||||
import type { Router } from 'vue-router'
|
||||
import logger from './logger'
|
||||
|
||||
export default class linkHandler {
|
||||
@ -20,7 +20,8 @@ export default class linkHandler {
|
||||
|
||||
public static internalLinkRouterPath(url: string) {
|
||||
if (this.isInternal(url)) {
|
||||
const { pathname, search, hash } = this.urlParser(url)
|
||||
const parsed = this.urlParser(url)
|
||||
const { pathname, search, hash } = this.urlParser(parsed.href)
|
||||
return pathname + search + hash
|
||||
} else {
|
||||
throw new Error('Not internal link')
|
||||
@ -38,10 +39,9 @@ export default class linkHandler {
|
||||
}) {
|
||||
logger('log', 'linkHandler: ', url)
|
||||
if (this.isInternal(url)) {
|
||||
const parsed = this.urlParser(url)
|
||||
// TODO: why not import? cause vue codes cannot pass the jest test...
|
||||
// router = router ?? ((window as any).router as Router)
|
||||
router.push(this.internalLinkRouterPath(parsed.href))
|
||||
router.push(this.internalLinkRouterPath(url))
|
||||
// window.setTimeout(() => (window.location.hash = parsed.hash))
|
||||
} else {
|
||||
console.log('open: ', this.urlParser(url).href)
|
||||
|
@ -15,7 +15,7 @@ export default class timeFormater {
|
||||
this.timestampFromNow = this.now - this.timestamp
|
||||
}
|
||||
|
||||
public getReadableTimeFromNow() {
|
||||
public getTimeFromNow() {
|
||||
const gap = this.timestampFromNow
|
||||
let num: number = 0
|
||||
let unit: Unit = 'second'
|
||||
@ -38,9 +38,20 @@ export default class timeFormater {
|
||||
num = gap / (365 * 24 * 60 * 60 * 1000)
|
||||
unit = 'year'
|
||||
}
|
||||
return { num, unit }
|
||||
}
|
||||
|
||||
public getReadableTimeFromNow() {
|
||||
const { num, unit } = this.getTimeFromNow()
|
||||
return intl.formatRelativeTime(Math.floor(num), unit, { style: 'narrow' })
|
||||
}
|
||||
|
||||
public getReadableTimeFromNowBrief() {
|
||||
const { num, unit } = this.getTimeFromNow()
|
||||
const _num = Math.floor(num)
|
||||
return timeFormater.commonUnites(_num)[unit]
|
||||
}
|
||||
|
||||
public getFormatTime(
|
||||
opts: FormatDateOptions = { year: 'numeric', month: 'numeric', day: 'numeric' }
|
||||
) {
|
||||
@ -60,4 +71,56 @@ export default class timeFormater {
|
||||
public getDate() {
|
||||
return this.date
|
||||
}
|
||||
|
||||
public static commonUnites = (num: number) => {
|
||||
const year = intl.formatMessage(
|
||||
{
|
||||
id: 'app.common.units.year',
|
||||
defaultMessage:
|
||||
'{num, plural, =0 {just now} =1 {1 year} other {{num, number, ::compact-short} years}}',
|
||||
},
|
||||
{ num }
|
||||
)
|
||||
const month = intl.formatMessage(
|
||||
{
|
||||
id: 'app.common.units.year',
|
||||
defaultMessage:
|
||||
'{num, plural, =0 {just now} =1 {1 month} other {{num, number, ::compact-short} monthes}}',
|
||||
},
|
||||
{ num }
|
||||
)
|
||||
const day = intl.formatMessage(
|
||||
{
|
||||
id: 'app.common.units.year',
|
||||
defaultMessage:
|
||||
'{num, plural, =0 {just now} =1 {1 day} other {{num, number, ::compact-short} days}}',
|
||||
},
|
||||
{ num }
|
||||
)
|
||||
const hour = intl.formatMessage(
|
||||
{
|
||||
id: 'app.common.units.year',
|
||||
defaultMessage:
|
||||
'{num, plural, =0 {just now} =1 {1 hour} other {{num, number, ::compact-short} hours}}',
|
||||
},
|
||||
{ num }
|
||||
)
|
||||
const minute = intl.formatMessage(
|
||||
{
|
||||
id: 'app.common.units.year',
|
||||
defaultMessage:
|
||||
'{num, plural, =0 {just now} =1 {1 minute} other {{num, number, ::compact-short} minutes}}',
|
||||
},
|
||||
{ num }
|
||||
)
|
||||
const second = intl.formatMessage(
|
||||
{
|
||||
id: 'app.common.units.year',
|
||||
defaultMessage:
|
||||
'{num, plural, =0 {just now} =1 {1 second} other {{num, number, ::compact-short} seconds}}',
|
||||
},
|
||||
{ num }
|
||||
)
|
||||
return { year, month, day, hour, minute, second }
|
||||
}
|
||||
}
|
||||
|
Loading…
Reference in New Issue
Block a user