refactor: migrate to nuxt compatibilityVersion: 4 (#3298)
This commit is contained in:
parent
46e4433e1c
commit
a3fbc056a9
342 changed files with 1200 additions and 2932 deletions
111
app/plugins/0.setup-users.ts
Normal file
111
app/plugins/0.setup-users.ts
Normal file
|
@ -0,0 +1,111 @@
|
|||
import type { UserLogin } from '#shared/types'
|
||||
import { useAsyncIDBKeyval } from '~/composables/idb'
|
||||
import { STORAGE_KEY_USERS } from '~/constants'
|
||||
|
||||
const mock = process.mock
|
||||
|
||||
export default defineNuxtPlugin({
|
||||
enforce: 'pre',
|
||||
parallel: import.meta.server,
|
||||
async setup() {
|
||||
const users = useUsers()
|
||||
|
||||
let defaultUsers = mock ? [mock.user] : []
|
||||
|
||||
// Backward compatibility with localStorage
|
||||
let removeUsersOnLocalStorage = false
|
||||
if (globalThis?.localStorage) {
|
||||
const usersOnLocalStorageString = globalThis.localStorage.getItem(STORAGE_KEY_USERS)
|
||||
if (usersOnLocalStorageString) {
|
||||
defaultUsers = JSON.parse(usersOnLocalStorageString)
|
||||
removeUsersOnLocalStorage = true
|
||||
}
|
||||
}
|
||||
|
||||
if (import.meta.server) {
|
||||
users.value = defaultUsers
|
||||
}
|
||||
|
||||
if (removeUsersOnLocalStorage)
|
||||
globalThis.localStorage.removeItem(STORAGE_KEY_USERS)
|
||||
|
||||
let callback = noop
|
||||
|
||||
// when multiple tabs: we need to reload window when sign in, switch account or sign out
|
||||
if (import.meta.client) {
|
||||
// prevent reloading on the first visit
|
||||
const initialLoad = ref(true)
|
||||
|
||||
callback = () => (initialLoad.value = false)
|
||||
|
||||
const { readIDB } = await useAsyncIDBKeyval<UserLogin[]>(STORAGE_KEY_USERS, defaultUsers, users)
|
||||
|
||||
function reload() {
|
||||
setTimeout(() => {
|
||||
window.location.reload()
|
||||
}, 0)
|
||||
}
|
||||
|
||||
debouncedWatch(
|
||||
() => [currentUserHandle.value, users.value.length] as const,
|
||||
async ([handle, currentUsers], old) => {
|
||||
if (initialLoad.value) {
|
||||
return
|
||||
}
|
||||
|
||||
const oldHandle = old?.[0]
|
||||
|
||||
// read database users: it is not reactive
|
||||
const dbUsers = await readIDB()
|
||||
|
||||
const numberOfUsers = dbUsers?.length || 0
|
||||
|
||||
// sign in or sign out
|
||||
if (currentUsers !== numberOfUsers) {
|
||||
reload()
|
||||
return
|
||||
}
|
||||
|
||||
let sameAcct: boolean
|
||||
// 1. detect account switching
|
||||
if (oldHandle) {
|
||||
sameAcct = handle === oldHandle
|
||||
}
|
||||
else {
|
||||
const acct = currentUser.value?.account?.acct
|
||||
// 2. detect sign-in?
|
||||
sameAcct = !acct || acct === handle
|
||||
}
|
||||
|
||||
if (!sameAcct) {
|
||||
reload()
|
||||
}
|
||||
},
|
||||
{ debounce: 450, flush: 'post', immediate: true },
|
||||
)
|
||||
}
|
||||
|
||||
const { params, query } = useRoute()
|
||||
|
||||
publicServer.value = params.server as string || useRuntimeConfig().public.defaultServer
|
||||
|
||||
const masto = createMasto()
|
||||
const user = (typeof query.server === 'string' && typeof query.token === 'string')
|
||||
? {
|
||||
server: query.server,
|
||||
token: query.token,
|
||||
vapidKey: typeof query.vapid_key === 'string' ? query.vapid_key : undefined,
|
||||
}
|
||||
: (currentUser.value || { server: publicServer.value })
|
||||
|
||||
if (import.meta.client) {
|
||||
loginTo(masto, user).finally(callback)
|
||||
}
|
||||
|
||||
return {
|
||||
provide: {
|
||||
masto,
|
||||
},
|
||||
}
|
||||
},
|
||||
})
|
9
app/plugins/1.scroll-to-top.ts
Normal file
9
app/plugins/1.scroll-to-top.ts
Normal file
|
@ -0,0 +1,9 @@
|
|||
export default defineNuxtPlugin(() => {
|
||||
return {
|
||||
provide: {
|
||||
scrollToTop: () => {
|
||||
window.scrollTo({ top: 0, left: 0, behavior: 'smooth' })
|
||||
},
|
||||
},
|
||||
}
|
||||
})
|
12
app/plugins/color-mode.ts
Normal file
12
app/plugins/color-mode.ts
Normal file
|
@ -0,0 +1,12 @@
|
|||
import { THEME_COLORS } from '~/constants'
|
||||
|
||||
export default defineNuxtPlugin(() => {
|
||||
const colorMode = useColorMode()
|
||||
useHead({
|
||||
meta: [{
|
||||
id: 'theme-color',
|
||||
name: 'theme-color',
|
||||
content: () => colorMode.value === 'dark' ? THEME_COLORS.themeDark : THEME_COLORS.themeLight,
|
||||
}],
|
||||
})
|
||||
})
|
6
app/plugins/floating-vue.ts
Normal file
6
app/plugins/floating-vue.ts
Normal file
|
@ -0,0 +1,6 @@
|
|||
import { defineNuxtPlugin } from '#imports'
|
||||
import FloatingVue from 'floating-vue'
|
||||
|
||||
export default defineNuxtPlugin((nuxtApp) => {
|
||||
nuxtApp.vueApp.use(FloatingVue)
|
||||
})
|
5
app/plugins/hydration.client.ts
Normal file
5
app/plugins/hydration.client.ts
Normal file
|
@ -0,0 +1,5 @@
|
|||
export default defineNuxtPlugin((nuxtApp) => {
|
||||
nuxtApp.hooks.hookOnce('app:suspense:resolve', () => {
|
||||
isHydrated.value = true
|
||||
})
|
||||
})
|
131
app/plugins/magic-keys.client.ts
Normal file
131
app/plugins/magic-keys.client.ts
Normal file
|
@ -0,0 +1,131 @@
|
|||
import type { RouteLocationRaw } from 'vue-router'
|
||||
import { useMagicSequence } from '~/composables/magickeys'
|
||||
import { currentUser, getInstanceDomain } from '~/composables/users'
|
||||
|
||||
export default defineNuxtPlugin(({ $scrollToTop }) => {
|
||||
const keys = useMagicKeys()
|
||||
const router = useRouter()
|
||||
const i18n = useNuxtApp().$i18n
|
||||
const { y } = useWindowScroll({ behavior: 'instant' })
|
||||
const virtualScroller = usePreferences('experimentalVirtualScroller')
|
||||
|
||||
// disable shortcuts when focused on inputs (https://vueuse.org/core/usemagickeys/#conditionally-disable)
|
||||
const activeElement = useActiveElement()
|
||||
|
||||
const notUsingInput = computed(() =>
|
||||
activeElement.value?.tagName !== 'INPUT'
|
||||
&& activeElement.value?.tagName !== 'TEXTAREA'
|
||||
&& !activeElement.value?.isContentEditable,
|
||||
)
|
||||
const isAuthenticated = currentUser.value !== undefined
|
||||
|
||||
const navigateTo = (to: string | RouteLocationRaw) => {
|
||||
closeKeyboardShortcuts()
|
||||
;($scrollToTop as () => void)() // is this really required?
|
||||
router.push(to)
|
||||
}
|
||||
|
||||
whenever(logicAnd(notUsingInput, keys['?']), toggleKeyboardShortcuts)
|
||||
|
||||
const defaultPublishDialog = () => {
|
||||
const current = keys.current
|
||||
// exclusive 'c' - not apply in combination
|
||||
// TODO: bugfix -> create PR for vueuse, reset `current` ref on window focus|blur
|
||||
if (!current.has('shift') && !current.has('meta') && !current.has('control') && !current.has('alt')) {
|
||||
// TODO: is this the correct way of using openPublishDialog()?
|
||||
openPublishDialog('dialog', getDefaultDraftItem())
|
||||
}
|
||||
}
|
||||
whenever(logicAnd(isAuthenticated, notUsingInput, keys.c), defaultPublishDialog)
|
||||
|
||||
const instanceDomain = currentInstance.value ? getInstanceDomain(currentInstance.value) : 'm.webtoo.ls'
|
||||
whenever(logicAnd(notUsingInput, useMagicSequence(['g', 'h'])), () => navigateTo('/home'))
|
||||
whenever(logicAnd(isAuthenticated, notUsingInput, useMagicSequence(['g', 'n'])), () => navigateTo('/notifications'))
|
||||
// TODO: always overridden by 'c' (compose) shortcut
|
||||
whenever(logicAnd(isAuthenticated, notUsingInput, useMagicSequence(['g', 'c'])), () => navigateTo('/conversations'))
|
||||
whenever(logicAnd(isAuthenticated, notUsingInput, useMagicSequence(['g', 'f'])), () => navigateTo('/favourites'))
|
||||
whenever(logicAnd(isAuthenticated, notUsingInput, useMagicSequence(['g', 'b'])), () => navigateTo('/bookmarks'))
|
||||
whenever(logicAnd(notUsingInput, useMagicSequence(['g', 'e'])), () => navigateTo(`/${instanceDomain}/explore`))
|
||||
whenever(logicAnd(notUsingInput, useMagicSequence(['g', 'l'])), () => navigateTo(`/${instanceDomain}/public/local`))
|
||||
whenever(logicAnd(notUsingInput, useMagicSequence(['g', 't'])), () => navigateTo(`/${instanceDomain}/public`))
|
||||
whenever(logicAnd(isAuthenticated, notUsingInput, useMagicSequence(['g', 'i'])), () => navigateTo('/lists'))
|
||||
whenever(logicAnd(notUsingInput, useMagicSequence(['g', 's'])), () => navigateTo('/settings'))
|
||||
whenever(logicAnd(isAuthenticated, notUsingInput, useMagicSequence(['g', 'p'])), () => navigateTo(`/${instanceDomain}/@${currentUser.value?.account.username}`))
|
||||
whenever(logicAnd(notUsingInput, computed(() => keys.current.size === 1), keys['/']), () => navigateTo('/search'))
|
||||
|
||||
const toggleFavouriteActiveStatus = () => {
|
||||
// TODO: find a better solution than clicking buttons...
|
||||
document
|
||||
.querySelector<HTMLElement>('[aria-roledescription=status-details]')
|
||||
?.querySelector<HTMLElement>(`button[aria-label=${i18n.t('action.favourite')}]`)
|
||||
?.click()
|
||||
}
|
||||
whenever(logicAnd(isAuthenticated, notUsingInput, keys.f), toggleFavouriteActiveStatus)
|
||||
|
||||
const toggleBoostActiveStatus = () => {
|
||||
// TODO: find a better solution than clicking buttons...
|
||||
document
|
||||
.querySelector<HTMLElement>('[aria-roledescription=status-details]')
|
||||
?.querySelector<HTMLElement>(`button[aria-label=${i18n.t('action.boost')}]`)
|
||||
?.click()
|
||||
}
|
||||
whenever(logicAnd(isAuthenticated, notUsingInput, keys.b), toggleBoostActiveStatus)
|
||||
|
||||
const showNewItems = () => {
|
||||
// TODO: find a better solution than clicking buttons...
|
||||
document
|
||||
?.querySelector<HTMLElement>('button#elk_show_new_items')
|
||||
?.click()
|
||||
}
|
||||
whenever(logicAnd(isAuthenticated, notUsingInput, keys['.']), showNewItems)
|
||||
|
||||
// TODO: virtual scroller cannot load off-screen post
|
||||
// that prevents focusing next post properly
|
||||
// we disabled this shortcut when enabled virtual scroller
|
||||
if (!virtualScroller.value) {
|
||||
const statusSelector = '[aria-roledescription="status-card"]'
|
||||
|
||||
// find the nearest status element id traversing up from the current active element
|
||||
// `activeElement` can be some of an element within a status element
|
||||
// otherwise, reach to the root `<html>`
|
||||
function getActiveStatueId(element: HTMLElement): string | undefined {
|
||||
if (element.nodeName === 'HTML')
|
||||
return undefined
|
||||
|
||||
if (element.matches(statusSelector))
|
||||
return element.id
|
||||
|
||||
return getActiveStatueId(element.parentNode as HTMLElement)
|
||||
}
|
||||
|
||||
function focusNextOrPreviousStatus(direction: 'next' | 'previous') {
|
||||
const activeStatusId = activeElement.value ? getActiveStatueId(activeElement.value) : undefined
|
||||
const nextOrPreviousStatusId = getNextOrPreviousStatusId(activeStatusId, direction)
|
||||
if (nextOrPreviousStatusId) {
|
||||
const status = document.getElementById(nextOrPreviousStatusId)
|
||||
if (status) {
|
||||
status.focus({ preventScroll: true })
|
||||
const topBarHeight = 58
|
||||
y.value += status.getBoundingClientRect().top - topBarHeight
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
function getNextOrPreviousStatusId(currentStatusId: string | undefined, direction: 'next' | 'previous'): string | undefined {
|
||||
const statusIds = [...document.querySelectorAll(statusSelector)].map(s => s.id)
|
||||
if (currentStatusId === undefined) {
|
||||
// if there is no selection, always focus on the first status
|
||||
return statusIds[0]
|
||||
}
|
||||
|
||||
const currentIndex = statusIds.findIndex(id => id === currentStatusId)
|
||||
const statusId = direction === 'next'
|
||||
? statusIds[Math.min(currentIndex + 1, statusIds.length)]
|
||||
: statusIds[Math.max(0, currentIndex - 1)]
|
||||
return statusId
|
||||
}
|
||||
|
||||
whenever(logicAnd(notUsingInput, keys.j), () => focusNextOrPreviousStatus('next'))
|
||||
whenever(logicAnd(notUsingInput, keys.k), () => focusNextOrPreviousStatus('previous'))
|
||||
}
|
||||
})
|
45
app/plugins/page-lifecycle.client.ts
Normal file
45
app/plugins/page-lifecycle.client.ts
Normal file
|
@ -0,0 +1,45 @@
|
|||
import lifecycle from 'page-lifecycle/dist/lifecycle.mjs'
|
||||
import { ELK_PAGE_LIFECYCLE_FROZEN } from '~/constants'
|
||||
import { closeDatabases } from '~/utils/elk-idb'
|
||||
|
||||
export default defineNuxtPlugin(() => {
|
||||
const state = ref(lifecycle.state)
|
||||
const frozenListeners: (() => void)[] = []
|
||||
const frozenState = useLocalStorage(ELK_PAGE_LIFECYCLE_FROZEN, false)
|
||||
|
||||
lifecycle.addEventListener('statechange', (evt) => {
|
||||
if (evt.newState === 'hidden' && evt.oldState === 'frozen') {
|
||||
frozenState.value = false
|
||||
nextTick().then(() => window.location.reload())
|
||||
return
|
||||
}
|
||||
|
||||
if (evt.newState === 'frozen') {
|
||||
frozenState.value = true
|
||||
frozenListeners.forEach(listener => listener())
|
||||
}
|
||||
else {
|
||||
state.value = evt.newState
|
||||
}
|
||||
})
|
||||
|
||||
const addFrozenListener = (listener: () => void) => {
|
||||
frozenListeners.push(listener)
|
||||
}
|
||||
|
||||
addFrozenListener(() => {
|
||||
if (useAppConfig().pwaEnabled && navigator.serviceWorker.controller)
|
||||
navigator.serviceWorker.controller.postMessage(ELK_PAGE_LIFECYCLE_FROZEN)
|
||||
|
||||
closeDatabases()
|
||||
})
|
||||
|
||||
return {
|
||||
provide: {
|
||||
pageLifecycle: reactive({
|
||||
state,
|
||||
addFrozenListener,
|
||||
}),
|
||||
},
|
||||
}
|
||||
})
|
6
app/plugins/path.ts
Normal file
6
app/plugins/path.ts
Normal file
|
@ -0,0 +1,6 @@
|
|||
export default defineNuxtPlugin({
|
||||
order: -40,
|
||||
setup: (nuxtApp) => {
|
||||
delete nuxtApp.payload.path
|
||||
},
|
||||
})
|
18
app/plugins/setup-global-effects.client.ts
Normal file
18
app/plugins/setup-global-effects.client.ts
Normal file
|
@ -0,0 +1,18 @@
|
|||
import type { OldFontSize } from '~/composables/settings'
|
||||
import { DEFAULT_FONT_SIZE } from '~/constants'
|
||||
import { oldFontSizeMap } from '~/constants/options'
|
||||
|
||||
export default defineNuxtPlugin(() => {
|
||||
const userSettings = useUserSettings()
|
||||
const html = document.documentElement
|
||||
watchEffect(() => {
|
||||
const { fontSize } = userSettings.value
|
||||
html.style.setProperty('--font-size', fontSize ? (oldFontSizeMap[fontSize as OldFontSize] ?? fontSize) : DEFAULT_FONT_SIZE)
|
||||
})
|
||||
watchEffect(() => {
|
||||
html.classList.toggle('zen', getPreferences(userSettings.value, 'zenMode'))
|
||||
})
|
||||
watchEffect(() => {
|
||||
Object.entries(userSettings.value.themeColors || {}).forEach(([k, v]) => html.style.setProperty(k, v))
|
||||
})
|
||||
})
|
38
app/plugins/setup-head-script.server.ts
Normal file
38
app/plugins/setup-head-script.server.ts
Normal file
|
@ -0,0 +1,38 @@
|
|||
import { STORAGE_KEY_CURRENT_USER_HANDLE, STORAGE_KEY_SETTINGS } from '~/constants'
|
||||
import { oldFontSizeMap } from '~/constants/options'
|
||||
|
||||
/**
|
||||
* Injecting scripts before renders
|
||||
*/
|
||||
export default defineNuxtPlugin(() => {
|
||||
useHead({
|
||||
script: [
|
||||
{
|
||||
innerHTML: `
|
||||
;(function() {
|
||||
const handle = localStorage.getItem('${STORAGE_KEY_CURRENT_USER_HANDLE}') || '[anonymous]'
|
||||
const allSettings = JSON.parse(localStorage.getItem('${STORAGE_KEY_SETTINGS}') || '{}')
|
||||
const settings = allSettings[handle]
|
||||
if (!settings) { return }
|
||||
|
||||
const html = document.documentElement
|
||||
${import.meta.dev ? 'console.log({ settings })' : ''}
|
||||
|
||||
if (settings.fontSize) {
|
||||
const oldFontSizeMap = ${JSON.stringify(oldFontSizeMap)}
|
||||
html.style.setProperty('--font-size', oldFontSizeMap[settings.fontSize] || settings.fontSize)
|
||||
}
|
||||
if (settings.language) {
|
||||
html.setAttribute('lang', settings.language)
|
||||
}
|
||||
if (settings.preferences.zenMode) {
|
||||
html.classList.add('zen')
|
||||
}
|
||||
if (settings.themeColors) {
|
||||
Object.entries(settings.themeColors).map(i => html.style.setProperty(i[0], i[1]))
|
||||
}
|
||||
})()`.trim().replace(/\s*\n\s*/g, ';'),
|
||||
},
|
||||
],
|
||||
})
|
||||
})
|
30
app/plugins/setup-i18n.ts
Normal file
30
app/plugins/setup-i18n.ts
Normal file
|
@ -0,0 +1,30 @@
|
|||
import type { Locale } from '#i18n'
|
||||
|
||||
export default defineNuxtPlugin(async (nuxt) => {
|
||||
const t = nuxt.vueApp.config.globalProperties.$t
|
||||
const d = nuxt.vueApp.config.globalProperties.$d
|
||||
const n = nuxt.vueApp.config.globalProperties.$n
|
||||
|
||||
nuxt.vueApp.config.globalProperties.$t = wrapI18n(t)
|
||||
nuxt.vueApp.config.globalProperties.$d = wrapI18n(d)
|
||||
nuxt.vueApp.config.globalProperties.$n = wrapI18n(n)
|
||||
|
||||
if (import.meta.client) {
|
||||
const i18n = useNuxtApp().$i18n
|
||||
const { setLocale, locales } = i18n
|
||||
const userSettings = useUserSettings()
|
||||
const lang = computed(() => userSettings.value.language as Locale)
|
||||
|
||||
const supportLanguages = unref(locales).map(locale => locale.code)
|
||||
if (!supportLanguages.includes(lang.value))
|
||||
userSettings.value.language = getDefaultLanguage(supportLanguages)
|
||||
|
||||
if (lang.value !== i18n.locale)
|
||||
await setLocale(userSettings.value.language as Locale)
|
||||
|
||||
watch([lang, isHydrated], () => {
|
||||
if (isHydrated.value && lang.value !== i18n.locale)
|
||||
setLocale(lang.value)
|
||||
}, { immediate: true })
|
||||
}
|
||||
})
|
19
app/plugins/social.server.ts
Normal file
19
app/plugins/social.server.ts
Normal file
|
@ -0,0 +1,19 @@
|
|||
import { sendRedirect } from 'h3'
|
||||
|
||||
const BOT_RE = /bot\b|index|spider|facebookexternalhit|crawl|wget|slurp|mediapartners-google|whatsapp/i
|
||||
|
||||
export default defineNuxtPlugin(async (nuxtApp) => {
|
||||
const route = useRoute()
|
||||
if (!('server' in route.params))
|
||||
return
|
||||
|
||||
const userAgent = useRequestHeaders()['user-agent']
|
||||
if (!userAgent)
|
||||
return
|
||||
|
||||
const isOpenGraphCrawler = BOT_RE.test(userAgent)
|
||||
if (isOpenGraphCrawler) {
|
||||
// Redirect bots to the original instance to respect their social sharing settings
|
||||
await sendRedirect(nuxtApp.ssrContext!.event, `https:/${route.path}`, 301)
|
||||
}
|
||||
})
|
Loading…
Add table
Add a link
Reference in a new issue