feat: render app shell with ssr to improve loading experience (#448)

This commit is contained in:
Daniel Roe 2022-12-17 16:55:29 +00:00 committed by GitHub
parent b545efeacc
commit 9395b7031e
No known key found for this signature in database
GPG key ID: 4AEE18F83AFDEB23
35 changed files with 169 additions and 127 deletions

View file

@ -1,3 +1,4 @@
*.css *.css
*.png *.png
*.ico *.ico
*.toml

View file

@ -1,7 +1,7 @@
<script setup lang="ts"> <script setup lang="ts">
setupI18n()
setupLogging() setupLogging()
setupPageHeader() setupPageHeader()
await setupI18n()
provideGlobalCommands() provideGlobalCommands()
// We want to trigger rerendering the page when account changes // We want to trigger rerendering the page when account changes
@ -11,7 +11,6 @@ const key = computed(() => `${currentServer.value}:${currentUser.value?.account.
<template> <template>
<NuxtLoadingIndicator color="repeating-linear-gradient(to right,var(--c-primary) 0%,var(--c-primary-active) 100%)" /> <NuxtLoadingIndicator color="repeating-linear-gradient(to right,var(--c-primary) 0%,var(--c-primary-active) 100%)" />
<NuxtLayout :key="key"> <NuxtLayout :key="key">
<NuxtPage /> <NuxtPage v-if="isMastoInitialised" />
</NuxtLayout> </NuxtLayout>
<TeleportTarget id="teleport-end" />
</template> </template>

View file

@ -22,7 +22,7 @@ defineProps<{
</div> </div>
<div flex items-center flex-shrink-0 gap-x-2> <div flex items-center flex-shrink-0 gap-x-2>
<slot name="actions" /> <slot name="actions" />
<NavUser v-if="isMediumScreen" /> <NavUser v-if="isHydrated && isMediumScreen" />
</div> </div>
</div> </div>
<slot name="header" /> <slot name="header" />

View file

@ -29,28 +29,30 @@ useEventListener('keydown', (e: KeyboardEvent) => {
</script> </script>
<template> <template>
<ModalDialog v-model="isSigninDialogOpen" py-4 px-8 max-w-125> <template v-if="isMastoInitialised">
<UserSignIn /> <ModalDialog v-model="isSigninDialogOpen" py-4 px-8 max-w-125>
</ModalDialog> <UserSignIn />
<ModalDialog v-model="isPreviewHelpOpen" max-w-125> </ModalDialog>
<HelpPreview @close="closePreviewHelp()" /> <ModalDialog v-model="isPreviewHelpOpen" max-w-125>
</ModalDialog> <HelpPreview @close="closePreviewHelp()" />
<ModalDialog v-model="isPublishDialogOpen" max-w-180 flex> </ModalDialog>
<!-- This `w-0` style is used to avoid overflow problems in flex layoutsso don't remove it unless you know what you're doing --> <ModalDialog v-model="isPublishDialogOpen" max-w-180 flex>
<PublishWidget :draft-key="dialogDraftKey" expanded flex-1 w-0 /> <!-- This `w-0` style is used to avoid overflow problems in flex layoutsso don't remove it unless you know what you're doing -->
</ModalDialog> <PublishWidget :draft-key="dialogDraftKey" expanded flex-1 w-0 />
<ModalDialog </ModalDialog>
v-model="isMediaPreviewOpen" <ModalDialog
pointer-events-none v-model="isMediaPreviewOpen"
w-full max-w-full h-full max-h-full pointer-events-none
bg-transparent border-0 shadow-none w-full max-w-full h-full max-h-full
> bg-transparent border-0 shadow-none
<ModalMediaPreview v-if="isMediaPreviewOpen" @close="closeMediaPreview()" /> >
</ModalDialog> <ModalMediaPreview v-if="isMediaPreviewOpen" @close="closeMediaPreview()" />
<ModalDialog v-model="isEditHistoryDialogOpen" max-w-125> </ModalDialog>
<StatusEditPreview :edit="statusEdit" /> <ModalDialog v-model="isEditHistoryDialogOpen" max-w-125>
</ModalDialog> <StatusEditPreview :edit="statusEdit" />
<ModalDialog v-model="isCommandPanelOpen" max-w-fit flex> </ModalDialog>
<CommandPanel @close="closeCommandPanel()" /> <ModalDialog v-model="isCommandPanelOpen" max-w-fit flex>
</ModalDialog> <CommandPanel @close="closeCommandPanel()" />
</ModalDialog>
</template>
</template> </template>

View file

@ -137,7 +137,7 @@ export default {
</script> </script>
<template> <template>
<SafeTeleport to="#teleport-end" @transitionend="trapFocusDialog"> <Teleport to="body" @transitionend="trapFocusDialog">
<!-- Dialog component --> <!-- Dialog component -->
<Transition name="dialog-visible"> <Transition name="dialog-visible">
<div <div
@ -173,7 +173,7 @@ export default {
</div> </div>
</div> </div>
</Transition> </Transition>
</SafeTeleport> </Teleport>
</template> </template>
<style lang="postcss" scoped> <style lang="postcss" scoped>

View file

@ -10,7 +10,7 @@ const moreMenuVisible = ref(false)
class="after-content-empty after:(h-[calc(100%+0.5px)] w-0.1px pointer-events-none)" class="after-content-empty after:(h-[calc(100%+0.5px)] w-0.1px pointer-events-none)"
> >
<!-- These weird styles above are used for scroll locking, don't change it unless you know exactly what you're doing. --> <!-- These weird styles above are used for scroll locking, don't change it unless you know exactly what you're doing. -->
<template v-if="currentUser"> <template v-if="isMastoInitialised && currentUser">
<NuxtLink to="/home" :active-class="moreMenuVisible ? '' : 'text-primary'" flex flex-row items-center place-content-center h-full flex-1 @click="$scrollToTop"> <NuxtLink to="/home" :active-class="moreMenuVisible ? '' : 'text-primary'" flex flex-row items-center place-content-center h-full flex-1 @click="$scrollToTop">
<div i-ri:home-5-line /> <div i-ri:home-5-line />
</NuxtLink> </NuxtLink>
@ -24,12 +24,12 @@ const moreMenuVisible = ref(false)
<NuxtLink group :to="`/${currentServer}/public/local`" :active-class="moreMenuVisible ? '' : 'text-primary'" flex flex-row items-center place-content-center h-full flex-1 @click="$scrollToTop"> <NuxtLink group :to="`/${currentServer}/public/local`" :active-class="moreMenuVisible ? '' : 'text-primary'" flex flex-row items-center place-content-center h-full flex-1 @click="$scrollToTop">
<div i-ri:group-2-line /> <div i-ri:group-2-line />
</NuxtLink> </NuxtLink>
<template v-if="!currentUser"> <template v-if="!isMastoInitialised || !currentUser">
<NuxtLink :to="`/${currentServer}/public`" :active-class="moreMenuVisible ? '' : 'text-primary'" flex flex-row items-center place-content-center h-full flex-1 @click="$scrollToTop"> <NuxtLink :to="`/${currentServer}/public`" :active-class="moreMenuVisible ? '' : 'text-primary'" flex flex-row items-center place-content-center h-full flex-1 @click="$scrollToTop">
<div i-ri:earth-line /> <div i-ri:earth-line />
</NuxtLink> </NuxtLink>
</template> </template>
<template v-if="currentUser"> <template v-if="isMastoInitialised && currentUser">
<NuxtLink to="/conversations" :active-class="moreMenuVisible ? '' : 'text-primary'" flex flex-row items-center place-content-center h-full flex-1 @click="$scrollToTop"> <NuxtLink to="/conversations" :active-class="moreMenuVisible ? '' : 'text-primary'" flex flex-row items-center place-content-center h-full flex-1 @click="$scrollToTop">
<div i-ri:at-line /> <div i-ri:at-line />
</NuxtLink> </NuxtLink>

View file

@ -96,7 +96,7 @@ onBeforeUnmount(() => {
</button> </button>
</NavSelectLanguage> </NavSelectLanguage>
<!-- Toggle Feature Flags --> <!-- Toggle Feature Flags -->
<NavSelectFeatureFlags v-if="currentUser"> <NavSelectFeatureFlags v-if="isMastoInitialised && currentUser">
<button <button
flex flex-row items-center flex flex-row items-center
block px-5 py-2 focus-blue w-full block px-5 py-2 focus-blue w-full

View file

@ -30,7 +30,7 @@ const buildTimeAgo = useTimeAgo(buildTime, timeAgoOptions)
</button> </button>
</CommonTooltip> </CommonTooltip>
</NavSelectLanguage> </NavSelectLanguage>
<NavSelectFeatureFlags v-if="currentUser"> <NavSelectFeatureFlags v-if="isMastoInitialised && currentUser">
<CommonTooltip :content="$t('nav_footer.select_feature_flags')"> <CommonTooltip :content="$t('nav_footer.select_feature_flags')">
<button flex :aria-label="$t('nav_footer.select_feature_flags')"> <button flex :aria-label="$t('nav_footer.select_feature_flags')">
<div i-ri:flag-line text-lg /> <div i-ri:flag-line text-lg />
@ -44,7 +44,7 @@ const buildTimeAgo = useTimeAgo(buildTime, timeAgoOptions)
</button> </button>
</div> </div>
<div>{{ $t('app_desc_short') }}</div> <div>{{ $t('app_desc_short') }}</div>
<div> <div v-if="isMastoInitialised">
<i18n-t keypath="nav_footer.built_at"> <i18n-t keypath="nav_footer.built_at">
<time :datetime="buildTime" :title="$d(buildTimeDate, 'long')">{{ buildTimeAgo }}</time> <time :datetime="buildTime" :title="$d(buildTimeDate, 'long')">{{ buildTimeAgo }}</time>
</i18n-t> </i18n-t>

View file

@ -4,7 +4,7 @@ const { notifications } = useNotifications()
<template> <template>
<nav md:px3 md:py4 flex="~ col gap2" text-size-base leading-normal md:text-lg> <nav md:px3 md:py4 flex="~ col gap2" text-size-base leading-normal md:text-lg>
<template v-if="currentUser"> <template v-if="isMastoInitialised && currentUser">
<NavSideItem :text="$t('nav_side.home')" to="/home" icon="i-ri:home-5-line" /> <NavSideItem :text="$t('nav_side.home')" to="/home" icon="i-ri:home-5-line" />
<NavSideItem :text="$t('nav_side.notifications')" to="/notifications" icon="i-ri:notification-4-line"> <NavSideItem :text="$t('nav_side.notifications')" to="/notifications" icon="i-ri:notification-4-line">
<template #icon> <template #icon>
@ -20,12 +20,12 @@ const { notifications } = useNotifications()
<NavSideItem :text="$t('nav_side.explore')" :to="`/${currentServer}/explore`" icon="i-ri:hashtag" /> <NavSideItem :text="$t('nav_side.explore')" :to="`/${currentServer}/explore`" icon="i-ri:hashtag" />
<NavSideItem :text="$t('nav_side.local')" :to="`/${currentServer}/public/local`" icon="i-ri:group-2-line " /> <NavSideItem :text="$t('nav_side.local')" :to="`/${currentServer}/public/local`" icon="i-ri:group-2-line " />
<NavSideItem :text="$t('nav_side.federated')" :to="`/${currentServer}/public`" icon="i-ri:earth-line" /> <NavSideItem :text="$t('nav_side.federated')" :to="`/${currentServer}/public`" icon="i-ri:earth-line" />
<template v-if="currentUser"> <template v-if="isMastoInitialised && currentUser">
<NavSideItem :text="$t('nav_side.conversations')" to="/conversations" icon="i-ri:at-line" /> <NavSideItem :text="$t('nav_side.conversations')" to="/conversations" icon="i-ri:at-line" />
<NavSideItem :text="$t('nav_side.favourites')" to="/favourites" icon="i-ri:heart-3-line" /> <NavSideItem :text="$t('nav_side.favourites')" to="/favourites" icon="i-ri:heart-3-line" />
<NavSideItem :text="$t('nav_side.bookmarks')" to="/bookmarks" icon="i-ri:bookmark-line " /> <NavSideItem :text="$t('nav_side.bookmarks')" to="/bookmarks" icon="i-ri:bookmark-line " />
<NavSideItem <NavSideItem
v-if="isMediumScreen" v-if="isHydrated && isMediumScreen"
:text="currentUser.account.displayName" :text="currentUser.account.displayName"
:to="getAccountRoute(currentUser.account)" :to="getAccountRoute(currentUser.account)"
icon="i-ri:account-circle-line" icon="i-ri:account-circle-line"

View file

@ -25,7 +25,7 @@ useCommand({
</script> </script>
<template> <template>
<NuxtLink :to="to" active-class="text-primary" group focus:outline-none @click="$scrollToTop"> <NuxtLink :to="to" :active-class="isMastoInitialised ? 'text-primary' : ''" group focus:outline-none @click="$scrollToTop">
<div flex w-fit px5 py2 md:gap2 gap4 items-center transition-100 rounded-full group-hover:bg-active group-focus-visible:ring="2 current"> <div flex w-fit px5 py2 md:gap2 gap4 items-center transition-100 rounded-full group-hover:bg-active group-focus-visible:ring="2 current">
<slot name="icon"> <slot name="icon">
<div :class="icon" md:text-size-inherit text-xl /> <div :class="icon" md:text-size-inherit text-xl />

View file

@ -1,5 +1,5 @@
<template> <template>
<VDropdown v-if="currentUser"> <VDropdown v-if="isMastoInitialised && currentUser">
<div style="-webkit-touch-callout: none;"> <div style="-webkit-touch-callout: none;">
<AccountAvatar <AccountAvatar
ref="avatar" ref="avatar"

View file

@ -27,11 +27,11 @@ const description = ref(props.attachment.description ?? '')
v-if="removable" v-if="removable"
aria-label="Remove attachment" aria-label="Remove attachment"
hover:bg="gray/40" transition-100 p-1 rounded-5 cursor-pointer hover:bg="gray/40" transition-100 p-1 rounded-5 cursor-pointer
:class="[isSmallScreen ? '' : 'op-0 group-hover:op-100hover:']" :class="[isHydrated && isSmallScreen ? '' : 'op-0 group-hover:op-100hover:']"
mix-blend-difference mix-blend-difference
@click="$emit('remove')" @click="$emit('remove')"
> >
<div i-ri:close-line text-3 :class="[isSmallScreen ? 'text-6' : 'text-3']" /> <div i-ri:close-line text-3 :class="[isHydrated && isSmallScreen ? 'text-6' : 'text-3']" />
</div> </div>
</div> </div>
<div absolute right-2 bottom-2> <div absolute right-2 bottom-2>

View file

@ -167,7 +167,7 @@ defineExpose({
</script> </script>
<template> <template>
<div v-if="currentUser" flex="~ col gap-4" py4 px2 sm:px4> <div v-if="isMastoInitialised && currentUser" flex="~ col gap-4" py4 px2 sm:px4>
<template v-if="draft.editingStatus"> <template v-if="draft.editingStatus">
<div flex="~ col gap-1"> <div flex="~ col gap-1">
<div id="state-editing" text-secondary self-center> <div id="state-editing" text-secondary self-center>

View file

@ -162,7 +162,7 @@ async function editStatus() {
@click="toggleTranslation" @click="toggleTranslation"
/> />
<template v-if="currentUser"> <template v-if="isMastoInitialised && currentUser">
<template v-if="isAuthor"> <template v-if="isAuthor">
<CommonDropdownItem <CommonDropdownItem
:text="status.pinned ? $t('menu.unpin_on_profile') : $t('menu.pin_on_profile')" :text="status.pinned ? $t('menu.unpin_on_profile') : $t('menu.pin_on_profile')"

View file

@ -1,6 +1,6 @@
<template> <template>
<div p8 flex="~ col gap4"> <div p8 flex="~ col gap4">
<p text-sm> <p v-if="isMastoInitialised" text-sm>
Viewing <strong>{{ currentServer }}</strong> public data Viewing <strong>{{ currentServer }}</strong> public data
</p> </p>
<p text-sm text-secondary> <p text-sm text-secondary>

View file

@ -44,7 +44,7 @@ const switchUser = (user: UserLogin) => {
@click="openSigninDialog" @click="openSigninDialog"
/> />
<CommonDropdownItem <CommonDropdownItem
v-if="currentUser" v-if="isMastoInitialised && currentUser"
:text="$t('user.sign_out_account', [getFullHandle(currentUser.account)])" :text="$t('user.sign_out_account', [getFullHandle(currentUser.account)])"
icon="i-ri:logout-box-line" icon="i-ri:logout-box-line"
@click="signout" @click="signout"

View file

@ -5,7 +5,7 @@ const cache = new LRU<string, any>({
max: 1000, max: 1000,
}) })
if (process.dev) if (process.dev && process.client)
// eslint-disable-next-line no-console // eslint-disable-next-line no-console
console.log({ cache }) console.log({ cache })

View file

@ -2,7 +2,7 @@ import type { Emoji } from 'masto'
import type { Node } from 'ultrahtml' import type { Node } from 'ultrahtml'
import { TEXT_NODE, parse, render, walkSync } from 'ultrahtml' import { TEXT_NODE, parse, render, walkSync } from 'ultrahtml'
const decoder = document.createElement('textarea') const decoder = process.client ? document.createElement('textarea') : null as any as HTMLTextAreaElement
function decode(text: string) { function decode(text: string) {
decoder.innerHTML = text decoder.innerHTML = text
return decoder.value return decoder.value

View file

@ -13,7 +13,7 @@ export function getDefaultFeatureFlags(): FeatureFlags {
} }
} }
export const currentUserFeatureFlags = useUserLocalStorage(STORAGE_KEY_FEATURE_FLAGS, getDefaultFeatureFlags) export const currentUserFeatureFlags = process.server ? computed(getDefaultFeatureFlags) : useUserLocalStorage(STORAGE_KEY_FEATURE_FLAGS, getDefaultFeatureFlags)
export function useFeatureFlags() { export function useFeatureFlags() {
const featureFlags = currentUserFeatureFlags.value const featureFlags = currentUserFeatureFlags.value

14
composables/hydration.ts Normal file
View file

@ -0,0 +1,14 @@
export const isHydrated = computed(() => {
if (process.server)
return false
const nuxtApp = useNuxtApp()
if (!nuxtApp.isHydrating)
return false
const hydrated = ref(false)
nuxtApp.hooks.hookOnce('app:suspense:resolve', () => {
hydrated.value = true
})
return hydrated
})

View file

@ -1,12 +1,11 @@
import type { Ref } from 'vue' import type { Ref } from 'vue'
import type { Account, MastoClient, Relationship, Status } from 'masto' import type { Account, Relationship, Status } from 'masto'
import { withoutProtocol } from 'ufo' import { withoutProtocol } from 'ufo'
import type { ElkMasto } from '~/types'
export const useMasto = () => useNuxtApp().$masto.api as MastoClient export const useMasto = () => useNuxtApp().$masto as ElkMasto
export const setMasto = (masto: MastoClient) => { export const isMastoInitialised = computed(() => process.client && useMasto().loggedIn.value)
useNuxtApp().$masto?.replace(masto)
}
// @unocss-include // @unocss-include
export const STATUS_VISIBILITIES = [ export const STATUS_VISIBILITIES = [

View file

@ -56,23 +56,25 @@ export function usePaginator<T>(paginator: Paginator<any, T[]>, stream?: WsEvent
bound.update() bound.update()
} }
useIntervalFn(() => { if (process.client) {
bound.update() useIntervalFn(() => {
}, 1000) bound.update()
}, 1000)
watch( watch(
() => [isInScreen, state], () => [isInScreen, state],
() => { () => {
if ( if (
isInScreen isInScreen
&& state.value === 'idle' && state.value === 'idle'
// No new content is loaded when the keepAlive page enters the background // No new content is loaded when the keepAlive page enters the background
&& deactivated.value === false && deactivated.value === false
) )
loadNext() loadNext()
}, },
{ immediate: true }, { immediate: true },
) )
}
return { return {
items, items,

View file

@ -19,22 +19,25 @@ export function setupPageHeader() {
export async function setupI18n() { export async function setupI18n() {
const { locale, setLocale, locales } = useI18n() const { locale, setLocale, locales } = useI18n()
const isFirstVisit = !window.localStorage.getItem(STORAGE_KEY_LANG) const nuxtApp = useNuxtApp()
const localeStorage = useLocalStorage(STORAGE_KEY_LANG, locale.value) nuxtApp.hook('app:suspense:resolve', async () => {
const isFirstVisit = process.server ? false : !window.localStorage.getItem(STORAGE_KEY_LANG)
const localeStorage = process.server ? ref('en-US') : useLocalStorage(STORAGE_KEY_LANG, locale.value)
if (isFirstVisit) { if (isFirstVisit) {
const userLang = (navigator.language || 'en-US').toLowerCase() const userLang = (navigator.language || 'en-US').toLowerCase()
// cause vue-i18n not explicit export LocaleObject type // cause vue-i18n not explicit export LocaleObject type
const supportLocales = unref(locales) as { code: string }[] const supportLocales = unref(locales) as { code: string }[]
const lang = supportLocales.find(locale => userLang.startsWith(locale.code.toLowerCase()))?.code const lang = supportLocales.find(locale => userLang.startsWith(locale.code.toLowerCase()))?.code
|| supportLocales.find(locale => userLang.startsWith(locale.code.split('-')[0]))?.code || supportLocales.find(locale => userLang.startsWith(locale.code.split('-')[0]))?.code
localeStorage.value = lang || 'en-US' localeStorage.value = lang || 'en-US'
} }
if (localeStorage.value !== locale.value) if (localeStorage.value !== locale.value)
await setLocale(localeStorage.value) await setLocale(localeStorage.value)
watchEffect(() => { watchEffect(() => {
localeStorage.value = locale.value localeStorage.value = locale.value
})
}) })
} }

View file

@ -2,7 +2,7 @@ import type { Account, Status } from 'masto'
import { STORAGE_KEY_DRAFTS } from '~/constants' import { STORAGE_KEY_DRAFTS } from '~/constants'
import type { Draft, DraftMap } from '~/types' import type { Draft, DraftMap } from '~/types'
export const currentUserDrafts = useUserLocalStorage<DraftMap>(STORAGE_KEY_DRAFTS, () => ({})) export const currentUserDrafts = process.server ? computed<DraftMap>(() => ({})) : useUserLocalStorage<DraftMap>(STORAGE_KEY_DRAFTS, () => ({}))
export function getDefaultDraft(options: Partial<Draft['params'] & Omit<Draft, 'params'>> = {}): Draft { export function getDefaultDraft(options: Partial<Draft['params'] & Omit<Draft, 'params'>> = {}): Draft {
const { const {

View file

@ -8,10 +8,9 @@ export interface TranslationResponse {
} }
} }
const config = useRuntimeConfig()
export const languageCode = process.server ? 'en' : navigator.language.replace(/-.*$/, '') export const languageCode = process.server ? 'en' : navigator.language.replace(/-.*$/, '')
export async function translateText(text: string, from?: string | null, to?: string) { export async function translateText(text: string, from?: string | null, to?: string) {
const config = useRuntimeConfig()
const { translatedText } = await $fetch<TranslationResponse>(config.public.translateApi, { const { translatedText } = await $fetch<TranslationResponse>(config.public.translateApi, {
method: 'POST', method: 'POST',
body: { body: {
@ -41,7 +40,7 @@ export function useTranslation(status: Status) {
} }
return { return {
enabled: !!config.public.translateApi, enabled: !!useRuntimeConfig().public.translateApi,
toggle, toggle,
translation, translation,
} }

View file

@ -73,8 +73,6 @@ export async function loginTo(user?: Omit<UserLogin, 'account'> & { account?: Ac
} }
} }
setMasto(masto)
if ('server' in route.params && user?.token) { if ('server' in route.params && user?.token) {
await router.push({ await router.push({
...route, ...route,
@ -117,6 +115,7 @@ const notifications = reactive<Record<string, undefined | [Promise<WsEvents>, nu
export const useNotifications = () => { export const useNotifications = () => {
const id = currentUser.value?.account.id const id = currentUser.value?.account.id
const masto = useMasto()
const clearNotifications = () => { const clearNotifications = () => {
if (!id || !notifications[id]) if (!id || !notifications[id])
@ -125,10 +124,9 @@ export const useNotifications = () => {
} }
async function connect(): Promise<void> { async function connect(): Promise<void> {
if (!id || notifications[id] || !currentUser.value?.token) if (!isMastoInitialised.value || !id || notifications[id] || !currentUser.value?.token)
return return
const masto = useMasto()
const stream = masto.stream.streamUser() const stream = masto.stream.streamUser()
notifications[id] = [stream, 0] notifications[id] = [stream, 0]
;(await stream).on('notification', () => { ;(await stream).on('notification', () => {

View file

@ -7,7 +7,7 @@
<NavTitle mx3 mt4 mb2 self-start /> <NavTitle mx3 mt4 mb2 self-start />
<div flex="~ col" overflow-y-auto> <div flex="~ col" overflow-y-auto>
<NavSide /> <NavSide />
<PublishButton v-if="currentUser" m5 /> <PublishButton v-if="isMastoInitialised && currentUser" m5 />
<div flex-auto /> <div flex-auto />
</div> </div>
</slot> </slot>
@ -18,15 +18,15 @@
<slot /> <slot />
</div> </div>
<div sticky left-0 right-0 bottom-0 z-10 bg-base pb="[env(safe-area-inset-bottom)]" transition="padding 20"> <div sticky left-0 right-0 bottom-0 z-10 bg-base pb="[env(safe-area-inset-bottom)]" transition="padding 20">
<CommonOfflineChecker :small-screen="isSmallScreen" /> <CommonOfflineChecker :small-screen="isHydrated && isSmallScreen" />
<NavBottom v-if="isSmallScreen" /> <NavBottom v-if="isHydrated && isSmallScreen" />
</div> </div>
</div> </div>
<aside class="hidden md:none lg:block w-1/4 zen-hide"> <aside class="hidden md:none lg:block w-1/4 zen-hide">
<div sticky top-0 h-screen flex="~ col"> <div sticky top-0 h-screen flex="~ col">
<slot name="right"> <slot name="right">
<UserSignInEntry v-if="!currentUser" /> <UserSignInEntry v-if="isMastoInitialised && !currentUser" />
<div v-if="currentUser" py6 px4 w-full flex="~" items-center justify-between> <div v-if="isMastoInitialised && currentUser" py6 px4 w-full flex="~" items-center justify-between>
<NuxtLink <NuxtLink
p2 rounded-full text-start w-full p2 rounded-full text-start w-full
hover:bg-active cursor-pointer transition-100 hover:bg-active cursor-pointer transition-100

View file

@ -1,4 +1,6 @@
export default defineNuxtRouteMiddleware((to) => { export default defineNuxtRouteMiddleware((to) => {
if (process.server)
return
if (!currentUser.value) if (!currentUser.value)
return navigateTo(`/${currentServer.value}/public`) return navigateTo(`/${currentServer.value}/public`)
if (to.path === '/') if (to.path === '/')

View file

@ -6,7 +6,6 @@ import { i18n } from './config/i18n'
const isPreview = process.env.PULL_REQUEST === 'true' const isPreview = process.env.PULL_REQUEST === 'true'
export default defineNuxtConfig({ export default defineNuxtConfig({
ssr: false,
modules: [ modules: [
'@vueuse/nuxt', '@vueuse/nuxt',
'@unocss/nuxt', '@unocss/nuxt',

View file

@ -83,7 +83,6 @@
"unplugin-auto-import": "^0.12.0", "unplugin-auto-import": "^0.12.0",
"vite-plugin-inspect": "^0.7.9", "vite-plugin-inspect": "^0.7.9",
"vitest": "^0.25.3", "vitest": "^0.25.3",
"vue-safe-teleport": "^0.1.1",
"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"
}, },

View file

@ -1,31 +1,60 @@
import type { MastoClient } from 'masto' import type { MastoClient } from 'masto'
import { currentUser } from '../composables/users' import type { ElkMasto } from '~/types'
export default defineNuxtPlugin(async () => { export default defineNuxtPlugin(async (nuxtApp) => {
let masto!: MastoClient const api = shallowRef<MastoClient | null>(null)
try { const apiPromise = ref<Promise<MastoClient> | null>(null)
const initialised = computed(() => !!api.value)
const masto = new Proxy({} as ElkMasto, {
get(_, key: keyof ElkMasto) {
if (key === 'loggedIn')
return initialised
if (key === 'loginTo') {
return (...args: any[]) => {
apiPromise.value = loginTo(...args).then((r) => {
api.value = r
return masto
}).catch(() => {
// Show error page when Mastodon server is down
throw createError({
fatal: true,
statusMessage: 'Could not log into account.',
})
})
return apiPromise
}
}
if (api.value && key in api.value)
return api.value[key as keyof MastoClient]
if (!api) {
return new Proxy({}, {
get(_, subkey) {
return (...args: any[]) => apiPromise.value?.then((r: any) => r[key][subkey](...args))
},
})
}
},
})
if (process.client) {
const { query } = useRoute() const { query } = useRoute()
const user = typeof query.server === 'string' && typeof query.token === 'string' const user = typeof query.server === 'string' && typeof query.token === 'string'
? { server: query.server, token: query.token } ? { server: query.server, token: query.token }
: currentUser.value : currentUser.value
// TODO: improve upstream to make this synchronous (delayed auth) nuxtApp.hook('app:suspense:resolve', () => {
masto = await loginTo(user) // TODO: improve upstream to make this synchronous (delayed auth)
} masto.loginTo(user)
catch {
// Show error page when Mastodon server is down
showError({
fatal: true,
statusMessage: 'Could not log into account.',
}) })
} }
return { return {
provide: { provide: {
masto: shallowReactive({ masto,
replace(api: MastoClient) { this.api = api },
api: masto,
}),
}, },
} }
}) })

View file

@ -1,6 +0,0 @@
import VueSafeTeleport from 'vue-safe-teleport'
import { defineNuxtPlugin } from '#app'
export default defineNuxtPlugin((nuxtApp) => {
nuxtApp.vueApp.use(VueSafeTeleport)
})

View file

@ -63,7 +63,6 @@ specifiers:
unplugin-auto-import: ^0.12.0 unplugin-auto-import: ^0.12.0
vite-plugin-inspect: ^0.7.9 vite-plugin-inspect: ^0.7.9
vitest: ^0.25.3 vitest: ^0.25.3
vue-safe-teleport: ^0.1.1
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
@ -132,7 +131,6 @@ devDependencies:
unplugin-auto-import: 0.12.0 unplugin-auto-import: 0.12.0
vite-plugin-inspect: 0.7.9 vite-plugin-inspect: 0.7.9
vitest: 0.25.3_jsdom@20.0.3 vitest: 0.25.3_jsdom@20.0.3
vue-safe-teleport: 0.1.1
vue-tsc: 1.0.11_typescript@4.9.3 vue-tsc: 1.0.11_typescript@4.9.3
vue-virtual-scroller: 2.0.0-beta.4 vue-virtual-scroller: 2.0.0-beta.4
@ -8710,12 +8708,6 @@ packages:
vue: 3.2.45 vue: 3.2.45
dev: true dev: true
/vue-safe-teleport/0.1.1:
resolution: {integrity: sha512-fHA4mod2oF7am2yEUtT0CsxAwfNBt6hWuYTVWzGxrY8vzxxgHMFnPjdZTKl01qGcKEMYYO38LmWizL7oGMVPGw==}
peerDependencies:
vue: ^3.2.0
dev: true
/vue-template-compiler/2.7.14: /vue-template-compiler/2.7.14:
resolution: {integrity: sha512-zyA5Y3ArvVG0NacJDkkzJuPQDF8RFeRlzV2vLeSnhSpieO6LK2OVbdLPi5MPPs09Ii+gMO8nY4S3iKQxBxDmWQ==} resolution: {integrity: sha512-zyA5Y3ArvVG0NacJDkkzJuPQDF8RFeRlzV2vLeSnhSpieO6LK2OVbdLPi5MPPs09Ii+gMO8nY4S3iKQxBxDmWQ==}
dependencies: dependencies:

View file

@ -1,4 +1,5 @@
import type { Account, AccountCredentials, Attachment, CreateStatusParams, Emoji, Instance, Notification, Status } from 'masto' import type { Account, AccountCredentials, Attachment, CreateStatusParams, Emoji, Instance, MastoClient, Notification, Status } from 'masto'
import type { Ref } from 'vue'
import type { Mutable } from './utils' import type { Mutable } from './utils'
export interface AppInfo { export interface AppInfo {
@ -17,6 +18,11 @@ export interface UserLogin {
account: AccountCredentials account: AccountCredentials
} }
export interface ElkMasto extends MastoClient {
loginTo (user?: Omit<UserLogin, 'account'> & { account?: AccountCredentials }): Promise<MastoClient>
loggedIn: Ref<boolean>
}
export type PaginatorState = 'idle' | 'loading' | 'done' | 'error' export type PaginatorState = 'idle' | 'loading' | 'done' | 'error'
export interface ServerInfo extends Instance { export interface ServerInfo extends Instance {

View file

@ -9,6 +9,10 @@ export default defineConfig({
'~/': `${resolve(__dirname)}/`, '~/': `${resolve(__dirname)}/`,
}, },
}, },
define: {
'process.server': 'false',
'process.client': 'true',
},
plugins: [ plugins: [
Vue(), Vue(),
AutoImport({ AutoImport({