Add mobile compatibility

This commit is contained in:
mashirozx 2021-07-29 17:41:40 +08:00
parent 214689a41b
commit d82fc5fb99
48 changed files with 911 additions and 403 deletions

View File

@ -100,7 +100,7 @@ class OptionController extends BaseController
if ($hasNoDiff) { if ($hasNoDiff) {
return [ return [
'code' => 'save_config_succeed', '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) { if (!$config) {
return new WP_Error( return new WP_Error(
'save_config_failure', 'save_config_failure',
__('Unable to save configuration.', self::$text_domain), __('Unable to save the configuration.', self::$text_domain),
array('status' => 500) array('status' => 500)
); );
} else { } else {
return [ return [
'code' => 'save_config_succeed', 'code' => 'save_config_succeed',
'message' => __('Configurations saved successfully.', self::$text_domain), 'message' => __('Configuration saved successfully.', self::$text_domain),
]; ];
} }
} }

View File

@ -5,7 +5,7 @@
define('SAKURA_VERSION', wp_get_theme()->get('Version')); define('SAKURA_VERSION', wp_get_theme()->get('Version'));
define('SAKURA_TEXT_DOMAIN', wp_get_theme()->get('TextDomain')); 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'); define('SAKURA_DEVEPLOMENT_HOST', 'http://127.0.0.1:9000');
// PHP loaders // PHP loaders

View File

@ -53,7 +53,20 @@ interface WPPostAbstract {
categories: [number?] categories: [number?]
categoriesMeta: { [key: string]: any } categoriesMeta: { [key: string]: any }
tags: [number?] 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 commentCount: number
viewCount: number viewCount: number
wordsCount: number wordsCount: number
@ -117,3 +130,5 @@ interface CommentStore {
pagination: Pagination pagination: Pagination
} }
} }
declare type FetchingStatus = 'inite' | 'cached' | 'pending' | 'success' | 'error' | 'empty'

View File

