mirror of
https://github.com/elk-zone/elk.git
synced 2024-11-21 17:05:22 +03:00
fix: change page reload and account switch logic (#2975)
This commit is contained in:
parent
54344acf4b
commit
44074ff1a3
9 changed files with 328 additions and 253 deletions
|
@ -8,6 +8,31 @@ const { notifications } = useNotifications()
|
||||||
const useStarFavoriteIcon = usePreferences('useStarFavoriteIcon')
|
const useStarFavoriteIcon = usePreferences('useStarFavoriteIcon')
|
||||||
const lastAccessedNotificationRoute = useLocalStorage(STORAGE_KEY_LAST_ACCESSED_NOTIFICATION_ROUTE, '')
|
const lastAccessedNotificationRoute = useLocalStorage(STORAGE_KEY_LAST_ACCESSED_NOTIFICATION_ROUTE, '')
|
||||||
const lastAccessedExploreRoute = useLocalStorage(STORAGE_KEY_LAST_ACCESSED_EXPLORE_ROUTE, '')
|
const lastAccessedExploreRoute = useLocalStorage(STORAGE_KEY_LAST_ACCESSED_EXPLORE_ROUTE, '')
|
||||||
|
|
||||||
|
const notificationsLink = computed(() => {
|
||||||
|
const hydrated = isHydrated.value
|
||||||
|
const user = currentUser.value
|
||||||
|
const lastRoute = lastAccessedNotificationRoute.value
|
||||||
|
if (!hydrated || !user || !lastRoute) {
|
||||||
|
return '/notifications'
|
||||||
|
}
|
||||||
|
|
||||||
|
return `/notifications/${lastRoute}`
|
||||||
|
})
|
||||||
|
const exploreLink = computed(() => {
|
||||||
|
const hydrated = isHydrated.value
|
||||||
|
const server = currentServer.value
|
||||||
|
let lastRoute = lastAccessedExploreRoute.value
|
||||||
|
if (!hydrated) {
|
||||||
|
return '/explore'
|
||||||
|
}
|
||||||
|
|
||||||
|
if (lastRoute.length) {
|
||||||
|
lastRoute = `/${lastRoute}`
|
||||||
|
}
|
||||||
|
|
||||||
|
return server ? `/${server}/explore${lastRoute}` : `/explore${lastRoute}`
|
||||||
|
})
|
||||||
</script>
|
</script>
|
||||||
|
|
||||||
<template>
|
<template>
|
||||||
|
@ -16,7 +41,7 @@ const lastAccessedExploreRoute = useLocalStorage(STORAGE_KEY_LAST_ACCESSED_EXPLO
|
||||||
|
|
||||||
<div class="spacer" shrink xl:hidden />
|
<div class="spacer" shrink xl:hidden />
|
||||||
<NavSideItem :text="$t('nav.home')" to="/home" icon="i-ri:home-5-line" user-only :command="command" />
|
<NavSideItem :text="$t('nav.home')" to="/home" icon="i-ri:home-5-line" user-only :command="command" />
|
||||||
<NavSideItem :text="$t('nav.notifications')" :to="`/notifications/${lastAccessedNotificationRoute}`" icon="i-ri:notification-4-line" user-only :command="command">
|
<NavSideItem :text="$t('nav.notifications')" :to="notificationsLink" icon="i-ri:notification-4-line" user-only :command="command">
|
||||||
<template #icon>
|
<template #icon>
|
||||||
<div flex relative>
|
<div flex relative>
|
||||||
<div class="i-ri:notification-4-line" text-xl />
|
<div class="i-ri:notification-4-line" text-xl />
|
||||||
|
@ -34,7 +59,7 @@ const lastAccessedExploreRoute = useLocalStorage(STORAGE_KEY_LAST_ACCESSED_EXPLO
|
||||||
<NavSideItem :text="$t('action.compose')" to="/compose" icon="i-ri:quill-pen-line" user-only :command="command" />
|
<NavSideItem :text="$t('action.compose')" to="/compose" icon="i-ri:quill-pen-line" user-only :command="command" />
|
||||||
|
|
||||||
<div class="spacer" shrink hidden sm:block />
|
<div class="spacer" shrink hidden sm:block />
|
||||||
<NavSideItem :text="$t('nav.explore')" :to="isHydrated ? `/${currentServer}/explore/${lastAccessedExploreRoute}` : `/explore/${lastAccessedExploreRoute}`" icon="i-ri:compass-3-line" :command="command" />
|
<NavSideItem :text="$t('nav.explore')" :to="exploreLink" icon="i-ri:compass-3-line" :command="command" />
|
||||||
<NavSideItem :text="$t('nav.local')" :to="isHydrated ? `/${currentServer}/public/local` : '/public/local'" icon="i-ri:group-2-line " :command="command" />
|
<NavSideItem :text="$t('nav.local')" :to="isHydrated ? `/${currentServer}/public/local` : '/public/local'" icon="i-ri:group-2-line " :command="command" />
|
||||||
<NavSideItem :text="$t('nav.federated')" :to="isHydrated ? `/${currentServer}/public` : '/public'" icon="i-ri:earth-line" :command="command" />
|
<NavSideItem :text="$t('nav.federated')" :to="isHydrated ? `/${currentServer}/public` : '/public'" icon="i-ri:earth-line" :command="command" />
|
||||||
<NavSideItem :text="$t('nav.lists')" :to="isHydrated ? `/${currentServer}/lists` : '/lists'" icon="i-ri:list-check" user-only :command="command" />
|
<NavSideItem :text="$t('nav.lists')" :to="isHydrated ? `/${currentServer}/lists` : '/lists'" icon="i-ri:list-check" user-only :command="command" />
|
||||||
|
|
|
@ -1,64 +1,53 @@
|
||||||
import type { MaybeRefOrGetter, RemovableRef } from '@vueuse/core'
|
import type { MaybeRefOrGetter, RemovableRef } from '@vueuse/core'
|
||||||
import type { Ref } from 'vue'
|
|
||||||
import type { UseIDBOptions } from '@vueuse/integrations/useIDBKeyval'
|
import type { UseIDBOptions } from '@vueuse/integrations/useIDBKeyval'
|
||||||
import { del, get, set, update } from '~/utils/elk-idb'
|
import { del, get, set, update } from '~/utils/elk-idb'
|
||||||
|
|
||||||
const isIDBSupported = !process.test && typeof indexedDB !== 'undefined'
|
export interface UseAsyncIDBKeyvalReturn<T> {
|
||||||
|
set: (value: T) => Promise<void>
|
||||||
|
readIDB: () => Promise<T | undefined>
|
||||||
|
}
|
||||||
|
|
||||||
export async function useAsyncIDBKeyval<T>(
|
export async function useAsyncIDBKeyval<T>(
|
||||||
key: IDBValidKey,
|
key: IDBValidKey,
|
||||||
initialValue: MaybeRefOrGetter<T>,
|
initialValue: MaybeRefOrGetter<T>,
|
||||||
options: UseIDBOptions = {},
|
source: RemovableRef<T>,
|
||||||
source?: Ref<T>,
|
options: Omit<UseIDBOptions, 'shallow'> = {},
|
||||||
): Promise<RemovableRef<T>> {
|
): Promise<UseAsyncIDBKeyvalReturn<T>> {
|
||||||
const {
|
const {
|
||||||
flush = 'pre',
|
flush = 'pre',
|
||||||
deep = true,
|
deep = true,
|
||||||
shallow,
|
writeDefaults = true,
|
||||||
onError = (e: unknown) => {
|
onError = (e: unknown) => {
|
||||||
console.error(e)
|
console.error(e)
|
||||||
},
|
},
|
||||||
} = options
|
} = options
|
||||||
|
|
||||||
const data = source ?? (shallow ? shallowRef : ref)(initialValue) as Ref<T>
|
|
||||||
|
|
||||||
const rawInit: T = toValue<T>(initialValue)
|
const rawInit: T = toValue<T>(initialValue)
|
||||||
|
|
||||||
async function read() {
|
try {
|
||||||
if (!isIDBSupported)
|
const rawValue = await get<T>(key)
|
||||||
return
|
if (rawValue === undefined) {
|
||||||
try {
|
if (rawInit !== undefined && rawInit !== null && writeDefaults) {
|
||||||
const rawValue = await get<T>(key)
|
await set(key, rawInit)
|
||||||
if (rawValue === undefined) {
|
source.value = rawInit
|
||||||
if (rawInit !== undefined && rawInit !== null)
|
|
||||||
await set(key, rawInit)
|
|
||||||
}
|
|
||||||
else {
|
|
||||||
data.value = rawValue
|
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
catch (e) {
|
else {
|
||||||
onError(e)
|
source.value = rawValue
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
catch (e) {
|
||||||
|
onError(e)
|
||||||
|
}
|
||||||
|
|
||||||
await read()
|
async function write(data: T) {
|
||||||
|
|
||||||
async function write() {
|
|
||||||
if (!isIDBSupported)
|
|
||||||
return
|
|
||||||
try {
|
try {
|
||||||
if (data.value == null) {
|
if (data == null) {
|
||||||
await del(key)
|
await del(key)
|
||||||
}
|
}
|
||||||
else {
|
else {
|
||||||
// IndexedDB does not support saving proxies, convert from proxy before saving
|
// IndexedDB does not support saving proxies, convert from proxy before saving
|
||||||
if (Array.isArray(data.value))
|
await update(key, () => toRaw(data))
|
||||||
await update(key, () => (JSON.parse(JSON.stringify(data.value))))
|
|
||||||
else if (typeof data.value === 'object')
|
|
||||||
await update(key, () => ({ ...data.value }))
|
|
||||||
else
|
|
||||||
await update(key, () => (data.value))
|
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
catch (e) {
|
catch (e) {
|
||||||
|
@ -66,7 +55,21 @@ export async function useAsyncIDBKeyval<T>(
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
watch(data, () => write(), { flush, deep })
|
const {
|
||||||
|
pause: pauseWatch,
|
||||||
|
resume: resumeWatch,
|
||||||
|
} = watchPausable(source, data => write(data), { flush, deep })
|
||||||
|
|
||||||
return data as RemovableRef<T>
|
async function setData(value: T): Promise<void> {
|
||||||
|
pauseWatch()
|
||||||
|
try {
|
||||||
|
await write(value)
|
||||||
|
source.value = value
|
||||||
|
}
|
||||||
|
finally {
|
||||||
|
resumeWatch()
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
return { set: setData, readIDB: () => get<T>(key) }
|
||||||
}
|
}
|
||||||
|
|
|
@ -19,7 +19,7 @@ const mock = process.mock
|
||||||
|
|
||||||
const users: Ref<UserLogin[]> | RemovableRef<UserLogin[]> = import.meta.server ? ref<UserLogin[]>([]) : ref<UserLogin[]>([]) as RemovableRef<UserLogin[]>
|
const users: Ref<UserLogin[]> | RemovableRef<UserLogin[]> = import.meta.server ? ref<UserLogin[]>([]) : ref<UserLogin[]>([]) as RemovableRef<UserLogin[]>
|
||||||
const nodes = useLocalStorage<Record<string, any>>(STORAGE_KEY_NODES, {}, { deep: true })
|
const nodes = useLocalStorage<Record<string, any>>(STORAGE_KEY_NODES, {}, { deep: true })
|
||||||
const currentUserHandle = useLocalStorage<string>(STORAGE_KEY_CURRENT_USER_HANDLE, mock ? mock.user.account.id : '')
|
export const currentUserHandle = useLocalStorage<string>(STORAGE_KEY_CURRENT_USER_HANDLE, mock ? mock.user.account.id : '')
|
||||||
export const instanceStorage = useLocalStorage<Record<string, mastodon.v1.Instance>>(STORAGE_KEY_SERVERS, mock ? mock.server : {}, { deep: true })
|
export const instanceStorage = useLocalStorage<Record<string, mastodon.v1.Instance>>(STORAGE_KEY_SERVERS, mock ? mock.server : {}, { deep: true })
|
||||||
|
|
||||||
export type ElkInstance = Partial<mastodon.v1.Instance> & {
|
export type ElkInstance = Partial<mastodon.v1.Instance> & {
|
||||||
|
@ -32,17 +32,24 @@ export function getInstanceCache(server: string): mastodon.v1.Instance | undefin
|
||||||
}
|
}
|
||||||
|
|
||||||
export const currentUser = computed<UserLogin | undefined>(() => {
|
export const currentUser = computed<UserLogin | undefined>(() => {
|
||||||
if (currentUserHandle.value) {
|
const handle = currentUserHandle.value
|
||||||
const user = users.value.find(user => user.account?.acct === currentUserHandle.value)
|
const currentUsers = users.value
|
||||||
|
if (handle) {
|
||||||
|
const user = currentUsers.find(user => user.account?.acct === handle)
|
||||||
if (user)
|
if (user)
|
||||||
return user
|
return user
|
||||||
}
|
}
|
||||||
// Fallback to the first account
|
// Fallback to the first account
|
||||||
return users.value[0]
|
return currentUsers.length ? currentUsers[0] : undefined
|
||||||
})
|
})
|
||||||
|
|
||||||
const publicInstance = ref<ElkInstance | null>(null)
|
const publicInstance = ref<ElkInstance | null>(null)
|
||||||
export const currentInstance = computed<null | ElkInstance>(() => currentUser.value ? instanceStorage.value[currentUser.value.server] ?? null : publicInstance.value)
|
export const currentInstance = computed<null | ElkInstance>(() => {
|
||||||
|
const user = currentUser.value
|
||||||
|
const storage = instanceStorage.value
|
||||||
|
const instance = publicInstance.value
|
||||||
|
return user ? storage[user.server] ?? null : instance
|
||||||
|
})
|
||||||
|
|
||||||
export function getInstanceDomain(instance: ElkInstance) {
|
export function getInstanceDomain(instance: ElkInstance) {
|
||||||
return instance.accountDomain || withoutProtocol(instance.uri)
|
return instance.accountDomain || withoutProtocol(instance.uri)
|
||||||
|
@ -55,44 +62,6 @@ export const currentNodeInfo = computed<null | Record<string, any>>(() => nodes.
|
||||||
export const isGotoSocial = computed(() => currentNodeInfo.value?.software?.name === 'gotosocial')
|
export const isGotoSocial = computed(() => currentNodeInfo.value?.software?.name === 'gotosocial')
|
||||||
export const isGlitchEdition = computed(() => currentInstance.value?.version?.includes('+glitch'))
|
export const isGlitchEdition = computed(() => currentInstance.value?.version?.includes('+glitch'))
|
||||||
|
|
||||||
// when multiple tabs: we need to reload window when sign in, switch account or sign out
|
|
||||||
if (import.meta.client) {
|
|
||||||
// fix #2972: now users loaded from idb, we need to wait for it
|
|
||||||
const initialLoad = ref(true)
|
|
||||||
watchOnce(users, () => {
|
|
||||||
initialLoad.value = false
|
|
||||||
}, { immediate: true, flush: 'sync' })
|
|
||||||
|
|
||||||
const windowReload = () => {
|
|
||||||
if (document.visibilityState === 'visible' && !initialLoad.value)
|
|
||||||
window.location.reload()
|
|
||||||
}
|
|
||||||
watch(currentUserHandle, async (handle, oldHandle) => {
|
|
||||||
// when sign in or switch account
|
|
||||||
if (handle) {
|
|
||||||
if (handle === currentUser.value?.account?.acct) {
|
|
||||||
// when sign in, the other tab will not have the user, idb is not reactive
|
|
||||||
const newUser = users.value.find(user => user.account?.acct === handle)
|
|
||||||
// if the user is there, then we are switching account
|
|
||||||
if (newUser) {
|
|
||||||
// check if the change is on current tab: if so, don't reload
|
|
||||||
if (document.hasFocus() || document.visibilityState === 'visible')
|
|
||||||
return
|
|
||||||
}
|
|
||||||
}
|
|
||||||
|
|
||||||
window.addEventListener('visibilitychange', windowReload, { capture: true })
|
|
||||||
}
|
|
||||||
// when sign out
|
|
||||||
else if (oldHandle) {
|
|
||||||
const oldUser = users.value.find(user => user.account?.acct === oldHandle)
|
|
||||||
// when sign out, the other tab will not have the user, idb is not reactive
|
|
||||||
if (oldUser)
|
|
||||||
window.addEventListener('visibilitychange', windowReload, { capture: true })
|
|
||||||
}
|
|
||||||
}, { immediate: true, flush: 'post' })
|
|
||||||
}
|
|
||||||
|
|
||||||
export function useUsers() {
|
export function useUsers() {
|
||||||
return users
|
return users
|
||||||
}
|
}
|
||||||
|
@ -102,7 +71,10 @@ export function useSelfAccount(user: MaybeRefOrGetter<mastodon.v1.Account | unde
|
||||||
|
|
||||||
export const characterLimit = computed(() => currentInstance.value?.configuration?.statuses.maxCharacters ?? DEFAULT_POST_CHARS_LIMIT)
|
export const characterLimit = computed(() => currentInstance.value?.configuration?.statuses.maxCharacters ?? DEFAULT_POST_CHARS_LIMIT)
|
||||||
|
|
||||||
export async function loginTo(masto: ElkMasto, user: Overwrite<UserLogin, { account?: mastodon.v1.AccountCredentials }>) {
|
export async function loginTo(
|
||||||
|
masto: ElkMasto,
|
||||||
|
user: Overwrite<UserLogin, { account?: mastodon.v1.AccountCredentials }>,
|
||||||
|
) {
|
||||||
const { client } = masto
|
const { client } = masto
|
||||||
const instance = mastoLogin(masto, user)
|
const instance = mastoLogin(masto, user)
|
||||||
|
|
||||||
|
@ -303,7 +275,7 @@ export async function signOut() {
|
||||||
if (!currentUserHandle.value)
|
if (!currentUserHandle.value)
|
||||||
await useRouter().push('/')
|
await useRouter().push('/')
|
||||||
|
|
||||||
loginTo(masto, currentUser.value || { server: publicServer.value })
|
await loginTo(masto, currentUser.value || { server: publicServer.value })
|
||||||
}
|
}
|
||||||
|
|
||||||
export function checkLogin() {
|
export function checkLogin() {
|
||||||
|
|
|
@ -1,70 +1,9 @@
|
||||||
<script setup lang="ts">
|
<script setup lang="ts">
|
||||||
import type { mastodon } from 'masto'
|
|
||||||
|
|
||||||
definePageMeta({
|
|
||||||
middleware: 'auth',
|
|
||||||
})
|
|
||||||
|
|
||||||
const { t } = useI18n()
|
const { t } = useI18n()
|
||||||
|
|
||||||
const client = useMastoClient()
|
|
||||||
|
|
||||||
const paginator = client.v1.lists.list()
|
|
||||||
|
|
||||||
useHydratedHead({
|
useHydratedHead({
|
||||||
title: () => t('nav.lists'),
|
title: () => t('nav.lists'),
|
||||||
})
|
})
|
||||||
|
|
||||||
const paginatorRef = ref()
|
|
||||||
const inputRef = ref<HTMLInputElement>()
|
|
||||||
const actionError = ref<string | undefined>(undefined)
|
|
||||||
const busy = ref<boolean>(false)
|
|
||||||
const createText = ref('')
|
|
||||||
const enableSubmit = computed(() => createText.value.length > 0)
|
|
||||||
|
|
||||||
async function createList() {
|
|
||||||
if (busy.value || !enableSubmit.value)
|
|
||||||
return
|
|
||||||
|
|
||||||
busy.value = true
|
|
||||||
actionError.value = undefined
|
|
||||||
await nextTick()
|
|
||||||
try {
|
|
||||||
const newEntry = await client.v1.lists.create({
|
|
||||||
title: createText.value,
|
|
||||||
})
|
|
||||||
paginatorRef.value?.createEntry(newEntry)
|
|
||||||
createText.value = ''
|
|
||||||
}
|
|
||||||
catch (err) {
|
|
||||||
console.error(err)
|
|
||||||
actionError.value = (err as Error).message
|
|
||||||
nextTick(() => {
|
|
||||||
inputRef.value?.focus()
|
|
||||||
})
|
|
||||||
}
|
|
||||||
finally {
|
|
||||||
busy.value = false
|
|
||||||
}
|
|
||||||
}
|
|
||||||
|
|
||||||
function clearError(focusBtn: boolean) {
|
|
||||||
actionError.value = undefined
|
|
||||||
if (focusBtn) {
|
|
||||||
nextTick(() => {
|
|
||||||
inputRef.value?.focus()
|
|
||||||
})
|
|
||||||
}
|
|
||||||
}
|
|
||||||
|
|
||||||
function updateEntry(list: mastodon.v1.List) {
|
|
||||||
paginatorRef.value?.updateEntry(list)
|
|
||||||
}
|
|
||||||
function removeEntry(id: string) {
|
|
||||||
paginatorRef.value?.removeEntry(id)
|
|
||||||
}
|
|
||||||
|
|
||||||
onDeactivated(() => clearError(false))
|
|
||||||
</script>
|
</script>
|
||||||
|
|
||||||
<template>
|
<template>
|
||||||
|
@ -75,80 +14,6 @@ onDeactivated(() => clearError(false))
|
||||||
<span text-lg font-bold>{{ t('nav.lists') }}</span>
|
<span text-lg font-bold>{{ t('nav.lists') }}</span>
|
||||||
</NuxtLink>
|
</NuxtLink>
|
||||||
</template>
|
</template>
|
||||||
<slot>
|
<NuxtPage v-if="isHydrated" />
|
||||||
<CommonPaginator ref="paginatorRef" :paginator="paginator">
|
|
||||||
<template #default="{ item }">
|
|
||||||
<ListEntry
|
|
||||||
:model-value="item"
|
|
||||||
@update:model-value="updateEntry"
|
|
||||||
@list-removed="removeEntry"
|
|
||||||
/>
|
|
||||||
</template>
|
|
||||||
<template #done>
|
|
||||||
<form
|
|
||||||
border="t base"
|
|
||||||
p-4 w-full
|
|
||||||
flex="~ wrap" relative gap-3
|
|
||||||
:aria-describedby="actionError ? 'create-list-error' : undefined"
|
|
||||||
:class="actionError ? 'border border-base border-rounded rounded-be-is-0 rounded-be-ie-0 border-b-unset border-$c-danger-active' : null"
|
|
||||||
@submit.prevent="createList"
|
|
||||||
>
|
|
||||||
<div
|
|
||||||
bg-base border="~ base" flex-1 h10 ps-1 pe-4 rounded-2 w-full flex="~ row"
|
|
||||||
items-center relative focus-within:box-shadow-outline gap-3
|
|
||||||
>
|
|
||||||
<input
|
|
||||||
ref="inputRef"
|
|
||||||
v-model="createText"
|
|
||||||
bg-transparent
|
|
||||||
outline="focus:none"
|
|
||||||
px-4
|
|
||||||
pb="1px"
|
|
||||||
flex-1
|
|
||||||
placeholder-text-secondary
|
|
||||||
:placeholder="$t('list.list_title_placeholder')"
|
|
||||||
@keypress.enter="createList"
|
|
||||||
>
|
|
||||||
</div>
|
|
||||||
<div flex="~ col" gap-y-4 gap-x-2 sm="~ justify-between flex-row">
|
|
||||||
<button flex="~ row" gap-x-2 items-center btn-solid :disabled="!enableSubmit || busy">
|
|
||||||
<span v-if="busy" aria-hidden="true" block animate animate-spin preserve-3d class="rtl-flip">
|
|
||||||
<span block i-ri:loader-2-fill aria-hidden="true" />
|
|
||||||
</span>
|
|
||||||
<span v-else aria-hidden="true" block i-material-symbols:playlist-add-rounded class="rtl-flip" />
|
|
||||||
{{ $t('list.create') }}
|
|
||||||
</button>
|
|
||||||
</div>
|
|
||||||
</form>
|
|
||||||
<CommonErrorMessage
|
|
||||||
v-if="actionError"
|
|
||||||
id="create-list-error"
|
|
||||||
described-by="create-list-failed"
|
|
||||||
class="rounded-bs-is-0 rounded-bs-ie-0 border-t-dashed m-b-2"
|
|
||||||
>
|
|
||||||
<header id="create-list-failed" flex justify-between>
|
|
||||||
<div flex items-center gap-x-2 font-bold>
|
|
||||||
<div aria-hidden="true" i-ri:error-warning-fill />
|
|
||||||
<p>{{ $t('list.error') }}</p>
|
|
||||||
</div>
|
|
||||||
<CommonTooltip placement="bottom" :content="$t('list.clear_error')">
|
|
||||||
<button
|
|
||||||
flex rounded-4 p1 hover:bg-active cursor-pointer transition-100 :aria-label="$t('list.clear_error')"
|
|
||||||
@click="clearError(true)"
|
|
||||||
>
|
|
||||||
<span aria-hidden="true" w="1.75em" h="1.75em" i-ri:close-line />
|
|
||||||
</button>
|
|
||||||
</CommonTooltip>
|
|
||||||
</header>
|
|
||||||
<ol ps-2 sm:ps-1>
|
|
||||||
<li flex="~ col sm:row" gap-y-1 sm:gap-x-2>
|
|
||||||
<strong sr-only>{{ $t('list.error_prefix') }}</strong>
|
|
||||||
<span>{{ actionError }}</span>
|
|
||||||
</li>
|
|
||||||
</ol>
|
|
||||||
</CommonErrorMessage>
|
|
||||||
</template>
|
|
||||||
</CommonPaginator>
|
|
||||||
</slot>
|
|
||||||
</MainContent>
|
</MainContent>
|
||||||
</template>
|
</template>
|
||||||
|
|
144
pages/[[server]]/lists/index.vue
Normal file
144
pages/[[server]]/lists/index.vue
Normal file
|
@ -0,0 +1,144 @@
|
||||||
|
<script setup lang="ts">
|
||||||
|
import type { mastodon } from 'masto'
|
||||||
|
|
||||||
|
definePageMeta({
|
||||||
|
middleware: 'auth',
|
||||||
|
})
|
||||||
|
|
||||||
|
const { t } = useI18n()
|
||||||
|
|
||||||
|
const client = useMastoClient()
|
||||||
|
|
||||||
|
const paginator = client.v1.lists.list()
|
||||||
|
|
||||||
|
useHydratedHead({
|
||||||
|
title: () => t('nav.lists'),
|
||||||
|
})
|
||||||
|
|
||||||
|
const paginatorRef = ref()
|
||||||
|
const inputRef = ref<HTMLInputElement>()
|
||||||
|
const actionError = ref<string | undefined>(undefined)
|
||||||
|
const busy = ref<boolean>(false)
|
||||||
|
const createText = ref('')
|
||||||
|
const enableSubmit = computed(() => createText.value.length > 0)
|
||||||
|
|
||||||
|
async function createList() {
|
||||||
|
if (busy.value || !enableSubmit.value)
|
||||||
|
return
|
||||||
|
|
||||||
|
busy.value = true
|
||||||
|
actionError.value = undefined
|
||||||
|
await nextTick()
|
||||||
|
try {
|
||||||
|
const newEntry = await client.v1.lists.create({
|
||||||
|
title: createText.value,
|
||||||
|
})
|
||||||
|
paginatorRef.value?.createEntry(newEntry)
|
||||||
|
createText.value = ''
|
||||||
|
}
|
||||||
|
catch (err) {
|
||||||
|
console.error(err)
|
||||||
|
actionError.value = (err as Error).message
|
||||||
|
nextTick(() => {
|
||||||
|
inputRef.value?.focus()
|
||||||
|
})
|
||||||
|
}
|
||||||
|
finally {
|
||||||
|
busy.value = false
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
function clearError(focusBtn: boolean) {
|
||||||
|
actionError.value = undefined
|
||||||
|
if (focusBtn) {
|
||||||
|
nextTick(() => {
|
||||||
|
inputRef.value?.focus()
|
||||||
|
})
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
function updateEntry(list: mastodon.v1.List) {
|
||||||
|
paginatorRef.value?.updateEntry(list)
|
||||||
|
}
|
||||||
|
function removeEntry(id: string) {
|
||||||
|
paginatorRef.value?.removeEntry(id)
|
||||||
|
}
|
||||||
|
|
||||||
|
onDeactivated(() => clearError(false))
|
||||||
|
</script>
|
||||||
|
|
||||||
|
<template>
|
||||||
|
<CommonPaginator ref="paginatorRef" :paginator="paginator">
|
||||||
|
<template #default="{ item }">
|
||||||
|
<ListEntry
|
||||||
|
:model-value="item"
|
||||||
|
@update:model-value="updateEntry"
|
||||||
|
@list-removed="removeEntry"
|
||||||
|
/>
|
||||||
|
</template>
|
||||||
|
<template #done>
|
||||||
|
<form
|
||||||
|
border="t base"
|
||||||
|
p-4 w-full
|
||||||
|
flex="~ wrap" relative gap-3
|
||||||
|
:aria-describedby="actionError ? 'create-list-error' : undefined"
|
||||||
|
:class="actionError ? 'border border-base border-rounded rounded-be-is-0 rounded-be-ie-0 border-b-unset border-$c-danger-active' : null"
|
||||||
|
@submit.prevent="createList"
|
||||||
|
>
|
||||||
|
<div
|
||||||
|
bg-base border="~ base" flex-1 h10 ps-1 pe-4 rounded-2 w-full flex="~ row"
|
||||||
|
items-center relative focus-within:box-shadow-outline gap-3
|
||||||
|
>
|
||||||
|
<input
|
||||||
|
ref="inputRef"
|
||||||
|
v-model="createText"
|
||||||
|
bg-transparent
|
||||||
|
outline="focus:none"
|
||||||
|
px-4
|
||||||
|
pb="1px"
|
||||||
|
flex-1
|
||||||
|
placeholder-text-secondary
|
||||||
|
:placeholder="$t('list.list_title_placeholder')"
|
||||||
|
@keypress.enter="createList"
|
||||||
|
>
|
||||||
|
</div>
|
||||||
|
<div flex="~ col" gap-y-4 gap-x-2 sm="~ justify-between flex-row">
|
||||||
|
<button flex="~ row" gap-x-2 items-center btn-solid :disabled="!enableSubmit || busy">
|
||||||
|
<span v-if="busy" aria-hidden="true" block animate animate-spin preserve-3d class="rtl-flip">
|
||||||
|
<span block i-ri:loader-2-fill aria-hidden="true" />
|
||||||
|
</span>
|
||||||
|
<span v-else aria-hidden="true" block i-material-symbols:playlist-add-rounded class="rtl-flip" />
|
||||||
|
{{ $t('list.create') }}
|
||||||
|
</button>
|
||||||
|
</div>
|
||||||
|
</form>
|
||||||
|
<CommonErrorMessage
|
||||||
|
v-if="actionError"
|
||||||
|
id="create-list-error"
|
||||||
|
described-by="create-list-failed"
|
||||||
|
class="rounded-bs-is-0 rounded-bs-ie-0 border-t-dashed m-b-2"
|
||||||
|
>
|
||||||
|
<header id="create-list-failed" flex justify-between>
|
||||||
|
<div flex items-center gap-x-2 font-bold>
|
||||||
|
<div aria-hidden="true" i-ri:error-warning-fill />
|
||||||
|
<p>{{ $t('list.error') }}</p>
|
||||||
|
</div>
|
||||||
|
<CommonTooltip placement="bottom" :content="$t('list.clear_error')">
|
||||||
|
<button
|
||||||
|
flex rounded-4 p1 hover:bg-active cursor-pointer transition-100 :aria-label="$t('list.clear_error')"
|
||||||
|
@click="clearError(true)"
|
||||||
|
>
|
||||||
|
<span aria-hidden="true" w="1.75em" h="1.75em" i-ri:close-line />
|
||||||
|
</button>
|
||||||
|
</CommonTooltip>
|
||||||
|
</header>
|
||||||
|
<ol ps-2 sm:ps-1>
|
||||||
|
<li flex="~ col sm:row" gap-y-1 sm:gap-x-2>
|
||||||
|
<strong sr-only>{{ $t('list.error_prefix') }}</strong>
|
||||||
|
<span>{{ actionError }}</span>
|
||||||
|
</li>
|
||||||
|
</ol>
|
||||||
|
</CommonErrorMessage>
|
||||||
|
</template>
|
||||||
|
</CommonPaginator>
|
||||||
|
</template>
|
|
@ -1,15 +1,6 @@
|
||||||
<script setup lang="ts">
|
<script setup lang="ts">
|
||||||
definePageMeta({
|
|
||||||
middleware: 'auth',
|
|
||||||
})
|
|
||||||
|
|
||||||
const { t } = useI18n()
|
const { t } = useI18n()
|
||||||
|
|
||||||
const { client } = useMasto()
|
|
||||||
const paginator = client.value.v1.followedTags.list({
|
|
||||||
limit: 20,
|
|
||||||
})
|
|
||||||
|
|
||||||
useHydratedHead({
|
useHydratedHead({
|
||||||
title: () => t('nav.hashtags'),
|
title: () => t('nav.hashtags'),
|
||||||
})
|
})
|
||||||
|
@ -24,6 +15,6 @@ useHydratedHead({
|
||||||
</NuxtLink>
|
</NuxtLink>
|
||||||
</template>
|
</template>
|
||||||
|
|
||||||
<TagCardPaginator v-bind="{ paginator }" />
|
<NuxtPage v-if="isHydrated && currentUser" />
|
||||||
</MainContent>
|
</MainContent>
|
||||||
</template>
|
</template>
|
||||||
|
|
20
pages/hashtags/index.vue
Normal file
20
pages/hashtags/index.vue
Normal file
|
@ -0,0 +1,20 @@
|
||||||
|
<script setup lang="ts">
|
||||||
|
definePageMeta({
|
||||||
|
middleware: 'auth',
|
||||||
|
})
|
||||||
|
|
||||||
|
const { t } = useI18n()
|
||||||
|
|
||||||
|
const { client } = useMasto()
|
||||||
|
const paginator = client.value.v1.followedTags.list({
|
||||||
|
limit: 20,
|
||||||
|
})
|
||||||
|
|
||||||
|
useHydratedHead({
|
||||||
|
title: () => t('nav.hashtags'),
|
||||||
|
})
|
||||||
|
</script>
|
||||||
|
|
||||||
|
<template>
|
||||||
|
<TagCardPaginator v-bind="{ paginator }" />
|
||||||
|
</template>
|
|
@ -5,6 +5,7 @@ import { STORAGE_KEY_USERS } from '~/constants'
|
||||||
const mock = process.mock
|
const mock = process.mock
|
||||||
|
|
||||||
export default defineNuxtPlugin({
|
export default defineNuxtPlugin({
|
||||||
|
enforce: 'pre',
|
||||||
parallel: import.meta.server,
|
parallel: import.meta.server,
|
||||||
async setup() {
|
async setup() {
|
||||||
const users = useUsers()
|
const users = useUsers()
|
||||||
|
@ -24,11 +25,87 @@ export default defineNuxtPlugin({
|
||||||
if (import.meta.server) {
|
if (import.meta.server) {
|
||||||
users.value = defaultUsers
|
users.value = defaultUsers
|
||||||
}
|
}
|
||||||
if (import.meta.client) {
|
|
||||||
await useAsyncIDBKeyval<UserLogin[]>(STORAGE_KEY_USERS, defaultUsers, { deep: true }, users)
|
|
||||||
}
|
|
||||||
|
|
||||||
if (removeUsersOnLocalStorage)
|
if (removeUsersOnLocalStorage)
|
||||||
globalThis.localStorage.removeItem(STORAGE_KEY_USERS)
|
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,
|
||||||
|
},
|
||||||
|
}
|
||||||
},
|
},
|
||||||
})
|
})
|
||||||
|
|
|
@ -1,22 +0,0 @@
|
||||||
export default defineNuxtPlugin(() => {
|
|
||||||
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 })
|
|
||||||
|
|
||||||
loginTo(masto, user)
|
|
||||||
|
|
||||||
return {
|
|
||||||
provide: {
|
|
||||||
masto,
|
|
||||||
},
|
|
||||||
}
|
|
||||||
})
|
|
Loading…
Reference in a new issue