Add mobile compatibility

next
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) {
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),
];
}
}

View File

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

View File

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

View File

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

View File

@ -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');
}

View File

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

View File

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

View File

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

View File

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

View File

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

View File

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

View File

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

View File

@ -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 {
// }
}
}
}

View File

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

View File

@ -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%;
}
}
}

View File

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

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'"
></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()
})

View File

@ -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);
}

View File

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

View File

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

View File

@ -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 }
},
})

View File

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

View File

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

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 { 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!',
}),
}
}

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">
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;

View File

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

View File

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

View File

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

View File

@ -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'],

View File

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

View File

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

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.";
}
}
@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;
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 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

View File

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

View File

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

View File

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