@ -4,15 +4,19 @@
<component :is="Component" :key="$route.fullPath"></component> <component :is="Component" :key="$route.fullPath"></component>
</keep-alive> </keep-alive>
</router-view> </router-view>
<div class="messages__wrapper">
<Messages position-y="bottom" position-x="left"></Messages>
</div>
</template> </template>
<script lang="ts"> <script lang="ts">
import { defineComponent } from 'vue' import { defineComponent } from 'vue'
import { init } from '@/store' import { init } from '@/store'
import { useInjector } from '@/hooks' import { useInjector } from '@/hooks'
import Messages from '@/components/messages/Messages.vue'
export default defineComponent({ export default defineComponent({
name: 'App', components: { Messages },
setup() { setup() {
const { fetchWpJson } = useInjector(init) const { fetchWpJson } = useInjector(init)
fetchWpJson() fetchWpJson()
@ -21,5 +25,12 @@ export default defineComponent({
</script> </script>
<style lang="scss"> <style lang="scss">
@use '@/styles/index'; @use '@/styles/global';
.messages__wrapper {
position: fixed;
bottom: 0;
left: 0;
z-index: 999999;
}
</style> </style>

View File

@ -47,10 +47,10 @@
</template> </template>
<script lang="ts"> <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, SwiperSlide } from 'swiper/vue'
import { Swiper as SwiperInterface } from 'swiper' import { Swiper as SwiperInterface } from 'swiper'
import { useInjector, useState, useMessage } from '@/hooks' import { useInjector, useState, useMessage, useIntervalWatcher } from '@/hooks'
import store from './store' import store from './store'
import options from './options' import options from './options'
import type { Option } from './options' import type { Option } from './options'
@ -90,11 +90,7 @@ export default defineComponent({
const updateAutoHeight = (timeout = 0) => swiperRef.value?.updateAutoHeight(timeout) const updateAutoHeight = (timeout = 0) => swiperRef.value?.updateAutoHeight(timeout)
// auto update height useIntervalWatcher(() => updateAutoHeight(100), 100)
onMounted(() => {
const timer = setInterval(() => updateAutoHeight(100), 100)
onBeforeUnmount(() => clearInterval(timer))
})
// messages // messages
const addMessage = useMessage() const addMessage = useMessage()
@ -213,7 +209,7 @@ export default defineComponent({
flex-flow: row wrap; flex-flow: row wrap;
justify-content: flex-start; justify-content: flex-start;
align-items: center; align-items: center;
padding: 12px; padding: 0 12px 12px 12px;
width: calc(100% - 24px); width: calc(100% - 24px);
@include polyfills.flex-gap(12px, 'row wrap'); @include polyfills.flex-gap(12px, 'row wrap');
} }

View File

@ -1,6 +1,11 @@
<template> <template>
<div class="option__container"> <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="column__wrapper--main">
<div class="row__wrapper--option"> <div class="row__wrapper--option">
<OutlinedInput <OutlinedInput
@ -43,7 +48,8 @@
<script lang="ts"> <script lang="ts">
import { defineComponent, ref, watch } from 'vue' import { defineComponent, ref, watch } from 'vue'
import { useInjector } from '@/hooks' import { cloneDeep } from 'lodash'
import { useInjector, useIntl } from '@/hooks'
import store from './store' import store from './store'
import validator from './validator' import validator from './validator'
import OutlinedInput from '@/components/inputs/OutlinedInput.vue' import OutlinedInput from '@/components/inputs/OutlinedInput.vue'
@ -60,9 +66,16 @@ export default defineComponent({
}, },
emits: [], emits: [],
setup(props, { emit }) { 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 { namespace, type, title, desc, binds } = props.option
const { config, updateOption } = useInjector(store) 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( watch(
optionResultRef, optionResultRef,
@ -74,7 +87,11 @@ export default defineComponent({
{ immediate: true, deep: true } { 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> </script>
@ -96,6 +113,19 @@ export default defineComponent({
flex: 0 0 auto; flex: 0 0 auto;
width: 200px; width: 200px;
padding-top: 15px; 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 { &--main {
width: 100%; width: 100%;
@ -112,6 +142,7 @@ export default defineComponent({
&--desc { &--desc {
font-size: 14px; font-size: 14px;
color: #646970; color: #646970;
// margin-block-end: 0;
} }
} }
} }

View File

@ -139,14 +139,21 @@ export default defineComponent({
(value) => { (value) => {
if (!props.multiple && value.length > 1) { if (!props.multiple && value.length > 1) {
selection.value = selection.value.slice(-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) emit('update:selection', selection.value)
}, },
{ deep: true } { deep: true }
) )
watch(
() => props.selection as { id: number; url: string }[],
(selectionProp) => {
selection.value = selectionProp
}
)
return { open, add, del, userInput, selection } return { open, add, del, userInput, selection }
}, },
}) })
@ -183,9 +190,6 @@ export default defineComponent({
flex-flow: row wrap; flex-flow: row wrap;
justify-content: flex-start; justify-content: flex-start;
@include polyfills.flex-gap(12px, 'row wrap'); @include polyfills.flex-gap(12px, 'row wrap');
> .input__wrapper {
flex: 0 0 auto;
}
} }
} }
&--preview { &--preview {

View File

@ -1,9 +1,9 @@
import { Ref } from 'vue' import { Ref } from 'vue'
import { useState } from '@/hooks' import { useState } from '@/hooks'
import API from '@/api' // import API from '@/api'
import camelcaseKeys from 'camelcase-keys' // import camelcaseKeys from 'camelcase-keys'
import intl from '@/locales' // import intl from '@/locales'
import options, { Options } from './options' // import options, { Options } from './options'
import { cloneDeep } from 'lodash' import { cloneDeep } from 'lodash'
export interface OptionStore { export interface OptionStore {

View File

@ -2,7 +2,8 @@
<div class="card__container mdc-card mdc-elevation--z8" :type="$props.type" v-if="data"> <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="card__content mdc-card__primary-action" :ref="setContentRef">
<div class="ripple__mask mdc-card__ripple"></div> <div class="ripple__mask mdc-card__ripple"></div>
<div class="thumbnail__wrapper" @click="handleViewPostDetailEvent"> <div class="thumbnail__wrapper">
<Link :url="$props.data.link">
<Image <Image
class="image" class="image"
:src="$props.data.featureImage.thumbnail" :src="$props.data.featureImage.thumbnail"
@ -10,13 +11,16 @@
placeholder="https://via.placeholder.com/1024x768" placeholder="https://via.placeholder.com/1024x768"
:draggable="false" :draggable="false"
/> />
</Link>
</div> </div>
<div class="details__wrapper"> <div class="details__wrapper">
<div class="row__wrapper--date"> <div class="row__wrapper--date">
<span><i class="far fa-clock"></i> {{ $props.data.publistTime }}</span> <span><i class="far fa-clock"></i> {{ $props.data.publistTime }}</span>
</div> </div>
<div class="row__wrapper--title" @click="handleViewPostDetailEvent"> <div class="row__wrapper--title">
<Link :url="$props.data.link">
<span>{{ $props.data.title }}</span> <span>{{ $props.data.title }}</span>
</Link>
</div> </div>
<div class="row__wrapper--info"> <div class="row__wrapper--info">
<div class="column__wrapper--read_count"> <div class="column__wrapper--read_count">
@ -32,19 +36,20 @@
<div class="row__wrapper--abstruct"> <div class="row__wrapper--abstruct">
<span>{{ $props.data.excerpt }} </span> <span>{{ $props.data.excerpt }} </span>
</div> </div>
<!-- <div class="row__wrapper--tags"> <div class="row__wrapper--tags" v-if="$props.data.tags.length > 0">
<div class="tags__container"> <div class="tags__container">
<div class="tag__wrapper" v-for="(tag, index) in tags" :key="index"> <div class="tag__wrapper" v-for="(tag, index) in $props.data.tags" :key="index">
<div class="tag yolk"> <Link :to="{ name: 'TagArchive', params: { tag: tag.slug } }">
<span class="text">{{ tag }}</span> <NormalChip :context="tag.name"></NormalChip>
</Link>
</div> </div>
</div> </div>
</div> </div>
</div> --> <div class="row__wrapper--button">
<!-- // TODO: use tags instead of button, button is useless! -->
<div class="row__wrapper--button" @click="handleViewPostDetailEvent">
<div class="button__wrapper"> <div class="button__wrapper">
<Link :url="$props.data.link">
<NormalButton icon="fab fa-readme" :context="buttonContext"></NormalButton> <NormalButton icon="fab fa-readme" :context="buttonContext"></NormalButton>
</Link>
</div> </div>
</div> </div>
</div> </div>
@ -55,11 +60,11 @@
<script lang="ts"> <script lang="ts">
import { defineComponent, computed } from 'vue' import { defineComponent, computed } from 'vue'
import { useIntl, useRouter, useElementRef, useMDCRipple } from '@/hooks' import { useIntl, useRouter, useElementRef, useMDCRipple } from '@/hooks'
import linkHandler from '@/utils/linkHandler'
import NormalButton from '@/components/buttons/NormalButton.vue' import NormalButton from '@/components/buttons/NormalButton.vue'
import NormalChip from '@/components/chips/NormalChip.vue'
export default defineComponent({ export default defineComponent({
components: { NormalButton }, components: { NormalButton, NormalChip },
props: { props: {
data: { type: Object }, data: { type: Object },
type: { type: String, default: 'normal' }, // normal | reverse | mobile type: { type: String, default: 'normal' }, // normal | reverse | mobile
@ -76,13 +81,8 @@ export default defineComponent({
defaultMessage: 'Read More', defaultMessage: 'Read More',
}) })
const handleViewPostDetailEvent = () => {
linkHandler.handleClickLink({ url: props.data?.link ?? '', router, target: '_blank' })
}
return { return {
buttonContext, buttonContext,
handleViewPostDetailEvent,
setContentRef, setContentRef,
} }
}, },
@ -96,11 +96,11 @@ export default defineComponent({
@use '@/styles/mixins/polyfills'; @use '@/styles/mixins/polyfills';
.card__container { .card__container {
// TODO: sizing in parent
width: 780px; width: 780px;
height: 300px; height: 300px;
background: #ffffff; background: #ffffff;
border-radius: 10px; border-radius: 10px;
user-select: none;
.card__content { .card__content {
width: 100%; width: 100%;
height: 100%; height: 100%;
@ -151,7 +151,7 @@ export default defineComponent({
} }
&--title { &--title {
cursor: pointer; cursor: pointer;
> span { span {
line-height: 32px; line-height: 32px;
font-size: large; font-size: large;
font-weight: 700; font-weight: 700;
@ -186,7 +186,7 @@ export default defineComponent({
} }
} }
&--tags { &--tags {
max-height: 16px; max-height: 32px;
overflow: hidden; overflow: hidden;
align-items: flex-start; align-items: flex-start;
.tags__container { .tags__container {
@ -200,7 +200,9 @@ export default defineComponent({
flex-flow: row nowrap; flex-flow: row nowrap;
justify-content: flex-start; justify-content: flex-start;
align-items: center; align-items: center;
@include tags.tag-style; .router-link {
text-decoration: none;
}
} }
} }
} }

View File

@ -1,6 +1,7 @@
<template> <template>
<div class="card__container"> <div class="card__container">
<div class="row__wrapper--thumbnail" @click="handleViewPostDetailEvent"> <div class="row__wrapper--thumbnail">
<Link :url="$props.data.link">
<Image <Image
class="image" class="image"
:src="$props.data.featureImage.thumbnail" :src="$props.data.featureImage.thumbnail"
@ -8,9 +9,12 @@
placeholder="https://via.placeholder.com/1024x768" placeholder="https://via.placeholder.com/1024x768"
:draggable="false" :draggable="false"
/> />
</Link>
</div> </div>
<div class="row__wrapper--title" @click="handleViewPostDetailEvent"> <div class="row__wrapper--title">
<Link :url="$props.data.link">
<span>{{ $props.data.title }}</span> <span>{{ $props.data.title }}</span>
</Link>
</div> </div>
<div class="row__wrapper--statistics"> <div class="row__wrapper--statistics">
<div class="column__wrapper--read_count"> <div class="column__wrapper--read_count">
@ -26,16 +30,12 @@
<div class="row__wrapper--abstract"> <div class="row__wrapper--abstract">
<span>{{ $props.data.excerpt }} </span> <span>{{ $props.data.excerpt }} </span>
</div> </div>
<div class="row__wrapper--tags"> <div class="row__wrapper--tags" v-if="$props.data.tags.length > 0">
<div class="tags__container"> <div class="tags__container">
<div <div class="tag__wrapper" v-for="(tag, index) in $props.data.tags" :key="index">
class="tag__wrapper" <Link :to="{ name: 'TagArchive', params: { tag: tag.slug } }">
v-for="(tag, index) in ['vue', 'javascript', 'php', 'wordpress']" <NormalChip :context="tag.name"></NormalChip>
:key="index" </Link>
>
<div class="tag yolk">
<span class="text">{{ tag }}</span>
</div>
</div> </div>
</div> </div>
</div> </div>
@ -45,11 +45,10 @@
<script lang="ts"> <script lang="ts">
import { defineComponent, computed } from 'vue' import { defineComponent, computed } from 'vue'
import { useIntl, useRouter } from '@/hooks' import { useIntl, useRouter } from '@/hooks'
import linkHandler from '@/utils/linkHandler' import NormalChip from '@/components/chips/NormalChip.vue'
import NormalButton from '@/components/buttons/NormalButton.vue'
export default defineComponent({ export default defineComponent({
components: { NormalButton }, components: { NormalChip },
props: { props: {
data: { type: Object /*, default: () => postMock*/ }, data: { type: Object /*, default: () => postMock*/ },
type: { type: String, default: 'normal' }, // normal | reverse | mobile type: { type: String, default: 'normal' }, // normal | reverse | mobile
@ -63,13 +62,8 @@ export default defineComponent({
defaultMessage: 'Read More', defaultMessage: 'Read More',
}) })
const handleViewPostDetailEvent = () => {
linkHandler.handleClickLink({ url: props.data?.link ?? '', router, target: '_blank' })
}
return { return {
buttonContext, buttonContext,
handleViewPostDetailEvent,
} }
}, },
}) })
@ -77,7 +71,6 @@ export default defineComponent({
<style lang="scss" scoped> <style lang="scss" scoped>
@use '@/styles/mixins/text'; @use '@/styles/mixins/text';
@use '@/styles/mixins/tags';
@use '@/styles/mixins/polyfills'; @use '@/styles/mixins/polyfills';
.card__container { .card__container {
@ -86,6 +79,7 @@ export default defineComponent({
flex-flow: column nowrap; flex-flow: column nowrap;
justify-content: flex-start; justify-content: flex-start;
align-items: center; align-items: center;
user-select: none;
@include polyfills.flex-gap(12px, 'column nowrap'); @include polyfills.flex-gap(12px, 'column nowrap');
> * { > * {
width: calc(100% - 24px); width: calc(100% - 24px);
@ -95,7 +89,7 @@ export default defineComponent({
width: 100%; width: 100%;
} }
&--tags { &--tags {
max-height: 16px; max-height: 32px;
overflow: hidden; overflow: hidden;
align-items: flex-start; align-items: flex-start;
.tags__container { .tags__container {
@ -109,7 +103,6 @@ export default defineComponent({
flex-flow: row nowrap; flex-flow: row nowrap;
justify-content: flex-start; justify-content: flex-start;
align-items: center; align-items: center;
@include tags.tag-style;
} }
} }
} }

View File

@ -42,9 +42,13 @@ export default defineComponent({
} }
}) })
// watch(resultRef,result=>{ watch(
// if() () => props.result,
// }) (resultProp) => {
resultRef.value = resultProp
},
{ deep: true }
)
return { resultRef, isMax } return { resultRef, isMax }
}, },

View File

@ -1,15 +1,15 @@
<template> <template>
<div class="single-content__wrapper" v-if="postData"> <div class="single-content__wrapper">
<div class="featuer-image__wrapper"> <div class="featuer-image__wrapper" v-if="postData.publistTimeBrief">
<FeatureImage :data="postData"></FeatureImage> <FeatureImage :data="postData"></FeatureImage>
</div> </div>
<div class="article__wrapper"> <div class="article__wrapper" v-if="postData.content">
<Article :content="postData.content"></Article> <Article :content="postData.content"></Article>
</div> </div>
<div class="content-loader__wrapper" v-show="postFetchStatus === 'fetching'"> <div class="content-loader__wrapper" v-show="postFetchStatus === 'pending'">
<BookLoader></BookLoader> <BookLoader></BookLoader>
</div> </div>
<div class="comment__wrapper"> <div class="comment__wrapper" v-if="postId">
<Comment :postId="postId"></Comment> <Comment :postId="postId"></Comment>
</div> </div>
</div> </div>
@ -17,6 +17,7 @@
<script lang="ts"> <script lang="ts">
import { defineComponent, computed } from 'vue' import { defineComponent, computed } from 'vue'
import { isEmpty } from 'lodash'
import contentHandler from './utils/contentHandler' import contentHandler from './utils/contentHandler'
import FeatureImage from './components/FeatureImage.vue' import FeatureImage from './components/FeatureImage.vue'
import Article from './components/Article.vue' import Article from './components/Article.vue'
@ -32,7 +33,8 @@ export default defineComponent({
setup(props) { setup(props) {
const { postData, postFetchStatus } = contentHandler(props) const { postData, postFetchStatus } = contentHandler(props)
const postId = computed(() => { const postId = computed(() => {
return postData.value?.id if (isEmpty(postData.value)) return false
return (postData.value as Post)?.id
}) })
return { postData, postFetchStatus, postId } return { postData, postFetchStatus, postId }
}, },
@ -41,6 +43,7 @@ export default defineComponent({
<style lang="scss" scoped> <style lang="scss" scoped>
@use '@/styles/mixins/tags'; @use '@/styles/mixins/tags';
@use '@/styles/mixins/sizes';
@use '@/styles/mixins/skeleton'; @use '@/styles/mixins/skeleton';
.single-content__wrapper { .single-content__wrapper {
width: 100%; width: 100%;
@ -51,15 +54,11 @@ export default defineComponent({
.featuer-image__wrapper { .featuer-image__wrapper {
width: 100%; width: 100%;
} }
.article__wrapper { .article__wrapper,
width: 100%;
max-width: 800px;
padding-top: 24px;
}
.comment__wrapper { .comment__wrapper {
width: 100%; width: calc(100% - 12px * 2);
max-width: 800px; max-width: #{sizes.$post-main-content-max-width}; // 800px
padding-top: 24px; padding: 24px 12px 0 12px;
} }
} }
</style> </style>

View File

@ -31,17 +31,7 @@
</div> </div>
<div class="flex-box"> <div class="flex-box">
<div class="column__wrapper--publish"> <div class="column__wrapper--publish">
<span>{{ $props.data.publistTime }}</span> <span>{{ $props.data.publistTimeBrief }}</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>
</div> </div>
</div> </div>
</div> </div>
@ -74,6 +64,7 @@ export default defineComponent({
<style lang="scss" scoped> <style lang="scss" scoped>
@use '@/styles/mixins/text'; @use '@/styles/mixins/text';
@use '@/styles/mixins/sizes';
@use '@/styles/mixins/polyfills'; @use '@/styles/mixins/polyfills';
.feature-image__container { .feature-image__container {
width: 100%; width: 100%;
@ -89,20 +80,21 @@ export default defineComponent({
width: 100%; width: 100%;
height: 100%; height: 100%;
z-index: -1; z-index: -1;
&--image { // &--image {
} // }
&--pattern { &--pattern {
background: yellowgreen; background: yellowgreen;
} }
} }
.post-info__wrapper { .post-info__wrapper {
width: 100%; width: 100%;
max-width: 800px; max-width: #{sizes.$post-main-content-max-width}; // 800px
padding-bottom: 24px; padding-bottom: 24px;
display: flex; display: flex;
flex-flow: column nowrap; flex-flow: column nowrap;
justify-content: flex-end; justify-content: flex-end;
align-items: flex-start; align-items: flex-start;
margin: 0 12px;
> * { > * {
display: flex; display: flex;
flex-flow: row nowrap; flex-flow: row nowrap;
@ -116,8 +108,8 @@ export default defineComponent({
line-height: 48px; line-height: 48px;
font-size: xx-large; font-size: xx-large;
color: #ffffff; color: #ffffff;
// @include text.line-number-limit(1); @include text.line-number-limit(4);
// @include text.text-shadow-offset; @include text.text-shadow-offset;
} }
} }
&--info { &--info {
@ -164,14 +156,10 @@ export default defineComponent({
margin-right: 6px; margin-right: 6px;
} }
} }
&--author { // &--author {
} // }
&--publish { // &--publish {
} // }
&--words {
}
&--reads {
}
} }
} }
} }

View File

@ -24,12 +24,13 @@
</template> </template>
<script lang="ts"> <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 { cloneDeep } from 'lodash'
import camelcaseKeys from 'camelcase-keys' import camelcaseKeys from 'camelcase-keys'
import { useInjector, useState, useRoute } from '@/hooks' import { useInjector, useState, useRoute, useMessage, useIntl } from '@/hooks'
import { comments } from '@/store' import { comments } from '@/store'
import API from '@/api' import API from '@/api'
import axiosErrorHandler from '@/utils/axiosErrorHandler'
import CommentList from './CommentList.vue' import CommentList from './CommentList.vue'
import Pagination from '@/components/pagination/Pagination.vue' import Pagination from '@/components/pagination/Pagination.vue'
import Composer from './Composer.vue' import Composer from './Composer.vue'
@ -40,6 +41,8 @@ export default defineComponent({
postId: Number, postId: Number,
}, },
setup(props) { setup(props) {
const addMessage = useMessage()
const intl = useIntl()
const route = useRoute() const route = useRoute()
// const commentPagination = { // const commentPagination = {
// hash: route.hash, // TODO: support nested // hash: route.hash, // TODO: support nested
@ -51,7 +54,7 @@ export default defineComponent({
const [page, setPage] = useState(1) const [page, setPage] = useState(1)
const [perPage, setPerpage] = useState(10) const [perPage, setPerpage] = useState(10)
const [totalPage, setTotalPage] = useState(1) const [totalPage, setTotalPage] = useState(1)
const [commentData, setCommentData] = useState([]) const [commentData, setCommentData] = useState([] as Comment[])
const namespace = computed(() => `comment-for-post-${postId.value}`) const namespace = computed(() => `comment-for-post-${postId.value}`)
@ -92,18 +95,27 @@ export default defineComponent({
API.Sakura.v1 API.Sakura.v1
.createComment({ authorEmail, authorName, authorUrl, content, parent, post }) .createComment({ authorEmail, authorName, authorUrl, content, parent, post })
.then((res) => { .then((res) => {
const _commentData = cloneDeep(commentData.value) const _commentData = cloneDeep(commentData.value) as Comment[]
_commentData.push(camelcaseKeys(res.data)) _commentData.push(camelcaseKeys(res.data))
setCommentData(_commentData) 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() composerRef.value?.clearInputContent()
}) })
.catch((error) => { .catch((error) => {
if (error.response) { const titleMsg = intl.formatMessage({
console.error(error.response) id: 'messages.comment.submit.error',
} else { defaultMessage: 'Comment post failure.',
console.error(error) })
} const errorMsg = axiosErrorHandler(error).msg
console.log(errorMsg)
addMessage({ type: 'error', title: titleMsg, detail: errorMsg, closeTimeout: 0 })
}) })
} }

View File

@ -11,6 +11,7 @@
></OutlinedTextarea> ></OutlinedTextarea>
</div> </div>
<div class="row__wrapper--profile"> <div class="row__wrapper--profile">
<div class="flex-box">
<div class="column__wrapper--avatar"> <div class="column__wrapper--avatar">
<div class="avatar__wrapper mdc-elevation--z1"> <div class="avatar__wrapper mdc-elevation--z1">
<Image :src="avatar" placeholder="" :avatar="false" alt="" :draggable="false"></Image> <Image :src="avatar" placeholder="" :avatar="false" alt="" :draggable="false"></Image>
@ -22,7 +23,7 @@
</span> </span>
</div> </div>
</div> </div>
<div class="column__wrapper--input"> <div class="column__wrapper--input username">
<OutlinedInput <OutlinedInput
v-model:content="inputAuthorName" v-model:content="inputAuthorName"
leadingIcon="fas fa-user" leadingIcon="fas fa-user"
@ -45,6 +46,7 @@
></OutlinedInput> ></OutlinedInput>
</div> </div>
</div> </div>
</div>
<!-- <div class="row__wrapper--options"></div> --> <!-- <div class="row__wrapper--options"></div> -->
<!-- <div class="captcha-button"> <!-- <div class="captcha-button">
<Captcha></Captcha> <Captcha></Captcha>
@ -164,15 +166,28 @@ export default defineComponent({
} }
} }
&--profile { &--profile {
> .flex-box {
position: relative;
display: flex; display: flex;
flex-flow: row nowrap; flex-flow: row nowrap;
justify-content: space-between; justify-content: space-between;
align-items: center; align-items: center;
@include polyfills.flex-gap(12px, 'row nowrap'); @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 { .column__wrapper {
&--avatar { &--avatar {
flex: 0 0 auto; flex: 0 0 auto;
position: relative; position: relative;
@media screen and (max-width: 800px) {
position: absolute;
top: 0;
right: 0;
transform: scale(0.8);
}
> .avatar__wrapper { > .avatar__wrapper {
width: 56px; width: 56px;
height: 56px; height: 56px;
@ -205,6 +220,16 @@ export default defineComponent({
&--input { &--input {
flex: 1 1 auto; flex: 1 1 auto;
width: 100%; width: 100%;
@media screen and (max-width: 800px) {
&.username {
::v-deep() {
.mdc-text-field__input {
width: calc(100% - 80px);
}
}
}
}
}
} }
} }
} }

View File

@ -1,17 +1,22 @@
import { computed, onMounted, Ref, nextTick } from 'vue' import { computed, onMounted, Ref, nextTick } from 'vue'
import { useInjector, useState, useIntl, useRoute } from '@/hooks' import { isEmpty } from 'lodash'
import { posts } from '@/store' import { useInjector, useState, useIntl, useRoute, useMessage, useCommonMessages } from '@/hooks'
import { posts, messages } from '@/store'
import { GetPostParams, GetPageParams } from '@/api/Wp/v2' // interfaces import { GetPostParams, GetPageParams } from '@/api/Wp/v2' // interfaces
import postFilter from '@/utils/filters/postFilter' import postFilter from '@/utils/filters/postFilter'
export type Content = Post | {}
export default function setup(props: { export default function setup(props: {
readonly singleType?: string | undefined readonly singleType?: string | undefined
readonly pageType?: string | undefined readonly pageType?: string | undefined
}) { }) {
const intl = useIntl() const intl = useIntl()
const addMessage = useMessage()
const commonMessages = useCommonMessages()
const route = useRoute() const route = useRoute()
const [fetchStatus, setFetchStatus] = useState('fetching') const [fetchStatus, setFetchStatus] = useState('inite' as FetchingStatus)
const [content, setContent]: [Ref<Post>, (attr: any) => any] = useState(null) const [content, setContent] = useState({} as Content)
const { const {
postsStore, postsStore,
fetchPost, fetchPost,
@ -19,13 +24,22 @@ export default function setup(props: {
getPostsList, getPostsList,
}: { postsStore: Ref<PostStore>; [key: string]: any } = useInjector(posts) // TODO: fix useInjector return type }: { 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 { slug, postId, postname } = route.params
const isSingle = props.singleType ? true : false const isSingle = props.singleType ? true : false
const namespace = isSingle ? `single-${postId || postname}` : `page-${slug}` const namespace = isSingle ? `single-${postId || postname}` : `page-${slug}`
// get parsed post content // get parsed post content
const data = computed(() => 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 } const defaultFetchOpts: GetPostParams | GetPageParams = { page: 1, perPage: 1 }
@ -33,12 +47,12 @@ export default function setup(props: {
if (postId) { if (postId) {
defaultFetchOpts['include'] = Number(postId) defaultFetchOpts['include'] = Number(postId)
} else if (postname) { } else if (postname) {
defaultFetchOpts['slug'] = postname defaultFetchOpts['slug'] = postname as string
} else if (slug) { } else if (slug) {
defaultFetchOpts['slug'] = slug defaultFetchOpts['slug'] = slug as string
} else if (isSingle) { } else if (isSingle) {
throw new Error( // TODO: should wait router https://github.com/mashirozx/sakura-next/issues/148
intl.formatMessage( const errorMsg = intl.formatMessage(
{ {
id: 'messages.wordpress.permalink.shouldIncludeFieldsInSingle', id: 'messages.wordpress.permalink.shouldIncludeFieldsInSingle',
defaultMessage: defaultMessage:
@ -48,26 +62,46 @@ export default function setup(props: {
baseUrl: window.location.origin, baseUrl: window.location.origin,
} }
) )
) addMessage({
title: commonMessages.javascriptErrorTitle,
detail: errorMsg,
type: 'error',
closeTimeout: 0,
})
console.error(errorMsg)
} else { } else {
throw new Error( const errorMsg = intl.formatMessage({
intl.formatMessage({
id: 'messages.wordpress.permalink.shouldIncludeFieldsInPost', id: 'messages.wordpress.permalink.shouldIncludeFieldsInPost',
defaultMessage: 'WordPress pages should use %slug% as the permalink.', 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 fetchContent = () => {
const fetchOption = isSingle ? fetchPost : fetchPage const fetchOption = isSingle ? fetchPost : fetchPage
setFetchStatus('fetching') if (fetchStatus.value !== 'cached') {
setFetchStatus('pending')
}
fetchOption({ fetchOption({
state: postsStore, state: postsStore,
namespace, namespace,
opts: { ...defaultFetchOpts }, opts: { ...defaultFetchOpts },
}).then(() => { addMessage,
})
.then(() => {
window.setTimeout(() => {
getContent() getContent()
setFetchStatus('done') setFetchStatus('success')
}, 500)
})
.catch(() => {
setFetchStatus('error')
}) })
} }
@ -84,8 +118,17 @@ export default function setup(props: {
onMounted(() => { onMounted(() => {
nextTick(() => { nextTick(() => {
getContent() getContent()
// setFetchStatus('refreshing') if ((content.value as Post)?.content) {
// 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. setFetchStatus('cached')
const msg = intl.formatMessage({
id: 'messages.postContent.cache.found',
defaultMessage: 'Fetching the latest post content...',
})
addMessage({
type: 'info',
title: msg,
})
}
}) })
fetchContent() fetchContent()
}) })

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

View File

@ -0,0 +1,6 @@
export interface RouterLinkTo {
path?: string
name?: string
params?: { [key: string]: any }
query?: { [key: string]: any }
}

View File

@ -7,7 +7,7 @@
:type="index % 2 ? 'normal' : 'reverse'" :type="index % 2 ? 'normal' : 'reverse'"
></PostThumbCardIndex> ></PostThumbCardIndex>
</div> </div>
<div class="loader__wrapper" v-show="fetchStatus === 'fetching'"> <div class="loader__wrapper" v-show="fetchStatus === 'pending'">
<BookLoader></BookLoader> <BookLoader></BookLoader>
</div> </div>
<div class="last-page__wrapper" v-show="isTheLastPage">no more</div> <div class="last-page__wrapper" v-show="isTheLastPage">no more</div>
@ -17,7 +17,14 @@
<script lang="ts"> <script lang="ts">
import { defineComponent, computed, onMounted, Ref } from 'vue' 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 { posts } from '@/store'
import PostThumbCardIndex from '@/components/cards/postThumbCards/PostThumbCardIndex.vue' import PostThumbCardIndex from '@/components/cards/postThumbCards/PostThumbCardIndex.vue'
import BookLoader from '@/components/loader/BookLoader.vue' import BookLoader from '@/components/loader/BookLoader.vue'
@ -37,8 +44,10 @@ export default defineComponent({
}, },
}, },
setup(props) { setup(props) {
const addMessage = useMessage()
const intl = useIntl()
const [listContainerRef, setListContainerRef] = useElementRef() const [listContainerRef, setListContainerRef] = useElementRef()
const [fetchStatus, setFetchStatus] = useState('fetching') const [fetchStatus, setFetchStatus] = useState('inite' as FetchingStatus)
const [currentPage, setCurrentPage] = useState(props.page) const [currentPage, setCurrentPage] = useState(props.page)
const [postList, setPostList]: [Ref<Post[]>, (attr: any) => any] = useState([] as Post[]) const [postList, setPostList]: [Ref<Post[]>, (attr: any) => any] = useState([] as Post[])
const { const {
@ -66,7 +75,9 @@ export default defineComponent({
} }
const fetch = async () => { const fetch = async () => {
setFetchStatus('fetching') if (fetchStatus.value !== 'cached') {
setFetchStatus('pending')
}
fetchPost({ fetchPost({
state: postsStore, state: postsStore,
namespace: props.namespace, namespace: props.namespace,
@ -76,9 +87,14 @@ export default defineComponent({
context: 'embed', context: 'embed',
...props.fetchParameters, ...props.fetchParameters,
}, },
}).then(() => { addMessage,
})
.then(() => {
get() get()
setFetchStatus('done') setFetchStatus('success')
})
.catch(() => {
setFetchStatus('error')
}) })
} }
@ -108,11 +124,21 @@ export default defineComponent({
}) })
onMounted(() => { onMounted(() => {
// this will only work when set to cache post store
window.setTimeout(() => { window.setTimeout(() => {
get() get()
// setFetchStatus('done') if (postList.value.length > 0) {
// 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. setFetchStatus('cached')
}, 500) // postsStore injection may not be OK when mounted 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() fetch()
}) })

View File

@ -141,20 +141,20 @@ export default defineComponent({
$i: 2; $i: 2;
@while $i < 6 { @while $i < 6 {
$delay: $i * 15 - 30; $delay: $i * 15% - 30%;
@keyframes page-#{$i} { @keyframes page-#{$i} {
#{0 + $delay}% { #{0% + $delay} {
transform: rotateY(180deg); transform: rotateY(180deg);
opacity: 0; opacity: 0;
} }
#{20 + $delay}% { #{20% + $delay} {
opacity: 1; opacity: 1;
} }
#{35 + $delay}%, #{35% + $delay},
100% { 100% {
opacity: 0; opacity: 0;
} }
#{50 + $delay}%, #{50% + $delay},
100% { 100% {
transform: rotateY(0deg); transform: rotateY(0deg);
} }

View File

@ -11,14 +11,6 @@
<div class="title"> <div class="title">
<span>{{ $props.message.title }}</span> <span>{{ $props.message.title }}</span>
</div> </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>
<div <div
v-if="$props.message.detail" v-if="$props.message.detail"
@ -32,6 +24,16 @@
<i class="fas fa-times-circle"></i> <i class="fas fa-times-circle"></i>
</div> </div>
</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> </div>
</div> </div>
@ -114,6 +116,7 @@ export default defineComponent({
<style lang="scss" scoped> <style lang="scss" scoped>
@use "sass:color"; @use "sass:color";
@use '@/styles/mixins/polyfills'; @use '@/styles/mixins/polyfills';
@use '@/styles/mixins/text';
.item__container { .item__container {
--text-color: #3c434a; --text-color: #3c434a;
@ -131,13 +134,14 @@ export default defineComponent({
&[type='error'] { &[type='error'] {
--highlight-color: #f93154; // danger --highlight-color: #f93154; // danger
} }
width: var(--width); width: var(--msg-width);
background: var(--background-color); background: var(--background-color);
border-left: 3px solid var(--highlight-color, #757575); border-left: 3px solid var(--highlight-color, #757575);
> .item__content { > .item__content {
width: calc(100% - 24px); width: calc(100% - 24px);
padding: 12px; padding: 12px;
> .flex-box { > .flex-box {
width: 100%;
display: flex; display: flex;
flex-flow: row nowrap; flex-flow: row nowrap;
align-items: space-between; align-items: space-between;
@ -157,16 +161,18 @@ export default defineComponent({
display: flex; display: flex;
flex-flow: column nowrap; flex-flow: column nowrap;
align-items: flex-start; align-items: flex-start;
// overflow-wrap: anywhere;
> .row__wrapper { > .row__wrapper {
&--title { &--title {
width: 100%;
display: flex; display: flex;
flex-flow: row nowrap; flex-flow: row nowrap;
justify-content: space-between; justify-content: space-between;
align-items: flex-start; align-items: flex-start;
@include polyfills.flex-gap(12px, 'row nowrap'); @include polyfills.flex-gap(12px, 'row nowrap');
width: calc(100% + 12px);
> * span { > * span {
line-height: 16px; line-height: 16px;
@include text.word-break;
} }
> .title__content { > .title__content {
&--message { &--message {
@ -177,35 +183,36 @@ export default defineComponent({
color: var(--text-color); 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 { &--collapse {
flex: 0 0 auto; flex: 0 0 auto;
transform: scaleY(1); transform: scaleY(1);
transition: transform 0.5s cubic-bezier(0, 0, 0.3, 1); transition: transform 0.5s cubic-bezier(0, 0, 0.3, 1);
cursor: pointer;
&.reverse { &.reverse {
transform: scaleY(-1); transform: scaleY(-1);
} }
} }
&--close { &--close {
flex: 0 0 auto; 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;
}
} }
} }
} }

View File

@ -20,7 +20,7 @@ export default defineComponent({
props: { props: {
positionX: { type: String, default: 'right' }, // left center right positionX: { type: String, default: 'right' }, // left center right
positionY: { type: String, default: 'top' }, // top bottom positionY: { type: String, default: 'top' }, // top bottom
width: { type: String, default: '380px' }, // width: { type: String, default: '380px' },
}, },
setup(props) { setup(props) {
const { messageList } = useInjector(messages) const { messageList } = useInjector(messages)
@ -43,7 +43,7 @@ export default defineComponent({
'--to-70': props.positionX === 'right' ? '100%' : '-100%', '--to-70': props.positionX === 'right' ? '100%' : '-100%',
'--to-100': props.positionX === 'right' ? '100%' : '-100%', '--to-100': props.positionX === 'right' ? '100%' : '-100%',
'--absolute-fix': props.positionY === 'bottom' ? '-100%' : '0', '--absolute-fix': props.positionY === 'bottom' ? '-100%' : '0',
'--width': props.width, // '--width': props.width,
} }
}) })
@ -54,7 +54,17 @@ export default defineComponent({
<style lang="scss" scoped> <style lang="scss" scoped>
.messages__container { .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 { .message__wrapper {
padding: 6px; padding: 6px;
} }

View File

@ -36,7 +36,7 @@ export default defineComponent({
.map((item, index) => index === props.result) .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)) const cacheArrayRef = computed(() => cloneDeep(arrayRef.value))
watch( watch(
@ -76,6 +76,14 @@ export default defineComponent({
.map((item, index) => index === props.result)) .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 } return { arrayRef }
}, },
}) })

View File

@ -6,7 +6,6 @@
role="switch" role="switch"
:aria-checked="checked" :aria-checked="checked"
:ref="setElRef" :ref="setElRef"
@click="handleChange"
> >
<div class="mdc-switch__track"></div> <div class="mdc-switch__track"></div>
<div class="mdc-switch__handle-track"> <div class="mdc-switch__handle-track">
@ -35,7 +34,7 @@
<script lang="ts"> <script lang="ts">
import { defineComponent, ref, watch } from 'vue' import { defineComponent, ref, watch } from 'vue'
import uniqueHash from '@/utils/uniqueHash' import uniqueHash from '@/utils/uniqueHash'
import { useElementRef } from '@/hooks' import { useElementRef, useIntervalWatcher } from '@/hooks'
import useMDCSwitch from '@/hooks/mdc/useMDCSwitch' import useMDCSwitch from '@/hooks/mdc/useMDCSwitch'
export default defineComponent({ export default defineComponent({
@ -54,17 +53,17 @@ export default defineComponent({
const checked = ref(props.checked) const checked = ref(props.checked)
const handleChange = () => { useIntervalWatcher(() => {
if (MDCSwitchRef.value) { if (MDCSwitchRef.value && MDCSwitchRef.value.selected !== checked.value) {
checked.value = !MDCSwitchRef.value.selected checked.value = MDCSwitchRef.value.selected
}
} }
}, 100)
watch( watch(
() => props.checked, () => props.checked,
(value) => { (value) => {
checked.value = value checked.value = value
if (MDCSwitchRef.value) MDCSwitchRef.value.selected = !value if (MDCSwitchRef.value) MDCSwitchRef.value.selected = value
}, },
{ immediate: true } { immediate: true }
) )
@ -87,7 +86,7 @@ export default defineComponent({
watch(checked, (value) => emit('update:checked', value)) watch(checked, (value) => emit('update:checked', value))
return { id, setElRef, handleChange, checked } return { id, setElRef, checked }
}, },
}) })
</script> </script>

View File

@ -8,8 +8,11 @@ import useReachElementSide from './useReachElementSide'
import { useElementRef, useElementRefs } from './useElementRef' import { useElementRef, useElementRefs } from './useElementRef'
import useOffsetDistance from './useOffsetDistance' import useOffsetDistance from './useOffsetDistance'
import useMDCRipple from './mdc/useMDCRipple' import useMDCRipple from './mdc/useMDCRipple'
import useMessage from './useMessage' import useMessage, { useCommonMessages } from './useMessage'
import useTypewriterEffect from './useTypewriterEffect' import useTypewriterEffect from './useTypewriterEffect'
import useIntervalWatcher from './useIntervalWatcher'
import useKeepAliveWindowScrollTop from './useKeepAliveWindowScrollTop'
import useWindowScrollLock from './useWindowScrollLock'
export { export {
useState, useState,
@ -29,5 +32,9 @@ export {
useElementRefs, useElementRefs,
useOffsetDistance, useOffsetDistance,
useMessage, useMessage,
useCommonMessages,
useTypewriterEffect, useTypewriterEffect,
useIntervalWatcher,
useKeepAliveWindowScrollTop,
useWindowScrollLock,
} }

View 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())
}

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

View File

@ -1,16 +1,24 @@
import type { Ref } from 'vue' import type { Ref } from 'vue'
import { useInjector } from '@/hooks' import { useInjector, useIntl } from '@/hooks'
import { messages } from '@/store' import { messages } from '@/store'
import type { Message, MessageOptions } from '@/store/messages' import type { Message, MessageOptions } from '@/store/messages'
export default function useMessage() { /**
const { * deprecated
messageList, */
addMessage, export interface UseMessageInjecter {
}: {
messageList: Ref<Message[]> messageList: Ref<Message[]>
addMessage: (state: Ref<Message[]>, options: MessageOptions) => void addMessage: (state: Ref<Message[]>, options: MessageOptions) => void
} = useInjector(messages) }
/**
* @param useMessageInjecter (deprecated)
* @returns
*/
export default function useMessage(useMessageInjecter?: UseMessageInjecter) {
const { messageList, addMessage }: UseMessageInjecter = useMessageInjecter
? useMessageInjecter
: useInjector(messages)
const _addMessage = (options: MessageOptions) => { const _addMessage = (options: MessageOptions) => {
addMessage(messageList, options) addMessage(messageList, options)
@ -18,3 +26,13 @@ export default function useMessage() {
return _addMessage return _addMessage
} }
export const useCommonMessages = () => {
const intl = useIntl()
return {
javascriptErrorTitle: intl.formatMessage({
id: 'messages.commonMessages.javascriptErrorTitle',
defaultMessage: 'Opps, something when wrong!',
}),
}
}

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

View File

@ -36,9 +36,13 @@
<script lang="ts"> <script lang="ts">
import { defineComponent, computed, onUnmounted, onDeactivated } from 'vue' import { defineComponent, computed, onUnmounted, onDeactivated } from 'vue'
import { throttle, xor } from 'lodash' import { throttle } from 'lodash'
import { useState, useWindowResize } from '@/hooks' import {
import getScrollbarWidth from '@/utils/getScrollbarWidth' useState,
useWindowResize,
useKeepAliveWindowScrollTop,
useWindowScrollLock,
} from '@/hooks'
import Header from '@/layouts/components/header/Header.vue' import Header from '@/layouts/components/header/Header.vue'
import Footer from '@/layouts/components/footer/Footer.vue' import Footer from '@/layouts/components/footer/Footer.vue'
import HeaderMobile from '@/layouts/components/header/HeaderMobile.vue' import HeaderMobile from '@/layouts/components/header/HeaderMobile.vue'
@ -49,25 +53,13 @@ export default defineComponent({
components: { Header, Footer, HeaderMobile, NavDrawer }, components: { Header, Footer, HeaderMobile, NavDrawer },
props: { headerPlaceholder: { type: Boolean, default: true } }, props: { headerPlaceholder: { type: Boolean, default: true } },
setup() { setup() {
useKeepAliveWindowScrollTop()
const windowSize = useWindowResize() const windowSize = useWindowResize()
const isMobile = computed(() => windowSize.value.innerWidth <= 600) const isMobile = computed(() => windowSize.value.innerWidth <= 600)
const [shouldDrawerOpen, setShouldDrawerOpen] = useState(false) const [shouldDrawerOpen, setShouldDrawerOpen] = useState(false)
const removeScrollLock = () => { const [removeScrollLock, addScrollLock] = useWindowScrollLock()
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 toggleDrawer = throttle( const toggleDrawer = throttle(
() => { () => {
setShouldDrawerOpen(!shouldDrawerOpen.value) setShouldDrawerOpen(!shouldDrawerOpen.value)
@ -76,14 +68,6 @@ export default defineComponent({
} else { } else {
removeScrollLock() 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, 500,
{ {
@ -101,12 +85,10 @@ export default defineComponent({
onUnmounted(() => { onUnmounted(() => {
setShouldDrawerOpen(false) setShouldDrawerOpen(false)
removeScrollLock()
}) })
onDeactivated(() => { onDeactivated(() => {
setShouldDrawerOpen(false) setShouldDrawerOpen(false)
removeScrollLock()
}) })
return { isMobile, handleMDrawerToggleEvent, shouldDrawerOpen, handleClickFakeAfterEvent } return { isMobile, handleMDrawerToggleEvent, shouldDrawerOpen, handleClickFakeAfterEvent }
@ -115,6 +97,11 @@ export default defineComponent({
</script> </script>
<style lang="scss" scoped> <style lang="scss" scoped>
@use '@/styles/app';
::v-deep() {
@include app.global;
}
$drawer-width: 260px; $drawer-width: 260px;
.page { .page {
position: relative; position: relative;

View File

@ -29,7 +29,7 @@
<NavItem <NavItem
:context="parent.title" :context="parent.title"
:prefix="parent.icon" :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' : ''" :suffix="parent.child.length > 0 ? 'fas fa-chevron-down' : ''"
></NavItem> ></NavItem>
</div> </div>
@ -175,6 +175,18 @@ export default defineComponent({
height: 36px; height: 36px;
background: rgba(2, 1, 1, 0); background: rgba(2, 1, 1, 0);
transition: all 0.3s; 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 { &--child {
max-height: 0; max-height: 0;
@ -193,6 +205,15 @@ export default defineComponent({
.ul__content { .ul__content {
&--tag { &--tag {
background: rgba(2, 1, 1, 0.05); background: rgba(2, 1, 1, 0.05);
::v-deep() {
.nav-item__content {
.icon--suffix {
i {
transform: rotate(-180deg);
}
}
}
}
} }
&--child { &--child {
max-height: var(--collapse-height); max-height: var(--collapse-height);

View File

@ -1,5 +1,6 @@
<template> <template>
<div class="nav-item__container mdc-list-item" :ref="setContainerRef" @click="handleClickEvent"> <Link class="link__container" :url="$props.url">
<div class="nav-item__container mdc-list-item" :ref="setContainerRef">
<div class="mdc-list-item__ripple"></div> <div class="mdc-list-item__ripple"></div>
<span class="nav-item__content mdc-list-item__text"> <span class="nav-item__content mdc-list-item__text">
<span class="icon icon--prefix" v-if="prefix"> <span class="icon icon--prefix" v-if="prefix">
@ -11,6 +12,7 @@
</span> </span>
</span> </span>
</div> </div>
</Link>
</template> </template>
<script lang="ts"> <script lang="ts">
@ -44,6 +46,9 @@ export default defineComponent({
</script> </script>
<style lang="scss" scoped> <style lang="scss" scoped>
.link__container {
height: 100%;
width: 100%;
.nav-item__container { .nav-item__container {
height: 100%; height: 100%;
width: 100%; width: 100%;
@ -76,4 +81,5 @@ export default defineComponent({
} }
} }
} }
}
</style> </style>

View File

@ -9,6 +9,7 @@ import { auth, init, posts, comments, messages } from './store'
import { intlPlugin } from './locales' import { intlPlugin } from './locales'
import UiIcon from '@/components/icon/UiIcon.vue' import UiIcon from '@/components/icon/UiIcon.vue'
import Image from '@/components/image/Image.vue' import Image from '@/components/image/Image.vue'
import Link from '@/components/link/Link.vue'
const theWindow = window as any const theWindow = window as any
theWindow.router = router theWindow.router = router
@ -20,4 +21,5 @@ app.use(intlPlugin)
app.use(VueSvgIconPlugin, { tagName: 'svg-icon' }) app.use(VueSvgIconPlugin, { tagName: 'svg-icon' })
app.component('UiIcon', UiIcon) app.component('UiIcon', UiIcon)
app.component('Image', Image) app.component('Image', Image)
app.component('Link', Link)
app.mount('#app') app.mount('#app')

View File

@ -15,8 +15,9 @@ interface FetchParams {
export default function comments(): object { export default function comments(): object {
const defaultCommentStore: CommentStore = {} const defaultCommentStore: CommentStore = {}
const [commentStore, setCommentStore]: [Ref<CommentStore>, (arg: CommentStore) => void] = const [commentStore, setCommentStore] = false
usePersistedState('commentStore', defaultCommentStore) ? usePersistedState('commentStore', defaultCommentStore)
: useState(defaultCommentStore)
const resHandler = ( const resHandler = (
state: FetchParams['state'], state: FetchParams['state'],

View File

@ -1,4 +1,3 @@
import { Ref } from 'vue'
import { cloneDeep, remove } from 'lodash' import { cloneDeep, remove } from 'lodash'
import { useState } from '@/hooks' import { useState } from '@/hooks'
import uniqueHash from '@/utils/uniqueHash' import uniqueHash from '@/utils/uniqueHash'
@ -31,7 +30,7 @@ export default function msg(): object {
return return
} }
const closeTimeout = options.closeTimeout || 3000 const closeTimeout = options.closeTimeout || 6000
setTimeout(() => removeMessage(state, id), closeTimeout) setTimeout(() => removeMessage(state, id), closeTimeout)
} }

View File

@ -1,5 +1,6 @@
import { Ref } from 'vue' import type { Ref } from 'vue'
import { usePersistedState, useState } from '@/hooks' import { usePersistedState, useState } from '@/hooks'
import type { MessageOptions } from '@/store/messages'
import camelcaseKeys from 'camelcase-keys' import camelcaseKeys from 'camelcase-keys'
import { AxiosResponse } from 'axios' // interface import { AxiosResponse } from 'axios' // interface
import { cloneDeep } from 'lodash' import { cloneDeep } from 'lodash'
@ -7,11 +8,14 @@ import API from '@/api'
import { GetPostParams, GetPageParams } from '@/api/Wp/v2' // interface import { GetPostParams, GetPageParams } from '@/api/Wp/v2' // interface
import { getPagination } from '@/utils/filters/paginationFilter' import { getPagination } from '@/utils/filters/paginationFilter'
import logger from '@/utils/logger' import logger from '@/utils/logger'
import axiosErrorHandler from '@/utils/axiosErrorHandler'
import intl from '@/locales'
interface FetchParams { export interface FetchParams {
state: Ref<PostStore> state: Ref<PostStore>
namespace: string namespace: string
opts: GetPostParams | GetPageParams opts: GetPostParams | GetPageParams
addMessage: (options: MessageOptions) => void
} }
export default function posts(): object { export default function posts(): object {
@ -24,7 +28,9 @@ export default function posts(): object {
data: {}, data: {},
list: {}, 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) * 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 * Fetch posts list from API /wp-json/wp/v2/posts
* TODO: what's the correct type of readonly (state)? * 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) => { return new Promise((resolve, reject) => {
API.Wp.v2 API.Wp.v2
.getPosts(opts as GetPostParams) .getPosts(opts as GetPostParams)
@ -108,6 +111,12 @@ export default function posts(): object {
}) })
.catch((error) => { .catch((error) => {
logger('error', 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) reject(error)
}) })
}) })
@ -116,11 +125,8 @@ export default function posts(): object {
/** /**
* Fetch posts list from API /wp-json/wp/v2/posts * Fetch posts list from API /wp-json/wp/v2/posts
* TODO: what's the correct type of readonly (state)? * 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) => { return new Promise((resolve, reject) => {
API.Wp.v2 API.Wp.v2
.getPages(opts as GetPageParams) .getPages(opts as GetPageParams)
@ -130,6 +136,12 @@ export default function posts(): object {
}) })
.catch((error) => { .catch((error) => {
logger('error', 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) reject(error)
}) })
}) })

9
src/styles/_app.scss Normal file
View File

@ -0,0 +1,9 @@
@mixin global {
a,
a:hover,
a:focus,
a:active {
text-decoration: none;
color: inherit;
}
}

View File

@ -35,3 +35,41 @@
@error "The second paramater $flex-flow is set to be '#{$flex-flow}', which is illegal."; @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.";
}
}

View File

@ -0,0 +1 @@
$post-main-content-max-width: 800px;

View File

@ -11,3 +11,8 @@
padding: 10px 10px; padding: 10px 10px;
transform: translate(-10px, 10px); transform: translate(-10px, 10px);
} }
@mixin word-break {
overflow: hidden;
word-break: break-word;
}

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

View File

@ -1,5 +1,4 @@
import intl from '@/locales' import intl from '@/locales'
import timeFormater from '@/utils/timeFormater'
import htmlStringInnerText from '@/utils/htmlStringInnerText' import htmlStringInnerText from '@/utils/htmlStringInnerText'
import camelcaseKeys from 'camelcase-keys' import camelcaseKeys from 'camelcase-keys'
import publishTime from './publishTime' import publishTime from './publishTime'
@ -9,6 +8,7 @@ export default function (post: Post, type: 'single' | 'page' | 'thumbList') {
const title = post.title.rendered const title = post.title.rendered
const publistTime = publishTime(post.date) const publistTime = publishTime(post.date)
const publistTimeBrief = publishTime(post.date, true)
const readCount = intl.formatMessage( const readCount = intl.formatMessage(
{ {
@ -55,9 +55,12 @@ export default function (post: Post, type: 'single' | 'page' | 'thumbList') {
const content = post.content ?? '' const content = post.content ?? ''
const link = post.link const link = post.link
const tags = post.tagsMeta ? camelcaseKeys(post.tagsMeta) : []
const data = { const data = {
id, id,
publistTime, publistTime,
publistTimeBrief,
title, title,
readCount, readCount,
commentCount, commentCount,
@ -67,6 +70,7 @@ export default function (post: Post, type: 'single' | 'page' | 'thumbList') {
author, author,
content, content,
link, link,
tags,
} }
return data return data

View File

@ -1,12 +1,29 @@
import intl from '@/locales' import intl from '@/locales'
import timeFormater from '@/utils/timeFormater' import timeFormater from '@/utils/timeFormater'
export default function (publishTime: string) { export default function (publishTime: string, brief = false) {
const publistTimeDate = new timeFormater(publishTime) const publistTimeDate = new timeFormater(publishTime)
const publistTime = publistTimeDate.moreThanOneYear() if (brief) {
return publistTimeDate.moreThanOneYear()
? intl.formatMessage( ? intl.formatMessage(
{ {
id: 'posts.postTimeOn', 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}', defaultMessage: 'Post on {publistTimeDate, date, long}',
}, },
{ publistTimeDate: publistTimeDate.getDate() } { publistTimeDate: publistTimeDate.getDate() }
@ -18,5 +35,5 @@ export default function (publishTime: string) {
}, },
{ duration: publistTimeDate.getReadableTimeFromNow() } { duration: publistTimeDate.getReadableTimeFromNow() }
) )
return publistTime }
} }

View File

@ -1,4 +1,4 @@
import { Router } from 'vue-router' import type { Router } from 'vue-router'
import logger from './logger' import logger from './logger'
export default class linkHandler { export default class linkHandler {
@ -20,7 +20,8 @@ export default class linkHandler {
public static internalLinkRouterPath(url: string) { public static internalLinkRouterPath(url: string) {
if (this.isInternal(url)) { 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 return pathname + search + hash
} else { } else {
throw new Error('Not internal link') throw new Error('Not internal link')
@ -38,10 +39,9 @@ export default class linkHandler {
}) { }) {
logger('log', 'linkHandler: ', url) logger('log', 'linkHandler: ', url)
if (this.isInternal(url)) { if (this.isInternal(url)) {
const parsed = this.urlParser(url)
// TODO: why not import? cause vue codes cannot pass the jest test... // TODO: why not import? cause vue codes cannot pass the jest test...
// router = router ?? ((window as any).router as Router) // 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)) // window.setTimeout(() => (window.location.hash = parsed.hash))
} else { } else {
console.log('open: ', this.urlParser(url).href) console.log('open: ', this.urlParser(url).href)

View File

@ -15,7 +15,7 @@ export default class timeFormater {
this.timestampFromNow = this.now - this.timestamp this.timestampFromNow = this.now - this.timestamp
} }
public getReadableTimeFromNow() { public getTimeFromNow() {
const gap = this.timestampFromNow const gap = this.timestampFromNow
let num: number = 0 let num: number = 0
let unit: Unit = 'second' let unit: Unit = 'second'
@ -38,9 +38,20 @@ export default class timeFormater {
num = gap / (365 * 24 * 60 * 60 * 1000) num = gap / (365 * 24 * 60 * 60 * 1000)
unit = 'year' unit = 'year'
} }
return { num, unit }
}
public getReadableTimeFromNow() {
const { num, unit } = this.getTimeFromNow()
return intl.formatRelativeTime(Math.floor(num), unit, { style: 'narrow' }) 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( public getFormatTime(
opts: FormatDateOptions = { year: 'numeric', month: 'numeric', day: 'numeric' } opts: FormatDateOptions = { year: 'numeric', month: 'numeric', day: 'numeric' }
) { ) {
@ -60,4 +71,56 @@ export default class timeFormater {
public getDate() { public getDate() {
return this.date 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 }
}
} }