refactor: migrate to nuxt compatibilityVersion: 4 (#3298)

This commit is contained in:
Daniel Roe 2025-05-20 15:05:01 +01:00 committed by GitHub
parent 46e4433e1c
commit a3fbc056a9
No known key found for this signature in database
GPG key ID: B5690EEEBB952194
342 changed files with 1200 additions and 2932 deletions

View file

@ -0,0 +1,128 @@
<script setup lang="ts">
import type { ComponentPublicInstance } from 'vue'
// @ts-expect-error missing types
import { DynamicScroller, DynamicScrollerItem } from 'vue-virtual-scroller'
definePageMeta({
name: 'status',
key: route => route.path,
// GoToSocial
alias: ['/:server?/@:account/statuses/:status'],
})
const route = useRoute()
const id = computed(() => route.params.status as string)
const main = ref<ComponentPublicInstance | null>(null)
const { data: status, pending, refresh: refreshStatus } = useAsyncData(
`status:${id.value}`,
() => fetchStatus(id.value, true),
{ watch: [isHydrated], immediate: isHydrated.value, default: () => shallowRef() },
)
const { client } = useMasto()
const { data: context, pending: pendingContext, refresh: refreshContext } = useAsyncData(
`context:${id.value}`,
async () => client.value.v1.statuses.$select(id.value).context.fetch(),
{ watch: [isHydrated], immediate: isHydrated.value, lazy: true, default: () => shallowRef() },
)
if (pendingContext)
watchOnce(pendingContext, scrollTo)
if (pending)
watchOnce(pending, scrollTo)
async function scrollTo() {
await nextTick()
const statusElement = unrefElement(main)
if (!statusElement)
return
statusElement.scrollIntoView(true)
}
const publishWidget = ref()
function focusEditor() {
return publishWidget.value?.focusEditor?.()
}
provide('focus-editor', focusEditor)
watch(publishWidget, () => {
if (window.history.state.focusReply)
focusEditor()
})
const replyDraft = computed(() => status.value ? getReplyDraft(status.value) : null)
onReactivated(() => {
// Silently update data when reentering the page
// The user will see the previous content first, and any changes will be updated to the UI when the request is completed
refreshStatus()
refreshContext()
})
</script>
<template>
<MainContent back>
<template v-if="!pending">
<template v-if="status">
<div xl:mt-4 mb="50vh" border="b base">
<template v-if="!pendingContext">
<StatusCard
v-for="(comment, i) of context?.ancestors" :key="comment.id"
:status="comment" :actions="comment.visibility !== 'direct'" context="account"
:has-older="true" :newer="context?.ancestors[i - 1]"
/>
</template>
<StatusDetails
ref="main"
:status="status"
:newer="context?.ancestors.at(-1)"
command
style="scroll-margin-top: 60px"
@refetch-status="refreshStatus()"
/>
<PublishWidgetList
v-if="currentUser"
ref="publishWidget"
class="border-y border-base"
:draft-key="replyDraft!.key"
:initial="replyDraft!.draft"
@published="refreshContext()"
/>
<template v-if="!pendingContext">
<DynamicScroller
v-slot="{ item, index, active }"
:items="context?.descendants || []"
:min-item-size="200"
:buffer="800"
key-field="id"
page-mode
>
<DynamicScrollerItem :item="item" :active="active">
<StatusCard
:key="item.id"
:status="item"
context="account"
:older="context?.descendants[index + 1]"
:newer="index > 0 ? context?.descendants[index - 1] : status"
:has-newer="index === 0"
:main="status"
/>
</DynamicScrollerItem>
</DynamicScroller>
</template>
</div>
</template>
<StatusNotFound v-else :account="route.params.account as string" :status="id" />
</template>
<StatusCardSkeleton v-else border="b base" />
<TimelineSkeleton v-if="pending || pendingContext" />
</MainContent>
</template>

View file

@ -0,0 +1,54 @@
<script setup lang="ts">
definePageMeta({
key: route => `${route.params.server ?? currentServer.value}:${route.params.account}`,
})
const params = useRoute().params
const accountName = computed(() => toShortHandle(params.account as string))
const { t } = useI18n()
const { data: account, pending, refresh } = await useAsyncData(() => `account-${accountName.value}`, () => fetchAccountByHandle(accountName.value).catch(() => null), { immediate: import.meta.client, default: () => shallowRef() })
const relationship = computed(() => account.value ? useRelationship(account.value).value : undefined)
const userSettings = useUserSettings()
onReactivated(() => {
// Silently update data when reentering the page
// The user will see the previous content first, and any changes will be updated to the UI when the request is completed
refresh()
})
</script>
<template>
<MainContent back>
<template #title>
<ContentRich
timeline-title-style
:content="account ? getDisplayName(account) : t('nav.profile')"
:show-emojis="!getPreferences(userSettings, 'hideUsernameEmojis')"
:markdown="false"
/>
</template>
<template v-if="pending" />
<template v-else-if="account">
<AccountMoved v-if="account.moved" :account="account" />
<AccountHeader :account="account" command border="b base" :class="{ 'op-50 grayscale-50': !!account.moved }" />
<div v-if="relationship?.blockedBy" h-30 flex="~ col center gap-2">
<div text-secondary>
{{ $t('account.profile_unavailable') }}
</div>
<div text-secondary-light text-sm>
{{ $t('account.blocked_by') }}
</div>
</div>
<NuxtPage v-else />
</template>
<CommonNotFound v-else>
{{ $t('error.account_not_found', [`@${accountName}`]) }}
</CommonNotFound>
</MainContent>
</template>

View file

@ -0,0 +1,24 @@
<script setup lang="ts">
const { t } = useI18n()
const params = useRoute().params
const handle = computed(() => params.account as string)
definePageMeta({ name: 'account-followers' })
const account = await fetchAccountByHandle(handle.value)
const paginator = account ? useMastoClient().v1.accounts.$select(account.id).followers.list() : null
const isSelf = useSelfAccount(account)
if (account) {
useHydratedHead({
title: () => `${t('account.followers')} | ${getDisplayName(account)} (@${account.acct})`,
})
}
</script>
<template>
<template v-if="paginator">
<AccountPaginator :paginator="paginator" :relationship-context="isSelf ? 'followedBy' : undefined" context="followers" :account="account" />
</template>
</template>

View file

@ -0,0 +1,24 @@
<script setup lang="ts">
const { t } = useI18n()
const params = useRoute().params
const handle = computed(() => params.account as string)
definePageMeta({ name: 'account-following' })
const account = await fetchAccountByHandle(handle.value)
const paginator = account ? useMastoClient().v1.accounts.$select(account.id).following.list() : null
const isSelf = useSelfAccount(account)
if (account) {
useHydratedHead({
title: () => `${t('account.following')} | ${getDisplayName(account)} (@${account.acct})`,
})
}
</script>
<template>
<template v-if="paginator">
<AccountPaginator :paginator="paginator" :relationship-context="isSelf ? 'following' : undefined" context="following" :account="account" />
</template>
</template>

View file

@ -0,0 +1,44 @@
<script setup lang="ts">
import type { mastodon } from 'masto'
const params = useRoute().params
const handle = computed(() => params.account as string)
definePageMeta({ name: 'account-index' })
const { t } = useI18n()
const account = await fetchAccountByHandle(handle.value)
// we need to ensure `pinned === true` on status
// because this prop is appeared only on current account's posts
function applyPinned(statuses: mastodon.v1.Status[]) {
return statuses.map((status) => {
status.pinned = true
return status
})
}
function reorderAndFilter(items: mastodon.v1.Status[]) {
return reorderedTimeline(items, 'account')
}
const pinnedPaginator = useMastoClient().v1.accounts.$select(account.id).statuses.list({ pinned: true })
const postPaginator = useMastoClient().v1.accounts.$select(account.id).statuses.list({ limit: 30, excludeReplies: true })
if (account) {
useHydratedHead({
title: () => `${t('nav.profile')} | ${getDisplayName(account)} (@${account.acct})`,
})
}
</script>
<template>
<div>
<AccountTabs />
<TimelinePaginator :paginator="pinnedPaginator" :preprocess="applyPinned" context="account" :account="account" :end-message="false" />
<!-- Upper border -->
<div h="1px" w-auto bg-border mb-1 />
<TimelinePaginator :paginator="postPaginator" :preprocess="reorderAndFilter" context="account" :account="account" />
</div>
</template>

View file

@ -0,0 +1,24 @@
<script setup lang="ts">
definePageMeta({ name: 'account-media' })
const { t } = useI18n()
const params = useRoute().params
const handle = computed(() => params.account as string)
const account = await fetchAccountByHandle(handle.value)
const paginator = useMastoClient().v1.accounts.$select(account.id).statuses.list({ onlyMedia: true, excludeReplies: false })
if (account) {
useHydratedHead({
title: () => `${t('tab.media')} | ${getDisplayName(account)} (@${account.acct})`,
})
}
</script>
<template>
<div>
<AccountTabs />
<TimelinePaginator :paginator="paginator" :preprocess="reorderedTimeline" context="account" :account="account" />
</div>
</template>

View file

@ -0,0 +1,24 @@
<script setup lang="ts">
definePageMeta({ name: 'account-replies' })
const { t } = useI18n()
const params = useRoute().params
const handle = computed(() => params.account as string)
const account = await fetchAccountByHandle(handle.value)
const paginator = useMastoClient().v1.accounts.$select(account.id).statuses.list({ excludeReplies: false })
if (account) {
useHydratedHead({
title: () => `${t('tab.posts_with_replies')} | ${getDisplayName(account)} (@${account.acct})`,
})
}
</script>
<template>
<div>
<AccountTabs />
<TimelinePaginator :paginator="paginator" :preprocess="reorderedTimeline" context="account" :account="account" />
</div>
</template>

View file

@ -0,0 +1,56 @@
<script setup lang="ts">
import type { CommonRouteTabOption } from '#shared/types'
const { t } = useI18n()
const search = ref<{ input?: HTMLInputElement }>()
const route = useRoute()
watchEffect(() => {
if (isMediumOrLargeScreen && route.name === 'explore' && search.value?.input)
search.value?.input?.focus()
})
onActivated(() =>
search.value?.input?.focus(),
)
onDeactivated(() => search.value?.input?.blur())
const userSettings = useUserSettings()
const tabs = computed<CommonRouteTabOption[]>(() => [
{
to: isHydrated.value ? `/${currentServer.value}/explore` : '/explore',
display: t('tab.posts'),
},
{
to: isHydrated.value ? `/${currentServer.value}/explore/tags` : '/explore/tags',
display: t('tab.hashtags'),
},
{
to: isHydrated.value ? `/${currentServer.value}/explore/links` : '/explore/links',
display: t('tab.news'),
hide: userSettings.value.preferences.hideNews,
},
// This section can only be accessed after logging in
{
to: isHydrated.value ? `/${currentServer.value}/explore/users` : '/explore/users',
display: t('tab.for_you'),
disabled: !isHydrated.value || !currentUser.value,
},
])
</script>
<template>
<MainContent>
<template #title>
<span timeline-title-style flex items-center gap-2 cursor-pointer @click="$scrollToTop">
<div i-ri:compass-3-line />
<span>{{ t('nav.explore') }}</span>
</span>
</template>
<template #header>
<CommonRouteTabs replace :options="tabs" />
</template>
<NuxtPage v-if="isHydrated" />
</MainContent>
</template>

View file

@ -0,0 +1,29 @@
<script setup lang="ts">
import { STORAGE_KEY_HIDE_EXPLORE_POSTS_TIPS, STORAGE_KEY_LAST_ACCESSED_EXPLORE_ROUTE } from '~/constants'
const { t } = useI18n()
const route = useRoute()
const paginator = useMastoClient().v1.trends.statuses.list()
const hideNewsTips = useLocalStorage(STORAGE_KEY_HIDE_EXPLORE_POSTS_TIPS, false)
useHydratedHead({
title: () => `${t('tab.posts')} | ${t('nav.explore')}`,
})
const lastAccessedExploreRoute = useLocalStorage(STORAGE_KEY_LAST_ACCESSED_EXPLORE_ROUTE, '')
lastAccessedExploreRoute.value = route.path.replace(/(.*\/explore\/?)/, '')
onActivated(() => {
lastAccessedExploreRoute.value = route.path.replace(/(.*\/explore\/?)/, '')
})
</script>
<template>
<CommonAlert v-if="isHydrated && !hideNewsTips" @close="hideNewsTips = true">
<p>{{ $t('tooltip.explore_posts_intro') }}</p>
</CommonAlert>
<!-- TODO: Tabs for trending statuses, tags, and links -->
<TimelinePaginator v-if="isHydrated" :paginator="paginator" context="public" />
</template>

View file

@ -0,0 +1,38 @@
<script setup lang="ts">
import { STORAGE_KEY_HIDE_EXPLORE_NEWS_TIPS, STORAGE_KEY_LAST_ACCESSED_EXPLORE_ROUTE } from '~/constants'
const { t } = useI18n()
const route = useRoute()
const paginator = useMastoClient().v1.trends.links.list()
const hideNewsTips = useLocalStorage(STORAGE_KEY_HIDE_EXPLORE_NEWS_TIPS, false)
useHydratedHead({
title: () => `${t('tab.news')} | ${t('nav.explore')}`,
})
const lastAccessedExploreRoute = useLocalStorage(STORAGE_KEY_LAST_ACCESSED_EXPLORE_ROUTE, '')
lastAccessedExploreRoute.value = route.path.replace(/(.*\/explore\/?)/, '')
onActivated(() => {
lastAccessedExploreRoute.value = route.path.replace(/(.*\/explore\/?)/, '')
})
</script>
<template>
<CommonAlert v-if="isHydrated && !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>

View file

@ -0,0 +1,32 @@
<script setup lang="ts">
import { STORAGE_KEY_HIDE_EXPLORE_TAGS_TIPS, STORAGE_KEY_LAST_ACCESSED_EXPLORE_ROUTE } from '~/constants'
const { t } = useI18n()
const route = useRoute()
const { client } = useMasto()
const paginator = client.value.v1.trends.tags.list({
limit: 20,
})
const hideTagsTips = useLocalStorage(STORAGE_KEY_HIDE_EXPLORE_TAGS_TIPS, false)
useHydratedHead({
title: () => `${t('tab.hashtags')} | ${t('nav.explore')}`,
})
const lastAccessedExploreRoute = useLocalStorage(STORAGE_KEY_LAST_ACCESSED_EXPLORE_ROUTE, '')
lastAccessedExploreRoute.value = route.path.replace(/(.*\/explore\/?)/, '')
onActivated(() => {
lastAccessedExploreRoute.value = route.path.replace(/(.*\/explore\/?)/, '')
})
</script>
<template>
<CommonAlert v-if="!hideTagsTips" @close="hideTagsTips = true">
<p>{{ $t('tooltip.explore_tags_intro') }}</p>
</CommonAlert>
<TagCardPaginator v-bind="{ paginator }" />
</template>

View file

@ -0,0 +1,38 @@
<script setup lang="ts">
import { STORAGE_KEY_LAST_ACCESSED_EXPLORE_ROUTE } from '~/constants'
const { t } = useI18n()
const route = useRoute()
// limit: 20 is the default configuration of the official client
const paginator = useMastoClient().v2.suggestions.list({ limit: 20 })
useHydratedHead({
title: () => `${t('tab.for_you')} | ${t('nav.explore')}`,
})
const lastAccessedExploreRoute = useLocalStorage(STORAGE_KEY_LAST_ACCESSED_EXPLORE_ROUTE, '')
lastAccessedExploreRoute.value = route.path.replace(/(.*\/explore\/?)/, '')
onActivated(() => {
lastAccessedExploreRoute.value = route.path.replace(/(.*\/explore\/?)/, '')
})
</script>
<template>
<CommonPaginator :paginator="paginator" key-prop="account">
<template #default="{ item }">
<AccountBigCard
:account="item.account"
as="router-link"
:to="getAccountRoute(item.account)"
border="b base"
/>
</template>
<template #loading>
<AccountBigCardSkeleton border="b base" />
<AccountBigCardSkeleton border="b base" op50 />
<AccountBigCardSkeleton border="b base" op25 />
</template>
</CommonPaginator>
</template>

View file

@ -0,0 +1,15 @@
<script setup lang="ts">
const instance = instanceStorage.value[currentServer.value]
try {
clearError({ redirect: currentUser.value ? '/home' : `/${currentServer.value}/public/local` })
}
catch (err) {
console.error(err)
}
</script>
<template>
<MainContent text-base grid gap-3 m3>
<img rounded-3 :src="instance.thumbnail.url">
</MainContent>
</template>

View file

@ -0,0 +1,60 @@
<script setup lang="ts">
import type { CommonRouteTabOption } from '#shared/types'
definePageMeta({
middleware: 'auth',
})
const route = useRoute()
const { t } = useI18n()
const list = computed(() => route.params.list as string)
const server = computed(() => (route.params.server ?? currentServer.value) as string)
const tabs = computed<CommonRouteTabOption[]>(() => [
{
to: {
name: 'list',
params: { server: server.value, list: list.value },
},
display: t('tab.posts'),
icon: 'i-ri:list-unordered',
},
{
to: {
name: 'list-accounts',
params: { server: server.value, list: list.value },
},
display: t('tab.accounts'),
icon: 'i-ri:user-line',
},
],
)
const { client } = useMasto()
const { data: listInfo, refresh } = await useAsyncData(() => `list-${list.value}`, () => client.value.v1.lists.$select(list.value).fetch(), { default: () => shallowRef() })
if (listInfo) {
useHydratedHead({
title: () => `${listInfo.value.title} | ${route.fullPath.endsWith('/accounts') ? t('tab.accounts') : t('tab.posts')} | ${t('nav.lists')}`,
})
}
onReactivated(() => {
// Silently update data when reentering the page
// The user will see the previous content first, and any changes will be updated to the UI when the request is completed
refresh()
})
</script>
<template>
<MainContent back>
<template #title>
<span text-lg font-bold>{{ listInfo ? listInfo.title : t('nav.list') }}</span>
</template>
<template #header>
<CommonRouteTabs replace :options="tabs" />
</template>
<NuxtPage v-if="isHydrated" />
</MainContent>
</template>

View file

@ -0,0 +1,167 @@
<script setup lang="ts">
import type { mastodon } from 'masto'
import AccountSearchResult from '~/components/list/AccountSearchResult.vue'
definePageMeta({
name: 'list-accounts',
})
const inputRef = ref<HTMLInputElement>()
defineExpose({
inputRef,
})
const params = useRoute().params
const listId = computed(() => params.list as string)
const mastoListAccounts = useMastoClient().v1.lists.$select(listId.value).accounts
const paginator = mastoListAccounts.list()
// the limit parameter is set to 1000 while masto.js issue is still open: https://github.com/neet/masto.js/issues/1282
const accountsInList = ref((await useMastoClient().v1.lists.$select(listId.value).accounts.list({ limit: 1000 })))
const paginatorRef = ref()
// search stuff
const query = ref('')
const el = ref<HTMLElement>()
const { accounts, loading } = useSearch(query, {
following: true,
})
const { focused } = useFocusWithin(el)
const index = ref(0)
function isInCurrentList(userId: string) {
return accountsInList.value.map(account => account.id).includes(userId)
}
const results = computed(() => {
if (query.value.length === 0)
return []
return [...accounts.value]
})
// Reset index when results change
watch([results, focused], () => index.value = -1)
function addAccount(account: mastodon.v1.Account) {
try {
mastoListAccounts.create({ accountIds: [account.id] })
accountsInList.value.push(account)
paginatorRef.value?.createEntry(account)
}
catch (err) {
console.error(err)
}
}
function removeAccount(account: mastodon.v1.Account) {
try {
mastoListAccounts.remove({ accountIds: [account.id] })
const accountIdsInList = accountsInList.value.map(account => account.id)
const index = accountIdsInList.indexOf(account.id)
if (index > -1) {
accountsInList.value.splice(index, 1)
paginatorRef.value?.removeEntry(account.id)
}
}
catch (err) {
console.error(err)
}
}
</script>
<template>
<!-- Search Accounts You Follow -->
<div ref="el" relative group>
<form
border="t base"
p-4 w-full
flex="~ wrap" relative gap-3
>
<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
ps-4
>
<div i-ri:search-2-line pointer-events-none text-secondary mt="1px" class="rtl-flip" />
<input
ref="inputRef"
v-model="query"
bg-transparent
outline="focus:none"
ps-3
rounded-3
pb="1px"
h-full
w-full
placeholder-text-secondary
:placeholder="$t('list.search_following_placeholder')"
@keydown.esc.prevent="inputRef?.blur()"
@keydown.enter.prevent
>
<button v-if="query.length" btn-action-icon text-secondary @click="query = ''; inputRef?.focus()">
<span aria-hidden="true" class="i-ri:close-line" />
</button>
</div>
</form>
<!-- Results -->
<div left-0 top-18 absolute w-full z-10 group-focus-within="pointer-events-auto visible" invisible pointer-events-none>
<div w-full bg-base border="~ dark" rounded-3 max-h-100 overflow-auto :class="results.length === 0 ? 'py2' : null">
<span v-if="query.trim().length === 0" block text-center text-sm text-secondary>
{{ $t('list.search_following_desc') }}
</span>
<template v-else-if="!loading">
<template v-if="results.length > 0">
<div
v-for="(result, i) in results"
:key="result.id"
flex
border="b base"
py2 px4
hover:bg-active justify-between transition-100 items-center
>
<AccountSearchResult
:active="index === parseInt(i.toString())"
:result="result"
:tabindex="focused ? 0 : -1"
/>
<CommonTooltip :content="isInCurrentList(result.id) ? $t('list.remove_account') : $t('list.add_account')">
<button
text-sm p2 border-1 transition-colors
border-dark
btn-action-icon
bg-base
:hover="isInCurrentList(result.id) ? 'text-red' : 'text-green'"
@click=" () => isInCurrentList(result.id) ? removeAccount(result.data) : addAccount(result.data) "
>
<span :class="isInCurrentList(result.id) ? 'i-ri:user-unfollow-line' : 'i-ri:user-add-line'" />
</button>
</CommonTooltip>
</div>
</template>
<span v-else block text-center text-sm text-secondary>
{{ $t('search.search_empty') }}
</span>
</template>
<div v-else>
<SearchResultSkeleton />
<SearchResultSkeleton />
<SearchResultSkeleton />
</div>
</div>
</div>
</div>
<CommonPaginator ref="paginatorRef" :paginator="paginator">
<template #default="{ item }">
<ListAccount
:account="item"
:list="listId"
hover-card
border="b base" py2 px4
/>
</template>
</CommonPaginator>
</template>

View file

@ -0,0 +1,17 @@
<script setup lang="ts">
definePageMeta({
name: 'list',
})
const params = useRoute().params
const listId = computed(() => params.list as string)
const client = useMastoClient()
const paginator = client.v1.timelines.list.$select(listId.value).list()
const stream = useStreaming(client => client.list.subscribe({ list: listId.value }))
</script>
<template>
<TimelinePaginator v-bind="{ paginator, stream }" :preprocess="reorderedTimeline" context="home" />
</template>

View file

@ -0,0 +1,19 @@
<script setup lang="ts">
const { t } = useI18n()
useHydratedHead({
title: () => t('nav.lists'),
})
</script>
<template>
<MainContent>
<template #title>
<NuxtLink to="/lists" timeline-title-style flex items-center gap-2 @click="$scrollToTop">
<div i-ri:list-check />
<span text-lg font-bold>{{ t('nav.lists') }}</span>
</NuxtLink>
</template>
<NuxtPage v-if="isHydrated" />
</MainContent>
</template>

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

View file

@ -0,0 +1,20 @@
<script setup lang="ts">
const { t } = useI18n()
useHydratedHead({
title: () => t('title.federated_timeline'),
})
</script>
<template>
<MainContent>
<template #title>
<NuxtLink to="/public" timeline-title-style flex items-center gap-2 @click="$scrollToTop">
<div i-ri:earth-line />
<span>{{ $t('title.federated_timeline') }}</span>
</NuxtLink>
</template>
<TimelinePublic v-if="isHydrated" />
</MainContent>
</template>

View file

@ -0,0 +1,20 @@
<script setup lang="ts">
const { t } = useI18n()
useHydratedHead({
title: () => t('title.local_timeline'),
})
</script>
<template>
<MainContent>
<template #title>
<NuxtLink to="/public/local" timeline-title-style flex items-center gap-2 @click="$scrollToTop">
<div i-ri:group-2-line />
<span>{{ t('title.local_timeline') }}</span>
</NuxtLink>
</template>
<TimelinePublicLocal v-if="isHydrated" />
</MainContent>
</template>

View file

@ -0,0 +1,38 @@
<script setup lang="ts">
const keys = useMagicKeys()
const { t } = useI18n()
useHydratedHead({
title: () => t('nav.search'),
})
const search = ref<{ input?: HTMLInputElement }>()
watchEffect(() => {
if (search.value?.input)
search.value?.input?.focus()
})
onActivated(() => search.value?.input?.focus())
onDeactivated(() => search.value?.input?.blur())
watch(keys['/'], (v) => {
// focus on input when '/' is up to avoid '/' being typed
if (!v)
search.value?.input?.focus()
})
</script>
<template>
<MainContent>
<template #title>
<NuxtLink to="/search" timeline-title-style flex items-center gap-2 @click="$scrollToTop">
<div i-ri:search-line class="rtl-flip" />
<span>{{ $t('nav.search') }}</span>
</NuxtLink>
</template>
<div px2 mt3>
<SearchWidget v-if="isHydrated" ref="search" m-1 />
</div>
</MainContent>
</template>

View file

@ -0,0 +1,15 @@
<script setup lang="ts">
definePageMeta({
name: 'status-by-id',
middleware: async (to) => {
const params = to.params
const id = params.status as string
const status = await fetchStatus(id)
return getStatusRoute(status)
},
})
</script>
<template>
<div />
</template>

View file

@ -0,0 +1,51 @@
<script setup lang="ts">
import type { mastodon } from 'masto'
definePageMeta({
name: 'tag',
})
const params = useRoute().params
const tagName = computed(() => params.tag as string)
const { client } = useMasto()
const { data: tag, refresh } = await useAsyncData(() => `tag-${tagName.value}`, () => client.value.v1.tags.$select(tagName.value).fetch(), { default: () => shallowRef() })
const paginator = client.value.v1.timelines.tag.$select(tagName.value).list()
const stream = useStreaming(client => client.hashtag.subscribe({ tag: tagName.value }))
if (tag.value) {
useHydratedHead({
title: () => `#${tag.value.name}`,
})
}
onReactivated(() => {
// Silently update data when reentering the page
// The user will see the previous content first, and any changes will be updated to the UI when the request is completed
refresh()
})
let followedTags: mastodon.v1.Tag[] | undefined
if (currentUser.value !== undefined) {
followedTags = (await useMasto().client.value.v1.followedTags.list({ limit: 0 }))
}
</script>
<template>
<MainContent back>
<template #title>
<bdi text-lg font-bold>#{{ tagName }}</bdi>
</template>
<template #actions>
<template v-if="typeof tag?.following === 'boolean'">
<TagActionButton :tag="tag" @change="refresh()" />
</template>
</template>
<slot>
<TimelinePaginator :followed-tags="followedTags" v-bind="{ paginator, stream }" context="public" />
</slot>
</MainContent>
</template>