forked from Mirrors/elk
feat: more to explore (#360)
This commit is contained in:
parent
a36a26d745
commit
183b1659d1
23 changed files with 530 additions and 17 deletions
61
components/account/AccountBigCard.vue
Normal file
61
components/account/AccountBigCard.vue
Normal file
|
@ -0,0 +1,61 @@
|
||||||
|
<script lang="ts" setup>
|
||||||
|
import type { Account } from 'masto'
|
||||||
|
const { account, as = 'div' } = $defineProps<{
|
||||||
|
account: Account
|
||||||
|
as?: string
|
||||||
|
}>()
|
||||||
|
|
||||||
|
cacheAccount(account)
|
||||||
|
|
||||||
|
defineOptions({
|
||||||
|
inheritAttrs: false,
|
||||||
|
})
|
||||||
|
</script>
|
||||||
|
|
||||||
|
<template>
|
||||||
|
<component :is="as" block focus:outline-none focus-visible:ring="2 primary" v-bind="$attrs">
|
||||||
|
<!-- Banner -->
|
||||||
|
<div px2 pt2>
|
||||||
|
<div rounded of-hidden bg="gray-500/20" aspect="3.19">
|
||||||
|
<img h-full w-full object-cover :src="account.header" :alt="$t('account.profile_description', [account.username])">
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
<div px-4 pb-4 space-y-2>
|
||||||
|
<!-- User info -->
|
||||||
|
<div flex sm:flex-row flex-col flex-gap-2>
|
||||||
|
<div flex items-center justify-between>
|
||||||
|
<div w-17 h-17 rounded-full border-4 border-bg-base z-2 mt--2 ml--1>
|
||||||
|
<AccountAvatar :account="account" />
|
||||||
|
</div>
|
||||||
|
<a block sm:hidden href="javascript:;" @click.stop>
|
||||||
|
<AccountFollowButton :account="account" />
|
||||||
|
</a>
|
||||||
|
</div>
|
||||||
|
<div sm:mt-2>
|
||||||
|
<div>
|
||||||
|
<ContentRich
|
||||||
|
font-bold text-lg line-clamp-1 ws-pre-wrap break-all
|
||||||
|
:content="getDisplayName(account, { rich: true })"
|
||||||
|
:emojis="account.emojis"
|
||||||
|
/>
|
||||||
|
</div>
|
||||||
|
<AccountHandle text-sm :account="account" />
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
<!-- Note -->
|
||||||
|
<div v-if="account.note">
|
||||||
|
<ContentRich
|
||||||
|
:content="account.note" :emojis="account.emojis"
|
||||||
|
line-clamp-2
|
||||||
|
/>
|
||||||
|
</div>
|
||||||
|
<!-- Follow info -->
|
||||||
|
<div flex justify-between items-center>
|
||||||
|
<AccountPostsFollowers text-sm :account="account" />
|
||||||
|
<a sm:block hidden href="javascript:;" @click.stop>
|
||||||
|
<AccountFollowButton :account="account" />
|
||||||
|
</a>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
</component>
|
||||||
|
</template>
|
30
components/account/AccountBigCardSkeleton.vue
Normal file
30
components/account/AccountBigCardSkeleton.vue
Normal file
|
@ -0,0 +1,30 @@
|
||||||
|
<template>
|
||||||
|
<div>
|
||||||
|
<!-- Banner -->
|
||||||
|
<div px2 pt2>
|
||||||
|
<div rounded of-hidden aspect="3.19" class="flex skeleton-loading-bg" />
|
||||||
|
<div px-4 pb-4 flex="~ col gap-2">
|
||||||
|
<!-- User info -->
|
||||||
|
<div flex sm:flex-row flex-col flex-gap-2>
|
||||||
|
<div flex items-center justify-between>
|
||||||
|
<div w-17 h-17 rounded-full border-4 border-bg-base z-2 mt--2 ml--1 of-hidden bg-base>
|
||||||
|
<div class="flex skeleton-loading-bg" w-full h-full />
|
||||||
|
</div>
|
||||||
|
<div block sm:hidden class="skeleton-loading-bg" h-8 w-30 rounded-full />
|
||||||
|
</div>
|
||||||
|
<div sm:mt-2 flex="~ col 1 gap-2">
|
||||||
|
<div flex class="skeleton-loading-bg" h-5 w-20 rounded />
|
||||||
|
<div flex class="skeleton-loading-bg" h-4 w-40 rounded />
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
<!-- Note -->
|
||||||
|
<div flex class="skeleton-loading-bg" h-4 my3 w="3/5" rounded />
|
||||||
|
<!-- Follow info -->
|
||||||
|
<div flex justify-between items-center>
|
||||||
|
<div flex class="skeleton-loading-bg" h-4 w="sm:1/2 full" rounded />
|
||||||
|
<div sm:flex hidden class="skeleton-loading-bg" h-8 w-30 rounded-full />
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
</template>
|
31
components/common/CommonAlert.vue
Normal file
31
components/common/CommonAlert.vue
Normal file
|
@ -0,0 +1,31 @@
|
||||||
|
<script lang="ts" setup>
|
||||||
|
const props = withDefaults(defineProps<{
|
||||||
|
modelValue?: boolean
|
||||||
|
}>(), {
|
||||||
|
modelValue: true,
|
||||||
|
})
|
||||||
|
const emits = defineEmits<{
|
||||||
|
(e: 'update:modelValue', v: boolean): void
|
||||||
|
(event: 'close'): void
|
||||||
|
}>()
|
||||||
|
const visible = useVModel(props, 'modelValue', emits, { passive: true })
|
||||||
|
|
||||||
|
function close() {
|
||||||
|
emits('close')
|
||||||
|
visible.value = false
|
||||||
|
}
|
||||||
|
</script>
|
||||||
|
|
||||||
|
<template>
|
||||||
|
<div
|
||||||
|
flex="~ gap-2" justify-between items-center
|
||||||
|
class="border-b border-base text-sm text-secondary px4 py2 sm:py4"
|
||||||
|
>
|
||||||
|
<div>
|
||||||
|
<slot />
|
||||||
|
</div>
|
||||||
|
<button text-xl hover:text-primary bg-hover-overflow w-1.4em h-1.4em @click="close()">
|
||||||
|
<div i-ri:close-line />
|
||||||
|
</button>
|
||||||
|
</div>
|
||||||
|
</template>
|
44
components/common/CommonRouteTabs.vue
Normal file
44
components/common/CommonRouteTabs.vue
Normal file
|
@ -0,0 +1,44 @@
|
||||||
|
<script setup lang="ts">
|
||||||
|
import type { RouteLocationRaw } from 'vue-router'
|
||||||
|
|
||||||
|
const { options, command, replace } = $defineProps<{
|
||||||
|
options: {
|
||||||
|
to: RouteLocationRaw
|
||||||
|
display: string
|
||||||
|
name?: string
|
||||||
|
icon?: string
|
||||||
|
}[]
|
||||||
|
command?: boolean
|
||||||
|
replace?: boolean
|
||||||
|
}>()
|
||||||
|
|
||||||
|
const router = useRouter()
|
||||||
|
|
||||||
|
useCommands(() => command
|
||||||
|
? options.map(tab => ({
|
||||||
|
scope: 'Tabs',
|
||||||
|
|
||||||
|
name: tab.display,
|
||||||
|
icon: tab.icon ?? 'i-ri:file-list-2-line',
|
||||||
|
onActivate: () => router.replace(tab.to),
|
||||||
|
}))
|
||||||
|
: [])
|
||||||
|
</script>
|
||||||
|
|
||||||
|
<template>
|
||||||
|
<div flex w-full items-center lg:text-lg of-x-auto scrollbar-hide>
|
||||||
|
<NuxtLink
|
||||||
|
v-for="(option, index) in options"
|
||||||
|
:key="option?.name || index"
|
||||||
|
:to="option.to"
|
||||||
|
:replace="replace"
|
||||||
|
relative flex flex-auto cursor-pointer sm:px6 px2 rounded transition-all
|
||||||
|
tabindex="1"
|
||||||
|
hover:bg-active transition-100
|
||||||
|
exact-active-class="children:(font-bold !border-primary !op100)"
|
||||||
|
@click="$scrollToTop"
|
||||||
|
>
|
||||||
|
<span ws-nowrap mxa sm:px2 sm:py3 py2 text-center border-b-3 op50 hover:op70 border-transparent>{{ option.display }}</span>
|
||||||
|
</NuxtLink>
|
||||||
|
</div>
|
||||||
|
</template>
|
23
components/common/CommonTrending.vue
Normal file
23
components/common/CommonTrending.vue
Normal file
|
@ -0,0 +1,23 @@
|
||||||
|
<script lang="ts" setup>
|
||||||
|
import type { History } from 'masto'
|
||||||
|
|
||||||
|
const {
|
||||||
|
history,
|
||||||
|
maxDay = 2,
|
||||||
|
} = $defineProps<{
|
||||||
|
history: History[]
|
||||||
|
maxDay?: number
|
||||||
|
}>()
|
||||||
|
|
||||||
|
const ongoingHot = $computed(() => history.slice(0, maxDay))
|
||||||
|
|
||||||
|
const people = $computed(() =>
|
||||||
|
ongoingHot.reduce((total: number, item) => total + (Number(item.accounts) || 0), 0),
|
||||||
|
)
|
||||||
|
</script>
|
||||||
|
|
||||||
|
<template>
|
||||||
|
<p>
|
||||||
|
{{ $t('command.n-people-in-the-past-n-days', [people, maxDay]) }}
|
||||||
|
</p>
|
||||||
|
</template>
|
28
components/common/CommonTrendingCharts.vue
Normal file
28
components/common/CommonTrendingCharts.vue
Normal file
|
@ -0,0 +1,28 @@
|
||||||
|
<script lang="ts" setup>
|
||||||
|
import type { History } from 'masto'
|
||||||
|
import sparkline from '@fnando/sparkline'
|
||||||
|
|
||||||
|
const {
|
||||||
|
history,
|
||||||
|
} = $defineProps<{
|
||||||
|
history?: History[]
|
||||||
|
}>()
|
||||||
|
|
||||||
|
const historyNum = $computed(() => {
|
||||||
|
if (!history)
|
||||||
|
return [1, 1, 1, 1, 1, 1, 1]
|
||||||
|
return [...history].reverse().map(item => Number(item.accounts) || 0)
|
||||||
|
})
|
||||||
|
|
||||||
|
const sparklineEl = $ref<SVGSVGElement>()
|
||||||
|
|
||||||
|
watch([$$(historyNum), $$(sparklineEl)], ([historyNum, sparklineEl]) => {
|
||||||
|
if (!sparklineEl)
|
||||||
|
return
|
||||||
|
sparkline(sparklineEl, historyNum)
|
||||||
|
})
|
||||||
|
</script>
|
||||||
|
|
||||||
|
<template>
|
||||||
|
<svg ref="sparklineEl" class="sparkline" width="60" height="40" stroke-width="3" />
|
||||||
|
</template>
|
|
@ -10,7 +10,7 @@ const props = defineProps<{
|
||||||
}>()
|
}>()
|
||||||
const alt = $computed(() => `${props.card.title} - ${props.card.title}`)
|
const alt = $computed(() => `${props.card.title} - ${props.card.title}`)
|
||||||
const isSquare = $computed(() => props.smallPictureOnly || props.card.width === props.card.height)
|
const isSquare = $computed(() => props.smallPictureOnly || props.card.width === props.card.height)
|
||||||
const description = $computed(() => props.card.description ? props.card.description : new URL(props.card.url).hostname)
|
const providerName = $computed(() => props.card.providerName ? props.card.providerName : new URL(props.card.url).hostname)
|
||||||
|
|
||||||
// TODO: handle card.type: 'photo' | 'video' | 'rich';
|
// TODO: handle card.type: 'photo' | 'video' | 'rich';
|
||||||
</script>
|
</script>
|
||||||
|
@ -32,10 +32,9 @@ const description = $computed(() => props.card.description ? props.card.descript
|
||||||
v-if="card.image"
|
v-if="card.image"
|
||||||
flex flex-col
|
flex flex-col
|
||||||
display-block of-hidden
|
display-block of-hidden
|
||||||
|
|
||||||
border="base"
|
border="base"
|
||||||
:class="{
|
:class="{
|
||||||
'min-w-32 w-32 h-32 border-r': isSquare,
|
'sm:(min-w-32 w-32 h-32) min-w-22 w-22 h-22 border-r': isSquare,
|
||||||
'w-full aspect-[1.91] border-b': !isSquare,
|
'w-full aspect-[1.91] border-b': !isSquare,
|
||||||
'rounded-lg': root,
|
'rounded-lg': root,
|
||||||
}"
|
}"
|
||||||
|
@ -49,19 +48,41 @@ const description = $computed(() => props.card.description ? props.card.descript
|
||||||
w-full h-full object-cover
|
w-full h-full object-cover
|
||||||
/>
|
/>
|
||||||
</div>
|
</div>
|
||||||
<div v-else min-w-32 w-32 h-32 bg="slate-500/10" flex justify-center items-center>
|
<div
|
||||||
|
v-else
|
||||||
|
min-w-22 w-22 h-22 sm="min-w-32 w-32 h-32" bg="slate-500/10" flex justify-center items-center
|
||||||
|
:class="[
|
||||||
|
root ? 'rounded-lg' : '',
|
||||||
|
]"
|
||||||
|
>
|
||||||
<div i-ri:profile-line w="30%" h="30%" text-secondary />
|
<div i-ri:profile-line w="30%" h="30%" text-secondary />
|
||||||
</div>
|
</div>
|
||||||
<div
|
<div
|
||||||
p4 max-h-2xl
|
px3 max-h-2xl
|
||||||
flex flex-col
|
flex flex-col
|
||||||
|
:class="[
|
||||||
|
root ? 'flex-gap-1 py1 sm:py3' : 'py3 justify-center sm:justify-start',
|
||||||
|
]"
|
||||||
>
|
>
|
||||||
<p v-if="card.providerName" text-secondary line-clamp-1 text-ellipsis>
|
<p
|
||||||
{{ card.providerName }}
|
text-secondary ws-pre-wrap break-all
|
||||||
|
:class="[
|
||||||
|
!card.description || root
|
||||||
|
? 'line-clamp-1'
|
||||||
|
: 'hidden sm:line-clamp-1',
|
||||||
|
]"
|
||||||
|
>
|
||||||
|
{{ providerName }}
|
||||||
</p>
|
</p>
|
||||||
<strong v-if="card.title" line-clamp-1 text-ellipsis>{{ card.title }}</strong>
|
<strong
|
||||||
<p v-if="description" text-secondary line-clamp-2 text-ellipsis>
|
v-if="card.title" font-normal sm:font-medium line-clamp-1
|
||||||
{{ description }}
|
break-all ws-pre-wrap
|
||||||
|
>{{ card.title }}</strong>
|
||||||
|
<p
|
||||||
|
v-if="card.description"
|
||||||
|
line-clamp-1 break-all sm:line-clamp-2 sm:break-words text-secondary ws-pre-wrap
|
||||||
|
>
|
||||||
|
{{ card.description }}
|
||||||
</p>
|
</p>
|
||||||
</div>
|
</div>
|
||||||
</NuxtLink>
|
</NuxtLink>
|
||||||
|
|
46
components/status/StatusPreviewCardSkeleton.vue
Normal file
46
components/status/StatusPreviewCardSkeleton.vue
Normal file
|
@ -0,0 +1,46 @@
|
||||||
|
<script setup lang="ts">
|
||||||
|
defineProps<{
|
||||||
|
/** For the preview image, only the small image mode is displayed */
|
||||||
|
square?: boolean
|
||||||
|
/** When it is root card in the list, not appear as a child card */
|
||||||
|
root?: boolean
|
||||||
|
}>()
|
||||||
|
</script>
|
||||||
|
|
||||||
|
<template>
|
||||||
|
<div
|
||||||
|
of-hidden
|
||||||
|
:class="{
|
||||||
|
'flex': square,
|
||||||
|
'p-4': root,
|
||||||
|
'rounded-lg border border-base': !root,
|
||||||
|
}"
|
||||||
|
>
|
||||||
|
<div
|
||||||
|
flex flex-col
|
||||||
|
display-block of-hidden
|
||||||
|
border="base"
|
||||||
|
:class="{
|
||||||
|
'sm:(min-w-32 w-32 h-32) min-w-22 w-22 h-22 border-r': square,
|
||||||
|
'w-full aspect-[1.91] border-b': !square,
|
||||||
|
'rounded-lg': root,
|
||||||
|
}"
|
||||||
|
>
|
||||||
|
<div w-full h-full class="skeleton-loading-bg" />
|
||||||
|
</div>
|
||||||
|
<div
|
||||||
|
px3 max-h-2xl
|
||||||
|
flex-1 flex flex-col flex-gap-2 sm:flex-gap-3
|
||||||
|
:class="[
|
||||||
|
root ? 'py2.5 sm:py3' : 'py3 justify-center sm:justify-start',
|
||||||
|
]"
|
||||||
|
>
|
||||||
|
<div flex class="skeleton-loading-bg" h-4 w-30 rounded :class="root ? '' : 'hidden sm:block'" />
|
||||||
|
<div flex class="skeleton-loading-bg" h-5 w="4/5" rounded />
|
||||||
|
<div flex="~ col gap-2">
|
||||||
|
<div flex class="skeleton-loading-bg" h-4 w-full rounded />
|
||||||
|
<div sm:flex hidden class="skeleton-loading-bg" h-4 w="2/5" rounded />
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
</template>
|
26
components/tag/TagCard.vue
Normal file
26
components/tag/TagCard.vue
Normal file
|
@ -0,0 +1,26 @@
|
||||||
|
<script lang="ts" setup>
|
||||||
|
import type { Tag } from 'masto'
|
||||||
|
|
||||||
|
const {
|
||||||
|
tag,
|
||||||
|
} = $defineProps<{
|
||||||
|
tag: Tag
|
||||||
|
}>()
|
||||||
|
|
||||||
|
const to = $computed(() => new URL(tag.url).pathname)
|
||||||
|
</script>
|
||||||
|
|
||||||
|
<template>
|
||||||
|
<NuxtLink :to="to" block p4 hover:bg-active flex justify-between>
|
||||||
|
<div>
|
||||||
|
<h4 text-size-base leading-normal font-medium line-clamp-1 break-all ws-pre-wrap>
|
||||||
|
<span>#</span>
|
||||||
|
<span hover:underline>{{ tag.name }}</span>
|
||||||
|
</h4>
|
||||||
|
<CommonTrending :history="tag.history" text-sm text-secondary line-clamp-1 ws-pre-wrap break-all />
|
||||||
|
</div>
|
||||||
|
<div flex items-center>
|
||||||
|
<CommonTrendingCharts :history="tag.history" />
|
||||||
|
</div>
|
||||||
|
</NuxtLink>
|
||||||
|
</template>
|
11
components/tag/TagCardSkeleton.vue
Normal file
11
components/tag/TagCardSkeleton.vue
Normal file
|
@ -0,0 +1,11 @@
|
||||||
|
<template>
|
||||||
|
<div p4 flex justify-between>
|
||||||
|
<div flex="~ col 1 gap-2">
|
||||||
|
<div flex class="skeleton-loading-bg" h-5 w-30 rounded />
|
||||||
|
<div flex class="skeleton-loading-bg" h-4 w-45 rounded />
|
||||||
|
</div>
|
||||||
|
<div flex items-center>
|
||||||
|
<div flex class="skeleton-loading-bg" h-9 w-15 rounded />
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
</template>
|
|
@ -1,6 +1,7 @@
|
||||||
<script setup lang="ts">
|
<script setup lang="ts">
|
||||||
// @ts-expect-error missing types
|
// @ts-expect-error missing types
|
||||||
import { DynamicScrollerItem } from 'vue-virtual-scroller'
|
import { DynamicScrollerItem } from 'vue-virtual-scroller'
|
||||||
|
import 'vue-virtual-scroller/dist/vue-virtual-scroller.css'
|
||||||
import type { FilterContext, Paginator, Status, WsEvents } from 'masto'
|
import type { FilterContext, Paginator, Status, WsEvents } from 'masto'
|
||||||
|
|
||||||
const { paginator, stream } = defineProps<{
|
const { paginator, stream } = defineProps<{
|
||||||
|
|
|
@ -12,5 +12,8 @@ export const STORAGE_KEY_FIRST_VISIT = 'elk-first-visit'
|
||||||
export const STORAGE_KEY_ZEN_MODE = 'elk-zenmode'
|
export const STORAGE_KEY_ZEN_MODE = 'elk-zenmode'
|
||||||
export const STORAGE_KEY_LANG = 'elk-lang'
|
export const STORAGE_KEY_LANG = 'elk-lang'
|
||||||
export const STORAGE_KEY_FEATURE_FLAGS = 'elk-feature-flags'
|
export const STORAGE_KEY_FEATURE_FLAGS = 'elk-feature-flags'
|
||||||
|
export const STORAGE_KEY_HIDE_EXPLORE_POSTS_TIPS = 'elk-hide-explore-posts-tips'
|
||||||
|
export const STORAGE_KEY_HIDE_EXPLORE_NEWS_TIPS = 'elk-hide-explore-news-tips'
|
||||||
|
export const STORAGE_KEY_HIDE_EXPLORE_TAGS_TIPS = 'elk-hide-explore-tags-tips'
|
||||||
|
|
||||||
export const HANDLED_MASTO_URLS = /^(https?:\/\/)?([\w\d-]+\.)+\w+\/(@[@\w\d-\.]+)(\/objects)?(\/\d+)?$/
|
export const HANDLED_MASTO_URLS = /^(https?:\/\/)?([\w\d-]+\.)+\w+\/(@[@\w\d-\.]+)(\/objects)?(\/\d+)?$/
|
||||||
|
|
|
@ -50,6 +50,7 @@
|
||||||
"complete": "Complete",
|
"complete": "Complete",
|
||||||
"compose_desc": "Write a new post",
|
"compose_desc": "Write a new post",
|
||||||
"lang": "Languages",
|
"lang": "Languages",
|
||||||
|
"n-people-in-the-past-n-days": "{0} people in the past {1} days",
|
||||||
"select_lang": "Select language",
|
"select_lang": "Select language",
|
||||||
"sign_in_desc": "Add an existing account",
|
"sign_in_desc": "Add an existing account",
|
||||||
"switch_account": "Switch to {0}",
|
"switch_account": "Switch to {0}",
|
||||||
|
@ -70,6 +71,7 @@
|
||||||
},
|
},
|
||||||
"error": {
|
"error": {
|
||||||
"account_not_found": "Account {0} not found",
|
"account_not_found": "Account {0} not found",
|
||||||
|
"explore-list-empty": "Nothing is trending right now. Check back later!",
|
||||||
"status_not_found": "Status not found"
|
"status_not_found": "Status not found"
|
||||||
},
|
},
|
||||||
"feature_flag": {
|
"feature_flag": {
|
||||||
|
@ -172,7 +174,10 @@
|
||||||
"edited": "edited {0}"
|
"edited": "edited {0}"
|
||||||
},
|
},
|
||||||
"tab": {
|
"tab": {
|
||||||
|
"for_you": "For you",
|
||||||
|
"hashtags": "Hashtags",
|
||||||
"media": "Media",
|
"media": "Media",
|
||||||
|
"news": "News",
|
||||||
"notifications_all": "All",
|
"notifications_all": "All",
|
||||||
"notifications_mention": "Mention",
|
"notifications_mention": "Mention",
|
||||||
"posts": "Posts",
|
"posts": "Posts",
|
||||||
|
@ -220,6 +225,9 @@
|
||||||
"add_content_warning": "Add content warning",
|
"add_content_warning": "Add content warning",
|
||||||
"add_media": "Add images, a video or an audio file",
|
"add_media": "Add images, a video or an audio file",
|
||||||
"change_content_visibility": "Change content visibility",
|
"change_content_visibility": "Change content visibility",
|
||||||
|
"explore_links_intro": "These news stories are being talked about by people on this and other servers of the decentralized network right now.",
|
||||||
|
"explore_posts_intro": "These posts from this and other servers in the decentralized network are gaining traction on this server right now.",
|
||||||
|
"explore_tags_intro": "These hashtags are gaining traction among people on this and other servers of the decentralized network right now.",
|
||||||
"toggle_code_block": "Toggle code block"
|
"toggle_code_block": "Toggle code block"
|
||||||
},
|
},
|
||||||
"user": {
|
"user": {
|
||||||
|
|
|
@ -49,6 +49,7 @@
|
||||||
"complete": "完成",
|
"complete": "完成",
|
||||||
"compose_desc": "写一条新帖文",
|
"compose_desc": "写一条新帖文",
|
||||||
"lang": "语言",
|
"lang": "语言",
|
||||||
|
"n-people-in-the-past-n-days": "{0} 人在过去 {1} 天",
|
||||||
"select_lang": "选择语言",
|
"select_lang": "选择语言",
|
||||||
"sign_in_desc": "添加现有帐户",
|
"sign_in_desc": "添加现有帐户",
|
||||||
"switch_account": "切换到{0}",
|
"switch_account": "切换到{0}",
|
||||||
|
@ -69,6 +70,7 @@
|
||||||
},
|
},
|
||||||
"error": {
|
"error": {
|
||||||
"account_not_found": "未找到用户 {0}",
|
"account_not_found": "未找到用户 {0}",
|
||||||
|
"explore-list-empty": "目前没有热门话题,稍后再来看看吧!",
|
||||||
"status_not_found": "未找到帖文"
|
"status_not_found": "未找到帖文"
|
||||||
},
|
},
|
||||||
"feature_flag": {
|
"feature_flag": {
|
||||||
|
@ -168,7 +170,10 @@
|
||||||
"edited": "在 {0} 编辑了"
|
"edited": "在 {0} 编辑了"
|
||||||
},
|
},
|
||||||
"tab": {
|
"tab": {
|
||||||
|
"for_you": "推荐关注",
|
||||||
|
"hashtags": "话题标签",
|
||||||
"media": "媒体",
|
"media": "媒体",
|
||||||
|
"news": "最新消息",
|
||||||
"notifications_all": "全部",
|
"notifications_all": "全部",
|
||||||
"notifications_mention": "提及",
|
"notifications_mention": "提及",
|
||||||
"posts": "帖文",
|
"posts": "帖文",
|
||||||
|
@ -216,6 +221,9 @@
|
||||||
"add_content_warning": "添加内容警告标识",
|
"add_content_warning": "添加内容警告标识",
|
||||||
"add_media": "添加图片、视频或者音频文件",
|
"add_media": "添加图片、视频或者音频文件",
|
||||||
"change_content_visibility": "修改内容是否可见",
|
"change_content_visibility": "修改内容是否可见",
|
||||||
|
"explore_links_intro": "这些新闻故事正被本站和分布式网络上其他站点的用户谈论。",
|
||||||
|
"explore_posts_intro": "来自本站和分布式网络上其他站点的这些嘟文正在本站引起关注。",
|
||||||
|
"explore_tags_intro": "这些标签正在本站和分布式网络上其他站点的用户中引起关注。",
|
||||||
"toggle_code_block": "切换代码块"
|
"toggle_code_block": "切换代码块"
|
||||||
},
|
},
|
||||||
"user": {
|
"user": {
|
||||||
|
|
|
@ -17,6 +17,9 @@
|
||||||
"test": "nr test:unit",
|
"test": "nr test:unit",
|
||||||
"postinstall": "nuxi prepare && simple-git-hooks"
|
"postinstall": "nuxi prepare && simple-git-hooks"
|
||||||
},
|
},
|
||||||
|
"dependencies": {
|
||||||
|
"@fnando/sparkline": "^0.3.10"
|
||||||
|
},
|
||||||
"devDependencies": {
|
"devDependencies": {
|
||||||
"@antfu/eslint-config": "^0.33.1",
|
"@antfu/eslint-config": "^0.33.1",
|
||||||
"@antfu/ni": "^0.18.8",
|
"@antfu/ni": "^0.18.8",
|
||||||
|
@ -38,6 +41,7 @@
|
||||||
"@tiptap/starter-kit": "2.0.0-beta.204",
|
"@tiptap/starter-kit": "2.0.0-beta.204",
|
||||||
"@tiptap/suggestion": "2.0.0-beta.204",
|
"@tiptap/suggestion": "2.0.0-beta.204",
|
||||||
"@tiptap/vue-3": "2.0.0-beta.204",
|
"@tiptap/vue-3": "2.0.0-beta.204",
|
||||||
|
"@types/fnando__sparkline": "^0.3.4",
|
||||||
"@types/fs-extra": "^9.0.13",
|
"@types/fs-extra": "^9.0.13",
|
||||||
"@types/js-yaml": "^4.0.5",
|
"@types/js-yaml": "^4.0.5",
|
||||||
"@types/prettier": "^2.7.1",
|
"@types/prettier": "^2.7.1",
|
||||||
|
|
|
@ -1,8 +1,33 @@
|
||||||
<script setup lang="ts">
|
<script setup lang="ts">
|
||||||
const paginator = useMasto().trends.iterateStatuses()
|
import { invoke } from '@vueuse/shared'
|
||||||
|
|
||||||
const { t } = useI18n()
|
const { t } = useI18n()
|
||||||
|
|
||||||
|
const tabs = $computed(() => [
|
||||||
|
{
|
||||||
|
to: `/${currentServer.value}/explore`,
|
||||||
|
display: t('tab.posts'),
|
||||||
|
},
|
||||||
|
{
|
||||||
|
to: `/${currentServer.value}/explore/tags`,
|
||||||
|
display: t('tab.hashtags'),
|
||||||
|
},
|
||||||
|
{
|
||||||
|
to: `/${currentServer.value}/explore/links`,
|
||||||
|
display: t('tab.news'),
|
||||||
|
},
|
||||||
|
// This section can only be accessed after logging in
|
||||||
|
...invoke(() => currentUser.value
|
||||||
|
? [
|
||||||
|
{
|
||||||
|
to: `/${currentServer.value}/explore/users`,
|
||||||
|
display: t('tab.for_you'),
|
||||||
|
},
|
||||||
|
]
|
||||||
|
: [],
|
||||||
|
),
|
||||||
|
] as const)
|
||||||
|
|
||||||
useHeadFixed({
|
useHeadFixed({
|
||||||
title: () => t('nav_side.explore'),
|
title: () => t('nav_side.explore'),
|
||||||
})
|
})
|
||||||
|
@ -11,15 +36,15 @@ useHeadFixed({
|
||||||
<template>
|
<template>
|
||||||
<MainContent>
|
<MainContent>
|
||||||
<template #title>
|
<template #title>
|
||||||
<NuxtLink to="/explore" text-lg font-bold flex items-center gap-2 @click="$scrollToTop">
|
<span text-lg font-bold flex items-center gap-2 cursor-pointer @click="$scrollToTop">
|
||||||
<div i-ri:hashtag />
|
<div i-ri:hashtag />
|
||||||
<span>{{ t('nav_side.explore') }}</span>
|
<span>{{ t('nav_side.explore') }}</span>
|
||||||
</NuxtLink>
|
</span>
|
||||||
</template>
|
</template>
|
||||||
|
|
||||||
<slot>
|
<template #header>
|
||||||
<!-- TODO: Tabs for trending statuses, tags, and links -->
|
<CommonRouteTabs replace :options="tabs" />
|
||||||
<TimelinePaginator :paginator="paginator" context="public" />
|
</template>
|
||||||
</slot>
|
<NuxtPage />
|
||||||
</MainContent>
|
</MainContent>
|
||||||
</template>
|
</template>
|
||||||
|
|
15
pages/[[server]]/explore/index.vue
Normal file
15
pages/[[server]]/explore/index.vue
Normal file
|
@ -0,0 +1,15 @@
|
||||||
|
<script lang="ts" setup>
|
||||||
|
import { STORAGE_KEY_HIDE_EXPLORE_POSTS_TIPS } from '~~/constants'
|
||||||
|
|
||||||
|
const paginator = useMasto().trends.iterateStatuses()
|
||||||
|
|
||||||
|
const hideNewsTips = useLocalStorage(STORAGE_KEY_HIDE_EXPLORE_POSTS_TIPS, false)
|
||||||
|
</script>
|
||||||
|
|
||||||
|
<template>
|
||||||
|
<CommonAlert v-if="!hideNewsTips" @close="hideNewsTips = true">
|
||||||
|
<p>{{ $t('tooltip.explore_posts_intro') }}</p>
|
||||||
|
</CommonAlert>
|
||||||
|
<!-- TODO: Tabs for trending statuses, tags, and links -->
|
||||||
|
<TimelinePaginator :paginator="paginator" context="public" />
|
||||||
|
</template>
|
25
pages/[[server]]/explore/links.vue
Normal file
25
pages/[[server]]/explore/links.vue
Normal file
|
@ -0,0 +1,25 @@
|
||||||
|
<script lang="ts" setup>
|
||||||
|
// @ts-expect-error missing types
|
||||||
|
import { DynamicScrollerItem } from 'vue-virtual-scroller'
|
||||||
|
import { STORAGE_KEY_HIDE_EXPLORE_NEWS_TIPS } from '~~/constants'
|
||||||
|
const paginator = useMasto().trends.links
|
||||||
|
|
||||||
|
const hideNewsTips = useLocalStorage(STORAGE_KEY_HIDE_EXPLORE_NEWS_TIPS, false)
|
||||||
|
</script>
|
||||||
|
|
||||||
|
<template>
|
||||||
|
<CommonAlert v-if="!hideNewsTips" @close="hideNewsTips = true">
|
||||||
|
<p>{{ $t('tooltip.explore_links_intro') }}</p>
|
||||||
|
</CommonAlert>
|
||||||
|
|
||||||
|
<CommonPaginator v-bind="{ paginator }">
|
||||||
|
<template #default="{ item }">
|
||||||
|
<StatusPreviewCard :card="item" border="!b base" rounded="!none" p="!4" small-picture-only root />
|
||||||
|
</template>
|
||||||
|
<template #loading>
|
||||||
|
<StatusPreviewCardSkeleton square root border="b base" />
|
||||||
|
<StatusPreviewCardSkeleton square root border="b base" op50 />
|
||||||
|
<StatusPreviewCardSkeleton square root border="b base" op25 />
|
||||||
|
</template>
|
||||||
|
</CommonPaginator>
|
||||||
|
</template>
|
42
pages/[[server]]/explore/tags.vue
Normal file
42
pages/[[server]]/explore/tags.vue
Normal file
|
@ -0,0 +1,42 @@
|
||||||
|
<script lang="ts" setup>
|
||||||
|
import type { Tag } from 'masto'
|
||||||
|
import { STORAGE_KEY_HIDE_EXPLORE_TAGS_TIPS } from '~~/constants'
|
||||||
|
|
||||||
|
const { data, pending, error } = useLazyAsyncData(
|
||||||
|
() => useMasto().trends.fetchTags({ limit: 20 }),
|
||||||
|
{ immediate: true },
|
||||||
|
)
|
||||||
|
|
||||||
|
const hideTagsTips = useLocalStorage(STORAGE_KEY_HIDE_EXPLORE_TAGS_TIPS, false)
|
||||||
|
|
||||||
|
function getTagUrl(tag: Tag) {
|
||||||
|
return new URL(tag.url).pathname
|
||||||
|
}
|
||||||
|
</script>
|
||||||
|
|
||||||
|
<template>
|
||||||
|
<CommonAlert v-if="!hideTagsTips && data && data.length" @close="hideTagsTips = true">
|
||||||
|
<p>{{ $t('tooltip.explore_tags_intro') }}</p>
|
||||||
|
</CommonAlert>
|
||||||
|
|
||||||
|
<div v-if="data && data.length">
|
||||||
|
<TagCard v-for="item of data" :key="item.name" :tag="item" border="b base" />
|
||||||
|
|
||||||
|
<div p5 text-center text-secondary-light italic>
|
||||||
|
{{ $t('common.end_of_list') }}
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
<div v-else-if="pending">
|
||||||
|
<TagCardSkeleton border="b base" />
|
||||||
|
<TagCardSkeleton border="b base" />
|
||||||
|
<TagCardSkeleton border="b base" op50 />
|
||||||
|
<TagCardSkeleton border="b base" op50 />
|
||||||
|
<TagCardSkeleton border="b base" op25 />
|
||||||
|
</div>
|
||||||
|
<div v-else-if="error" p5 text-center text-red italic>
|
||||||
|
{{ $t('common.error') }}: {{ error }}
|
||||||
|
</div>
|
||||||
|
<div v-else p5 text-center text-secondary italic>
|
||||||
|
{{ $t('error.explore-list-empty') }}
|
||||||
|
</div>
|
||||||
|
</template>
|
35
pages/[[server]]/explore/users.vue
Normal file
35
pages/[[server]]/explore/users.vue
Normal file
|
@ -0,0 +1,35 @@
|
||||||
|
<script lang="ts" setup>
|
||||||
|
// limit: 20 is the default configuration of the official client
|
||||||
|
const { data, pending, error } = useLazyAsyncData(
|
||||||
|
() => useMasto().suggestions.fetchAll({ limit: 20 }),
|
||||||
|
{ immediate: true },
|
||||||
|
)
|
||||||
|
</script>
|
||||||
|
|
||||||
|
<template>
|
||||||
|
<div v-if="data && data.length">
|
||||||
|
<AccountBigCard
|
||||||
|
v-for="suggestion of data"
|
||||||
|
:key="suggestion.account.id"
|
||||||
|
:account="suggestion.account"
|
||||||
|
as="router-link"
|
||||||
|
:to="getAccountRoute(suggestion.account)"
|
||||||
|
border="b base"
|
||||||
|
/>
|
||||||
|
|
||||||
|
<div p5 text-center text-secondary-light italic>
|
||||||
|
{{ $t('common.end_of_list') }}
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
<div v-else-if="pending">
|
||||||
|
<AccountBigCardSkeleton border="b base" />
|
||||||
|
<AccountBigCardSkeleton border="b base" op50 />
|
||||||
|
<AccountBigCardSkeleton border="b base" op25 />
|
||||||
|
</div>
|
||||||
|
<div v-else-if="error" p5 text-center text-red italic>
|
||||||
|
{{ $t('common.error') }}: {{ error }}
|
||||||
|
</div>
|
||||||
|
<div v-else p5 text-center text-secondary italic>
|
||||||
|
{{ $t('common.not_found') }}
|
||||||
|
</div>
|
||||||
|
</template>
|
|
@ -3,6 +3,7 @@ lockfileVersion: 5.4
|
||||||
specifiers:
|
specifiers:
|
||||||
'@antfu/eslint-config': ^0.33.1
|
'@antfu/eslint-config': ^0.33.1
|
||||||
'@antfu/ni': ^0.18.8
|
'@antfu/ni': ^0.18.8
|
||||||
|
'@fnando/sparkline': ^0.3.10
|
||||||
'@iconify-json/carbon': ^1.1.11
|
'@iconify-json/carbon': ^1.1.11
|
||||||
'@iconify-json/logos': ^1.1.19
|
'@iconify-json/logos': ^1.1.19
|
||||||
'@iconify-json/material-symbols': ^1.1.25
|
'@iconify-json/material-symbols': ^1.1.25
|
||||||
|
@ -21,6 +22,7 @@ specifiers:
|
||||||
'@tiptap/starter-kit': 2.0.0-beta.204
|
'@tiptap/starter-kit': 2.0.0-beta.204
|
||||||
'@tiptap/suggestion': 2.0.0-beta.204
|
'@tiptap/suggestion': 2.0.0-beta.204
|
||||||
'@tiptap/vue-3': 2.0.0-beta.204
|
'@tiptap/vue-3': 2.0.0-beta.204
|
||||||
|
'@types/fnando__sparkline': ^0.3.4
|
||||||
'@types/fs-extra': ^9.0.13
|
'@types/fs-extra': ^9.0.13
|
||||||
'@types/js-yaml': ^4.0.5
|
'@types/js-yaml': ^4.0.5
|
||||||
'@types/prettier': ^2.7.1
|
'@types/prettier': ^2.7.1
|
||||||
|
@ -66,6 +68,9 @@ specifiers:
|
||||||
vue-tsc: ^1.0.11
|
vue-tsc: ^1.0.11
|
||||||
vue-virtual-scroller: 2.0.0-beta.4
|
vue-virtual-scroller: 2.0.0-beta.4
|
||||||
|
|
||||||
|
dependencies:
|
||||||
|
'@fnando/sparkline': 0.3.10
|
||||||
|
|
||||||
devDependencies:
|
devDependencies:
|
||||||
'@antfu/eslint-config': 0.33.1_s5ps7njkmjlaqajutnox5ntcla
|
'@antfu/eslint-config': 0.33.1_s5ps7njkmjlaqajutnox5ntcla
|
||||||
'@antfu/ni': 0.18.8
|
'@antfu/ni': 0.18.8
|
||||||
|
@ -87,6 +92,7 @@ devDependencies:
|
||||||
'@tiptap/starter-kit': 2.0.0-beta.204
|
'@tiptap/starter-kit': 2.0.0-beta.204
|
||||||
'@tiptap/suggestion': 2.0.0-beta.204
|
'@tiptap/suggestion': 2.0.0-beta.204
|
||||||
'@tiptap/vue-3': 2.0.0-beta.204
|
'@tiptap/vue-3': 2.0.0-beta.204
|
||||||
|
'@types/fnando__sparkline': 0.3.4
|
||||||
'@types/fs-extra': 9.0.13
|
'@types/fs-extra': 9.0.13
|
||||||
'@types/js-yaml': 4.0.5
|
'@types/js-yaml': 4.0.5
|
||||||
'@types/prettier': 2.7.1
|
'@types/prettier': 2.7.1
|
||||||
|
@ -626,6 +632,10 @@ packages:
|
||||||
'@floating-ui/core': 0.3.1
|
'@floating-ui/core': 0.3.1
|
||||||
dev: true
|
dev: true
|
||||||
|
|
||||||
|
/@fnando/sparkline/0.3.10:
|
||||||
|
resolution: {integrity: sha512-Rwz2swatdSU5F4sCOvYG8EOWdjtLgq5d8nmnqlZ3PXdWJI9Zq9BRUvJ/9ygjajJG8qOyNpMFX3GEVFjZIuB1Jg==}
|
||||||
|
dev: false
|
||||||
|
|
||||||
/@humanwhocodes/config-array/0.11.7:
|
/@humanwhocodes/config-array/0.11.7:
|
||||||
resolution: {integrity: sha512-kBbPWzN8oVMLb0hOUYXhmxggL/1cJE6ydvjDIGi9EnAGUyA7cLVKQg+d/Dsm+KZwx2czGHrCmMVLiyg8s5JPKw==}
|
resolution: {integrity: sha512-kBbPWzN8oVMLb0hOUYXhmxggL/1cJE6ydvjDIGi9EnAGUyA7cLVKQg+d/Dsm+KZwx2czGHrCmMVLiyg8s5JPKw==}
|
||||||
engines: {node: '>=10.10.0'}
|
engines: {node: '>=10.10.0'}
|
||||||
|
@ -1617,6 +1627,10 @@ packages:
|
||||||
resolution: {integrity: sha512-WulqXMDUTYAXCjZnk6JtIHPigp55cVtDgDrO2gHRwhyJto21+1zbVCtOYB2L1F9w4qCQ0rOGWBnBe0FNTiEJIQ==}
|
resolution: {integrity: sha512-WulqXMDUTYAXCjZnk6JtIHPigp55cVtDgDrO2gHRwhyJto21+1zbVCtOYB2L1F9w4qCQ0rOGWBnBe0FNTiEJIQ==}
|
||||||
dev: true
|
dev: true
|
||||||
|
|
||||||
|
/@types/fnando__sparkline/0.3.4:
|
||||||
|
resolution: {integrity: sha512-FWU1zw7CVJYVeDk77FGphTUabfPims4F/Yq+WFB0Gh647lLtiXHWn8vpfT95Fl65IsNBDOhEbxJdhmERMGubNQ==}
|
||||||
|
dev: true
|
||||||
|
|
||||||
/@types/fs-extra/9.0.13:
|
/@types/fs-extra/9.0.13:
|
||||||
resolution: {integrity: sha512-nEnwB++1u5lVDM2UI4c1+5R+FYaKfaAzS4OococimjVm3nQw3TuzH5UNsocrcTBbhnerblyHj4A49qXbIiZdpA==}
|
resolution: {integrity: sha512-nEnwB++1u5lVDM2UI4c1+5R+FYaKfaAzS4OococimjVm3nQw3TuzH5UNsocrcTBbhnerblyHj4A49qXbIiZdpA==}
|
||||||
dependencies:
|
dependencies:
|
||||||
|
|
|
@ -64,6 +64,7 @@
|
||||||
/* Force vertical scrollbar to be always visible to avoid layout shift while loading the content */
|
/* Force vertical scrollbar to be always visible to avoid layout shift while loading the content */
|
||||||
body {
|
body {
|
||||||
overflow-y: scroll;
|
overflow-y: scroll;
|
||||||
|
-webkit-tap-highlight-color: transparent;
|
||||||
}
|
}
|
||||||
|
|
||||||
.zen .zen-hide {
|
.zen .zen-hide {
|
||||||
|
@ -164,3 +165,13 @@ body {
|
||||||
/* Prevent arbitrary zooming on mobile devices */
|
/* Prevent arbitrary zooming on mobile devices */
|
||||||
touch-action: pan-x pan-y;
|
touch-action: pan-x pan-y;
|
||||||
}
|
}
|
||||||
|
|
||||||
|
.sparkline--fill {
|
||||||
|
fill: var(--c-primary-active);
|
||||||
|
opacity: 0.2;
|
||||||
|
}
|
||||||
|
|
||||||
|
.sparkline--line {
|
||||||
|
stroke: var(--c-primary);
|
||||||
|
stroke-width: 2;
|
||||||
|
}
|
||||||
|
|
|
@ -43,6 +43,7 @@ export default defineConfig({
|
||||||
'flex-center': 'items-center justify-center',
|
'flex-center': 'items-center justify-center',
|
||||||
'flex-v-center': 'items-center',
|
'flex-v-center': 'items-center',
|
||||||
'flex-h-center': 'justify-center',
|
'flex-h-center': 'justify-center',
|
||||||
|
'bg-hover-overflow': 'relative z-0 transition-colors duration-250 after-content-empty after:(absolute inset--2px bg-transparent rounded-lg z--1 transition-colors duration-250) hover:after:(bg-active)',
|
||||||
},
|
},
|
||||||
],
|
],
|
||||||
presets: [
|
presets: [
|
||||||
|
|
Loading…
Reference in a new issue