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
25
app/pages/[...permalink].vue
Normal file
25
app/pages/[...permalink].vue
Normal file
|
@ -0,0 +1,25 @@
|
|||
<script setup lang="ts">
|
||||
import { hasProtocol, parseURL } from 'ufo'
|
||||
|
||||
definePageMeta({
|
||||
middleware: async (to) => {
|
||||
const permalink = Array.isArray(to.params.permalink)
|
||||
? to.params.permalink.join('/')
|
||||
: to.params.permalink
|
||||
|
||||
if (hasProtocol(permalink)) {
|
||||
const { host, pathname } = parseURL(permalink)
|
||||
|
||||
if (host)
|
||||
return `/${host}${pathname}`
|
||||
}
|
||||
|
||||
// We've reached a page that doesn't exist
|
||||
return false
|
||||
},
|
||||
})
|
||||
</script>
|
||||
|
||||
<template>
|
||||
<div />
|
||||
</template>
|
128
app/pages/[[server]]/@[account]/[status].vue
Normal file
128
app/pages/[[server]]/@[account]/[status].vue
Normal 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>
|
54
app/pages/[[server]]/@[account]/index.vue
Normal file
54
app/pages/[[server]]/@[account]/index.vue
Normal 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>
|
24
app/pages/[[server]]/@[account]/index/followers.vue
Normal file
24
app/pages/[[server]]/@[account]/index/followers.vue
Normal 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>
|
24
app/pages/[[server]]/@[account]/index/following.vue
Normal file
24
app/pages/[[server]]/@[account]/index/following.vue
Normal 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>
|
44
app/pages/[[server]]/@[account]/index/index.vue
Normal file
44
app/pages/[[server]]/@[account]/index/index.vue
Normal 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>
|
24
app/pages/[[server]]/@[account]/index/media.vue
Normal file
24
app/pages/[[server]]/@[account]/index/media.vue
Normal 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>
|
24
app/pages/[[server]]/@[account]/index/with_replies.vue
Normal file
24
app/pages/[[server]]/@[account]/index/with_replies.vue
Normal 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>
|
56
app/pages/[[server]]/explore.vue
Normal file
56
app/pages/[[server]]/explore.vue
Normal 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>
|
29
app/pages/[[server]]/explore/index.vue
Normal file
29
app/pages/[[server]]/explore/index.vue
Normal 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>
|
38
app/pages/[[server]]/explore/links.vue
Normal file
38
app/pages/[[server]]/explore/links.vue
Normal 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>
|
32
app/pages/[[server]]/explore/tags.vue
Normal file
32
app/pages/[[server]]/explore/tags.vue
Normal 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>
|
38
app/pages/[[server]]/explore/users.vue
Normal file
38
app/pages/[[server]]/explore/users.vue
Normal 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>
|
15
app/pages/[[server]]/index.vue
Normal file
15
app/pages/[[server]]/index.vue
Normal 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>
|
60
app/pages/[[server]]/list/[list]/index.vue
Normal file
60
app/pages/[[server]]/list/[list]/index.vue
Normal 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>
|
167
app/pages/[[server]]/list/[list]/index/accounts.vue
Normal file
167
app/pages/[[server]]/list/[list]/index/accounts.vue
Normal 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>
|
17
app/pages/[[server]]/list/[list]/index/index.vue
Normal file
17
app/pages/[[server]]/list/[list]/index/index.vue
Normal 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>
|
19
app/pages/[[server]]/lists.vue
Normal file
19
app/pages/[[server]]/lists.vue
Normal 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>
|
144
app/pages/[[server]]/lists/index.vue
Normal file
144
app/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>
|
20
app/pages/[[server]]/public/index.vue
Normal file
20
app/pages/[[server]]/public/index.vue
Normal 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>
|
20
app/pages/[[server]]/public/local.vue
Normal file
20
app/pages/[[server]]/public/local.vue
Normal 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>
|
38
app/pages/[[server]]/search.vue
Normal file
38
app/pages/[[server]]/search.vue
Normal 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>
|
15
app/pages/[[server]]/status/[status].vue
Normal file
15
app/pages/[[server]]/status/[status].vue
Normal 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>
|
51
app/pages/[[server]]/tags/[tag].vue
Normal file
51
app/pages/[[server]]/tags/[tag].vue
Normal 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>
|
21
app/pages/blocks.vue
Normal file
21
app/pages/blocks.vue
Normal file
|
@ -0,0 +1,21 @@
|
|||
<script setup lang="ts">
|
||||
definePageMeta({
|
||||
middleware: 'auth',
|
||||
})
|
||||
|
||||
const { t } = useI18n()
|
||||
|
||||
useHydratedHead({
|
||||
title: () => t('nav.blocked_users'),
|
||||
})
|
||||
</script>
|
||||
|
||||
<template>
|
||||
<MainContent back>
|
||||
<template #title>
|
||||
<span timeline-title-style>{{ $t('nav.blocked_users') }}</span>
|
||||
</template>
|
||||
|
||||
<TimelineBlocks v-if="isHydrated" />
|
||||
</MainContent>
|
||||
</template>
|
24
app/pages/bookmarks.vue
Normal file
24
app/pages/bookmarks.vue
Normal file
|
@ -0,0 +1,24 @@
|
|||
<script setup lang="ts">
|
||||
definePageMeta({
|
||||
middleware: 'auth',
|
||||
})
|
||||
|
||||
const { t } = useI18n()
|
||||
|
||||
useHydratedHead({
|
||||
title: () => t('nav.bookmarks'),
|
||||
})
|
||||
</script>
|
||||
|
||||
<template>
|
||||
<MainContent>
|
||||
<template #title>
|
||||
<NuxtLink to="/bookmarks" timeline-title-style flex items-center gap-2 @click="$scrollToTop">
|
||||
<div i-ri:bookmark-line />
|
||||
<span>{{ t('nav.bookmarks') }}</span>
|
||||
</NuxtLink>
|
||||
</template>
|
||||
|
||||
<TimelineBookmarks v-if="isHydrated" />
|
||||
</MainContent>
|
||||
</template>
|
23
app/pages/compose.vue
Normal file
23
app/pages/compose.vue
Normal file
|
@ -0,0 +1,23 @@
|
|||
<script setup lang="ts">
|
||||
definePageMeta({
|
||||
middleware: 'auth',
|
||||
})
|
||||
|
||||
const { t } = useI18n()
|
||||
|
||||
useHydratedHead({
|
||||
title: () => t('nav.compose'),
|
||||
})
|
||||
</script>
|
||||
|
||||
<template>
|
||||
<MainContent>
|
||||
<template #title>
|
||||
<NuxtLink to="/compose" timeline-title-style flex items-center gap-2 @click="$scrollToTop">
|
||||
<div i-ri:quill-pen-line />
|
||||
<span>{{ $t('nav.compose') }}</span>
|
||||
</NuxtLink>
|
||||
</template>
|
||||
<PublishWidgetFull />
|
||||
</MainContent>
|
||||
</template>
|
24
app/pages/conversations.vue
Normal file
24
app/pages/conversations.vue
Normal file
|
@ -0,0 +1,24 @@
|
|||
<script setup lang="ts">
|
||||
definePageMeta({
|
||||
middleware: 'auth',
|
||||
})
|
||||
|
||||
const { t } = useI18n()
|
||||
|
||||
useHydratedHead({
|
||||
title: () => t('nav.conversations'),
|
||||
})
|
||||
</script>
|
||||
|
||||
<template>
|
||||
<MainContent>
|
||||
<template #title>
|
||||
<NuxtLink to="/conversations" timeline-title-style flex items-center gap-2 @click="$scrollToTop">
|
||||
<div i-ri:at-line />
|
||||
<span>{{ t('nav.conversations') }}</span>
|
||||
</NuxtLink>
|
||||
</template>
|
||||
|
||||
<TimelineConversations v-if="isHydrated" />
|
||||
</MainContent>
|
||||
</template>
|
21
app/pages/domain_blocks.vue
Normal file
21
app/pages/domain_blocks.vue
Normal file
|
@ -0,0 +1,21 @@
|
|||
<script setup lang="ts">
|
||||
definePageMeta({
|
||||
middleware: 'auth',
|
||||
})
|
||||
|
||||
const { t } = useI18n()
|
||||
|
||||
useHydratedHead({
|
||||
title: () => t('nav.blocked_domains'),
|
||||
})
|
||||
</script>
|
||||
|
||||
<template>
|
||||
<MainContent back>
|
||||
<template #title>
|
||||
<span timeline-title-style>{{ $t('nav.blocked_domains') }}</span>
|
||||
</template>
|
||||
|
||||
<TimelineDomainBlocks v-if="isHydrated" />
|
||||
</MainContent>
|
||||
</template>
|
25
app/pages/favourites.vue
Normal file
25
app/pages/favourites.vue
Normal file
|
@ -0,0 +1,25 @@
|
|||
<script setup lang="ts">
|
||||
definePageMeta({
|
||||
middleware: 'auth',
|
||||
})
|
||||
|
||||
const { t } = useI18n()
|
||||
const useStarFavoriteIcon = usePreferences('useStarFavoriteIcon')
|
||||
|
||||
useHydratedHead({
|
||||
title: () => t('nav.favourites'),
|
||||
})
|
||||
</script>
|
||||
|
||||
<template>
|
||||
<MainContent>
|
||||
<template #title>
|
||||
<NuxtLink to="/favourites" timeline-title-style flex items-center gap-2 @click="$scrollToTop">
|
||||
<div :class="useStarFavoriteIcon ? 'i-ri:star-line' : 'i-ri:heart-3-line'" />
|
||||
<span>{{ t('nav.favourites') }}</span>
|
||||
</NuxtLink>
|
||||
</template>
|
||||
|
||||
<TimelineFavourites v-if="isHydrated" />
|
||||
</MainContent>
|
||||
</template>
|
20
app/pages/hashtags.vue
Normal file
20
app/pages/hashtags.vue
Normal file
|
@ -0,0 +1,20 @@
|
|||
<script setup lang="ts">
|
||||
const { t } = useI18n()
|
||||
|
||||
useHydratedHead({
|
||||
title: () => t('nav.hashtags'),
|
||||
})
|
||||
</script>
|
||||
|
||||
<template>
|
||||
<MainContent>
|
||||
<template #title>
|
||||
<NuxtLink to="/hashtags" timeline-title-style flex items-center gap-2 @click="$scrollToTop">
|
||||
<div class="i-ri:hashtag" />
|
||||
<span>{{ t('nav.hashtags') }}</span>
|
||||
</NuxtLink>
|
||||
</template>
|
||||
|
||||
<NuxtPage v-if="isHydrated && currentUser" />
|
||||
</MainContent>
|
||||
</template>
|
20
app/pages/hashtags/index.vue
Normal file
20
app/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>
|
29
app/pages/home.vue
Normal file
29
app/pages/home.vue
Normal file
|
@ -0,0 +1,29 @@
|
|||
<script setup lang="ts">
|
||||
definePageMeta({
|
||||
middleware: 'auth',
|
||||
alias: ['/signin/callback'],
|
||||
})
|
||||
|
||||
const route = useRoute()
|
||||
const router = useRouter()
|
||||
if (import.meta.client && route.path === '/signin/callback')
|
||||
router.push('/home')
|
||||
|
||||
const { t } = useI18n()
|
||||
useHydratedHead({
|
||||
title: () => t('nav.home'),
|
||||
})
|
||||
</script>
|
||||
|
||||
<template>
|
||||
<MainContent>
|
||||
<template #title>
|
||||
<NuxtLink to="/home" timeline-title-style flex items-center gap-2 @click="$scrollToTop">
|
||||
<div i-ri:home-5-line />
|
||||
<span>{{ $t('nav.home') }}</span>
|
||||
</NuxtLink>
|
||||
</template>
|
||||
|
||||
<TimelineHome v-if="isHydrated" />
|
||||
</MainContent>
|
||||
</template>
|
9
app/pages/index.vue
Normal file
9
app/pages/index.vue
Normal file
|
@ -0,0 +1,9 @@
|
|||
<script setup lang="ts">
|
||||
definePageMeta({
|
||||
middleware: 'auth',
|
||||
})
|
||||
</script>
|
||||
|
||||
<template>
|
||||
<div />
|
||||
</template>
|
25
app/pages/intent/post.vue
Normal file
25
app/pages/intent/post.vue
Normal file
|
@ -0,0 +1,25 @@
|
|||
<script setup lang="ts">
|
||||
import type { mastodon } from 'masto'
|
||||
|
||||
const router = useRouter()
|
||||
const route = useRoute()
|
||||
|
||||
onMounted(async () => {
|
||||
// TODO: login check
|
||||
await openPublishDialog('intent', getDefaultDraftItem({
|
||||
status: route.query.text as string,
|
||||
sensitive: route.query.sensitive === 'true' || route.query.sensitive === null,
|
||||
spoilerText: route.query.spoiler_text as string,
|
||||
visibility: route.query.visibility as mastodon.v1.StatusVisibility,
|
||||
language: route.query.language as string,
|
||||
}), true)
|
||||
// TODO: need a better idea 👀
|
||||
await router.replace('/home')
|
||||
})
|
||||
</script>
|
||||
|
||||
<template>
|
||||
<div>
|
||||
<slot />
|
||||
</div>
|
||||
</template>
|
21
app/pages/mutes.vue
Normal file
21
app/pages/mutes.vue
Normal file
|
@ -0,0 +1,21 @@
|
|||
<script setup lang="ts">
|
||||
definePageMeta({
|
||||
middleware: 'auth',
|
||||
})
|
||||
|
||||
const { t } = useI18n()
|
||||
|
||||
useHydratedHead({
|
||||
title: () => t('nav.muted_users'),
|
||||
})
|
||||
</script>
|
||||
|
||||
<template>
|
||||
<MainContent back>
|
||||
<template #title>
|
||||
<span timeline-title-style>{{ $t('nav.muted_users') }}</span>
|
||||
</template>
|
||||
|
||||
<TimelineMutes v-if="isHydrated" />
|
||||
</MainContent>
|
||||
</template>
|
104
app/pages/notifications.vue
Normal file
104
app/pages/notifications.vue
Normal file
|
@ -0,0 +1,104 @@
|
|||
<script setup lang="ts">
|
||||
import type { CommonRouteTabMoreOption, CommonRouteTabOption } from '#shared/types'
|
||||
import type { mastodon } from 'masto'
|
||||
import { NOTIFICATION_FILTER_TYPES } from '~/constants'
|
||||
|
||||
definePageMeta({
|
||||
middleware: 'auth',
|
||||
})
|
||||
|
||||
const route = useRoute()
|
||||
const { t } = useI18n()
|
||||
const pwaEnabled = useAppConfig().pwaEnabled
|
||||
|
||||
const tabs = computed<CommonRouteTabOption[]>(() => [
|
||||
{
|
||||
name: 'all',
|
||||
to: '/notifications',
|
||||
display: t('tab.notifications_all'),
|
||||
},
|
||||
{
|
||||
name: 'mention',
|
||||
to: '/notifications/mention',
|
||||
display: t('tab.notifications_mention'),
|
||||
},
|
||||
])
|
||||
|
||||
const filter = computed<mastodon.v1.NotificationType | undefined>(() => {
|
||||
if (!isHydrated.value)
|
||||
return undefined
|
||||
|
||||
const rawFilter = route.params?.filter
|
||||
const actualFilter = Array.isArray(rawFilter) ? rawFilter[0] : rawFilter
|
||||
if (isNotificationFilter(actualFilter))
|
||||
return actualFilter
|
||||
|
||||
return undefined
|
||||
})
|
||||
|
||||
const filterIconMap: Record<mastodon.v1.NotificationType, string> = {
|
||||
'mention': 'i-ri:at-line',
|
||||
'status': 'i-ri:account-pin-circle-line',
|
||||
'reblog': 'i-ri:repeat-fill',
|
||||
'follow': 'i-ri:user-follow-line',
|
||||
'follow_request': 'i-ri:user-shared-line',
|
||||
'favourite': 'i-ri:heart-3-line',
|
||||
'poll': 'i-ri:chat-poll-line',
|
||||
'update': 'i-ri:edit-2-line',
|
||||
'admin.sign_up': 'i-ri:user-add-line',
|
||||
'admin.report': 'i-ri:flag-line',
|
||||
'severed_relationships': 'i-ri:user-unfollow-line',
|
||||
'moderation_warning': 'i-ri:error-warning-line',
|
||||
}
|
||||
|
||||
const filterText = computed(() => `${t('tab.notifications_more_tooltip')}${filter.value ? `: ${t(`tab.notifications_${filter.value}`)}` : ''}`)
|
||||
const notificationFilterRoutes = computed<CommonRouteTabOption[]>(() => NOTIFICATION_FILTER_TYPES.map(
|
||||
name => ({
|
||||
name,
|
||||
to: `/notifications/${name}`,
|
||||
display: t(`tab.notifications_${name}`),
|
||||
icon: filterIconMap[name],
|
||||
match: name === filter.value,
|
||||
}),
|
||||
))
|
||||
const moreOptions = computed<CommonRouteTabMoreOption>(() => ({
|
||||
options: notificationFilterRoutes.value,
|
||||
icon: 'i-ri:filter-2-line',
|
||||
tooltip: filterText.value,
|
||||
match: !!filter.value,
|
||||
}))
|
||||
</script>
|
||||
|
||||
<template>
|
||||
<MainContent>
|
||||
<template #title>
|
||||
<NuxtLink to="/notifications" timeline-title-style flex items-center gap-2 @click="$scrollToTop">
|
||||
<div i-ri:notification-4-line />
|
||||
<span>{{ t('nav.notifications') }}</span>
|
||||
</NuxtLink>
|
||||
</template>
|
||||
|
||||
<template #actions>
|
||||
<NuxtLink
|
||||
flex rounded-4 p1
|
||||
hover:bg-active cursor-pointer transition-100
|
||||
:title="t('settings.notifications.show_btn')"
|
||||
to="/settings/notifications"
|
||||
>
|
||||
<span aria-hidden="true" i-ri:notification-badge-line />
|
||||
</NuxtLink>
|
||||
</template>
|
||||
|
||||
<template #header>
|
||||
<CommonRouteTabs replace :options="tabs" :more-options="moreOptions" />
|
||||
</template>
|
||||
|
||||
<slot>
|
||||
<template v-if="pwaEnabled">
|
||||
<NotificationPreferences />
|
||||
</template>
|
||||
|
||||
<NuxtPage />
|
||||
</slot>
|
||||
</MainContent>
|
||||
</template>
|
26
app/pages/notifications/[filter].vue
Normal file
26
app/pages/notifications/[filter].vue
Normal file
|
@ -0,0 +1,26 @@
|
|||
<script setup lang="ts">
|
||||
import type { mastodon } from 'masto'
|
||||
|
||||
const route = useRoute()
|
||||
const { t } = useI18n()
|
||||
|
||||
const filter = computed<mastodon.v1.NotificationType | undefined>(() => {
|
||||
if (!isHydrated.value)
|
||||
return undefined
|
||||
|
||||
const rawFilter = route.params?.filter
|
||||
const actualFilter = Array.isArray(rawFilter) ? rawFilter[0] : rawFilter
|
||||
if (isNotification(actualFilter))
|
||||
return actualFilter
|
||||
|
||||
return undefined
|
||||
})
|
||||
|
||||
useHydratedHead({
|
||||
title: () => `${t(`tab.notifications_${filter.value ?? 'all'}`)} | ${t('nav.notifications')}`,
|
||||
})
|
||||
</script>
|
||||
|
||||
<template>
|
||||
<TimelineNotifications v-if="isHydrated" :filter="filter" />
|
||||
</template>
|
11
app/pages/notifications/index.vue
Normal file
11
app/pages/notifications/index.vue
Normal file
|
@ -0,0 +1,11 @@
|
|||
<script setup lang="ts">
|
||||
const { t } = useI18n()
|
||||
|
||||
useHydratedHead({
|
||||
title: () => `${t('tab.notifications_all')} | ${t('nav.notifications')}`,
|
||||
})
|
||||
</script>
|
||||
|
||||
<template>
|
||||
<TimelineNotifications v-if="isHydrated" />
|
||||
</template>
|
24
app/pages/pinned.vue
Normal file
24
app/pages/pinned.vue
Normal file
|
@ -0,0 +1,24 @@
|
|||
<script setup lang="ts">
|
||||
definePageMeta({
|
||||
middleware: 'auth',
|
||||
})
|
||||
|
||||
const { t } = useI18n()
|
||||
|
||||
useHydratedHead({
|
||||
title: () => t('account.pinned'),
|
||||
})
|
||||
</script>
|
||||
|
||||
<template>
|
||||
<MainContent>
|
||||
<template #title>
|
||||
<NuxtLink to="/pinned" timeline-title-style flex items-center gap-2 @click="$scrollToTop">
|
||||
<div i-ri:pushpin-line />
|
||||
<span>{{ t('account.pinned') }}</span>
|
||||
</NuxtLink>
|
||||
</template>
|
||||
|
||||
<TimelinePinned v-if="isHydrated && currentUser" />
|
||||
</MainContent>
|
||||
</template>
|
90
app/pages/settings.vue
Normal file
90
app/pages/settings.vue
Normal file
|
@ -0,0 +1,90 @@
|
|||
<script setup lang="ts">
|
||||
definePageMeta({
|
||||
wideLayout: true,
|
||||
})
|
||||
|
||||
const { t } = useI18n()
|
||||
|
||||
useHydratedHead({
|
||||
title: () => t('nav.settings'),
|
||||
})
|
||||
|
||||
const route = useRoute()
|
||||
|
||||
const isRootPath = computed(() => route.name === 'settings')
|
||||
</script>
|
||||
|
||||
<template>
|
||||
<div>
|
||||
<div min-h-screen flex>
|
||||
<div border="e base" :class="isRootPath ? 'block lg:flex-none flex-1' : 'hidden lg:block'">
|
||||
<MainContent>
|
||||
<template #title>
|
||||
<div timeline-title-style flex items-center gap-2 @click="$scrollToTop">
|
||||
<div i-ri:settings-3-line />
|
||||
<span>{{ $t('nav.settings') }}</span>
|
||||
</div>
|
||||
</template>
|
||||
<div xl:w-97 lg:w-78 w-full>
|
||||
<SettingsItem
|
||||
v-if="currentUser"
|
||||
command
|
||||
icon="i-ri:user-line"
|
||||
:text="$t('settings.profile.label')"
|
||||
to="/settings/profile"
|
||||
:match="$route.path.startsWith('/settings/profile/')"
|
||||
/>
|
||||
<SettingsItem
|
||||
command
|
||||
icon="i-ri-compasses-2-line"
|
||||
:text="$t('settings.interface.label')"
|
||||
to="/settings/interface"
|
||||
:match="$route.path.startsWith('/settings/interface/')"
|
||||
/>
|
||||
<SettingsItem
|
||||
v-if="currentUser"
|
||||
command
|
||||
icon="i-ri:notification-badge-line"
|
||||
:text="$t('settings.notifications_settings')"
|
||||
to="/settings/notifications"
|
||||
:match="$route.path.startsWith('/settings/notifications/')"
|
||||
/>
|
||||
<SettingsItem
|
||||
command
|
||||
icon="i-ri-globe-line"
|
||||
:text="$t('settings.language.label')"
|
||||
to="/settings/language"
|
||||
:match="$route.path.startsWith('/settings/language/')"
|
||||
/>
|
||||
<SettingsItem
|
||||
command
|
||||
icon="i-ri-equalizer-line"
|
||||
:text="$t('settings.preferences.label')"
|
||||
to="/settings/preferences"
|
||||
:match="$route.path.startsWith('/settings/preferences/')"
|
||||
/>
|
||||
<SettingsItem
|
||||
command
|
||||
icon="i-ri-group-line"
|
||||
:text="$t('settings.users.label')"
|
||||
to="/settings/users"
|
||||
:match="$route.path.startsWith('/settings/users/')"
|
||||
/>
|
||||
<SettingsItem
|
||||
command
|
||||
icon="i-ri:information-line"
|
||||
:text="$t('settings.about.label')"
|
||||
to="/settings/about"
|
||||
:match="$route.path.startsWith('/settings/about/')"
|
||||
/>
|
||||
</div>
|
||||
</MainContent>
|
||||
</div>
|
||||
<div flex-1 :class="isRootPath ? 'hidden lg:block' : 'block'">
|
||||
<ClientOnly>
|
||||
<NuxtPage />
|
||||
</ClientOnly>
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
</template>
|
136
app/pages/settings/about/index.vue
Normal file
136
app/pages/settings/about/index.vue
Normal file
|
@ -0,0 +1,136 @@
|
|||
<script setup lang="ts">
|
||||
const buildInfo = useBuildInfo()
|
||||
const { t } = useI18n()
|
||||
|
||||
useHydratedHead({
|
||||
title: () => `${t('settings.about.label')} | ${t('nav.settings')}`,
|
||||
})
|
||||
|
||||
const showCommit = ref(buildInfo.env !== 'release' && buildInfo.env !== 'dev')
|
||||
const builtTime = useFormattedDateTime(buildInfo.time)
|
||||
|
||||
function handleShowCommit() {
|
||||
setTimeout(() => {
|
||||
showCommit.value = true
|
||||
}, 50)
|
||||
}
|
||||
</script>
|
||||
|
||||
<template>
|
||||
<MainContent back-on-small-screen>
|
||||
<template #title>
|
||||
<div text-lg font-bold flex items-center gap-2 @click="$scrollToTop">
|
||||
<span>{{ $t('settings.about.label') }}</span>
|
||||
</div>
|
||||
</template>
|
||||
|
||||
<div flex="~ col gap4" w-full items-center justify-center my5>
|
||||
<img :alt="$t('app_logo')" :src="`${''}/logo.svg`" w-24 h-24 class="rtl-flip">
|
||||
<p text-lg>
|
||||
{{ $t('app_desc_short') }}
|
||||
</p>
|
||||
</div>
|
||||
|
||||
<template v-if="isHydrated">
|
||||
<SettingsItem
|
||||
:text="$t('settings.about.version')"
|
||||
:to="showCommit ? `https://github.com/elk-zone/elk/commit/${buildInfo.commit}` : undefined"
|
||||
external target="_blank"
|
||||
@click="handleShowCommit"
|
||||
>
|
||||
<template #content>
|
||||
<div font-mono>
|
||||
<span>{{ buildInfo.env === 'release' ? `v${buildInfo.version}` : buildInfo.env }}</span>
|
||||
<span v-if="showCommit"> ({{ buildInfo.shortCommit }}@{{ buildInfo.branch }})</span>
|
||||
</div>
|
||||
</template>
|
||||
</SettingsItem>
|
||||
|
||||
<SettingsItem :text="$t('settings.about.built_at')" :content="builtTime" />
|
||||
</template>
|
||||
|
||||
<div h-1px bg-border my2 />
|
||||
|
||||
<SettingsItem
|
||||
:text="$t('nav.show_intro')"
|
||||
icon="i-ri:article-line"
|
||||
cursor-pointer large
|
||||
@click="openPreviewHelp"
|
||||
/>
|
||||
|
||||
<SettingsItem
|
||||
:text="$t('nav.docs')"
|
||||
icon="i-ri:book-open-line"
|
||||
to="https://docs.elk.zone/"
|
||||
large target="_blank"
|
||||
/>
|
||||
|
||||
<SettingsItem
|
||||
text="Mastodon"
|
||||
icon="i-ri:mastodon-line"
|
||||
to="/m.webtoo.ls/@elk"
|
||||
large target="_blank"
|
||||
/>
|
||||
<SettingsItem
|
||||
text="Discord"
|
||||
icon="i-ri:discord-fill"
|
||||
to="https://chat.elk.zone"
|
||||
external large target="_blank"
|
||||
/>
|
||||
<SettingsItem
|
||||
text="GitHub"
|
||||
icon="i-ri:github-fill"
|
||||
to="https://github.com/elk-zone/elk"
|
||||
external large target="_blank"
|
||||
/>
|
||||
|
||||
<div h-1px bg-border my2 />
|
||||
|
||||
<p px5 py3 font-bold text-lg>
|
||||
{{ $t('settings.about.sponsors') }}
|
||||
</p>
|
||||
|
||||
<p px5 text-secondary>
|
||||
{{ $t('settings.about.sponsors_body_1') }}
|
||||
</p>
|
||||
|
||||
<LazySettingsSponsorsList />
|
||||
|
||||
<p px5 mb1 text-secondary>
|
||||
{{ $t('settings.about.sponsors_body_2') }}
|
||||
</p>
|
||||
<p px5 mb2 text-secondary>
|
||||
{{ $t('settings.about.sponsors_body_3') }}
|
||||
</p>
|
||||
|
||||
<SettingsItem
|
||||
:text="$t('settings.about.sponsor_action')"
|
||||
to="https://github.com/sponsors/elk-zone"
|
||||
:description="$t('settings.about.sponsor_action_desc')"
|
||||
external large target="_blank"
|
||||
>
|
||||
<template #icon>
|
||||
<div i-ri-heart-3-fill text-rose rounded-full w-8 h-8 height="32" width="32" />
|
||||
</template>
|
||||
</SettingsItem>
|
||||
|
||||
<div h-1px bg-border my2 />
|
||||
|
||||
<template v-if="isHydrated">
|
||||
<p px5 py3 font-bold text-lg>
|
||||
{{ $t('settings.about.meet_the_team') }}
|
||||
</p>
|
||||
|
||||
<SettingsItem
|
||||
v-for="team in elkTeamMembers" :key="team.github"
|
||||
:text="team.display"
|
||||
:to="team.link"
|
||||
external target="_blank"
|
||||
>
|
||||
<template #icon>
|
||||
<img :src="`/avatars/${team.github}-60x60.png`" :alt="team.display" rounded-full w-8 h-8 height="32" width="32">
|
||||
</template>
|
||||
</SettingsItem>
|
||||
</template>
|
||||
</MainContent>
|
||||
</template>
|
8
app/pages/settings/index.vue
Normal file
8
app/pages/settings/index.vue
Normal file
|
@ -0,0 +1,8 @@
|
|||
<template>
|
||||
<div min-h-screen flex justify-center items-center>
|
||||
<div text-center flex="~ col gap-2" items-center>
|
||||
<div i-ri:settings-3-line text-5xl />
|
||||
<span text-xl>{{ $t('settings.select_a_settings') }}</span>
|
||||
</div>
|
||||
</div>
|
||||
</template>
|
23
app/pages/settings/interface/index.vue
Normal file
23
app/pages/settings/interface/index.vue
Normal file
|
@ -0,0 +1,23 @@
|
|||
<script setup lang="ts">
|
||||
const { t } = useI18n()
|
||||
|
||||
useHydratedHead({
|
||||
title: () => `${t('settings.interface.label')} | ${t('nav.settings')}`,
|
||||
})
|
||||
</script>
|
||||
|
||||
<template>
|
||||
<MainContent back-on-small-screen>
|
||||
<template #title>
|
||||
<div text-lg font-bold flex items-center gap-2 @click="$scrollToTop">
|
||||
<span>{{ $t('settings.interface.label') }}</span>
|
||||
</div>
|
||||
</template>
|
||||
<div px-6 pt-3 pb-6 flex="~ col gap6">
|
||||
<SettingsFontSize />
|
||||
<SettingsColorMode />
|
||||
<SettingsThemeColors />
|
||||
<SettingsBottomNav />
|
||||
</div>
|
||||
</MainContent>
|
||||
</template>
|
64
app/pages/settings/language/index.vue
Normal file
64
app/pages/settings/language/index.vue
Normal file
|
@ -0,0 +1,64 @@
|
|||
<script setup lang="ts">
|
||||
import type { ElkTranslationStatus } from '#shared/types/translation-status'
|
||||
|
||||
const { t, locale } = useI18n()
|
||||
|
||||
const translationStatus: ElkTranslationStatus = await import('~~/elk-translation-status.json').then(m => m.default)
|
||||
|
||||
useHydratedHead({
|
||||
title: () => `${t('settings.language.label')} | ${t('nav.settings')}`,
|
||||
})
|
||||
const status = computed(() => {
|
||||
const entry = translationStatus.locales[locale.value]
|
||||
return t('settings.language.status', [entry.total, translationStatus.total, entry.percentage])
|
||||
})
|
||||
</script>
|
||||
|
||||
<template>
|
||||
<MainContent back-on-small-screen>
|
||||
<template #title>
|
||||
<div text-lg font-bold flex items-center gap-2 @click="$scrollToTop">
|
||||
<span>{{ $t('settings.language.label') }}</span>
|
||||
</div>
|
||||
</template>
|
||||
<div p6>
|
||||
<section space-y-2>
|
||||
<h2 py2 font-bold text-xl flex="~ gap-1" items-center>
|
||||
{{ $t('settings.language.display_language') }}
|
||||
</h2>
|
||||
<div>
|
||||
{{ status }}
|
||||
</div>
|
||||
<SettingsLanguage select-settings />
|
||||
<NuxtLink
|
||||
href="https://docs.elk.zone/guide/contributing"
|
||||
target="_blank"
|
||||
hover:underline text-primary inline-flex items-center gap-1
|
||||
>
|
||||
<span inline-block i-ri:information-line />
|
||||
{{ $t('settings.language.how_to_contribute') }}
|
||||
</NuxtLink>
|
||||
</section>
|
||||
<section mt4>
|
||||
<h2 font-bold text-xl flex="~ gap-1" items-center>
|
||||
{{ $t('settings.language.post_language') }}
|
||||
</h2>
|
||||
<SettingsItem
|
||||
v-if="currentUser"
|
||||
command large
|
||||
icon="i-ri:quill-pen-line"
|
||||
:text="$t('settings.language.post_language')"
|
||||
:description="$t('settings.account_settings.description')"
|
||||
:to="`https://${currentUser!.server}/settings/preferences/other`"
|
||||
external target="_blank"
|
||||
/>
|
||||
</section>
|
||||
<section>
|
||||
<h2 py4 mt2 font-bold text-xl flex="~ gap-1" items-center>
|
||||
{{ $t('settings.language.translations.heading') }}
|
||||
</h2>
|
||||
<SettingsTranslations />
|
||||
</section>
|
||||
</div>
|
||||
</MainContent>
|
||||
</template>
|
35
app/pages/settings/notifications/index.vue
Normal file
35
app/pages/settings/notifications/index.vue
Normal file
|
@ -0,0 +1,35 @@
|
|||
<script setup lang="ts">
|
||||
definePageMeta({
|
||||
middleware: 'auth',
|
||||
})
|
||||
|
||||
const { t } = useI18n()
|
||||
const pwaEnabled = useAppConfig().pwaEnabled
|
||||
|
||||
useHydratedHead({
|
||||
title: () => `${t('settings.notifications.label')} | ${t('nav.settings')}`,
|
||||
})
|
||||
</script>
|
||||
|
||||
<template>
|
||||
<MainContent back-on-small-screen>
|
||||
<template #title>
|
||||
<div text-lg font-bold flex items-center gap-2 @click="$scrollToTop">
|
||||
<span>{{ $t('settings.notifications.label') }}</span>
|
||||
</div>
|
||||
</template>
|
||||
|
||||
<SettingsItem
|
||||
command
|
||||
:text="$t('settings.notifications.notifications.label')"
|
||||
to="/settings/notifications/notifications"
|
||||
/>
|
||||
<SettingsItem
|
||||
command
|
||||
:disabled="!pwaEnabled"
|
||||
:text="$t('settings.notifications.push_notifications.label')"
|
||||
:description="$t('settings.notifications.push_notifications.description')"
|
||||
to="/settings/notifications/push-notifications"
|
||||
/>
|
||||
</MainContent>
|
||||
</template>
|
31
app/pages/settings/notifications/notifications.vue
Normal file
31
app/pages/settings/notifications/notifications.vue
Normal file
|
@ -0,0 +1,31 @@
|
|||
<script setup lang="ts">
|
||||
definePageMeta({
|
||||
middleware: 'auth',
|
||||
})
|
||||
|
||||
const { t } = useI18n()
|
||||
|
||||
useHydratedHead({
|
||||
title: () => `${t('settings.notifications.notifications.label')} | ${t('settings.notifications.label')} | ${t('nav.settings')}`,
|
||||
})
|
||||
</script>
|
||||
|
||||
<template>
|
||||
<MainContent back>
|
||||
<template #title>
|
||||
<div text-lg font-bold flex items-center gap-2 @click="$scrollToTop">
|
||||
<div i-ri:test-tube-line />
|
||||
<span>{{ $t('settings.notifications.notifications.label') }}</span>
|
||||
</div>
|
||||
</template>
|
||||
<div text-center mt-10>
|
||||
<h1 text-4xl>
|
||||
<span sr-only>{{ $t('settings.notifications.under_construction') }}</span>
|
||||
🚧
|
||||
</h1>
|
||||
<h3 text-xl>
|
||||
{{ $t('settings.notifications.notifications.label') }}
|
||||
</h3>
|
||||
</div>
|
||||
</MainContent>
|
||||
</template>
|
25
app/pages/settings/notifications/push-notifications.vue
Normal file
25
app/pages/settings/notifications/push-notifications.vue
Normal file
|
@ -0,0 +1,25 @@
|
|||
<script setup lang="ts">
|
||||
definePageMeta({
|
||||
middleware: ['auth', () => {
|
||||
if (!useAppConfig().pwaEnabled)
|
||||
return navigateTo('/settings/notifications')
|
||||
}],
|
||||
})
|
||||
|
||||
const { t } = useI18n()
|
||||
|
||||
useHydratedHead({
|
||||
title: () => `${t('settings.notifications.push_notifications.label')} | ${t('settings.notifications.label')} | ${t('nav.settings')}`,
|
||||
})
|
||||
</script>
|
||||
|
||||
<template>
|
||||
<MainContent back>
|
||||
<template #title>
|
||||
<div text-lg font-bold flex items-center gap-2 @click="$scrollToTop">
|
||||
<span>{{ $t('settings.notifications.push_notifications.label') }}</span>
|
||||
</div>
|
||||
</template>
|
||||
<NotificationPreferences show />
|
||||
</MainContent>
|
||||
</template>
|
192
app/pages/settings/preferences/index.vue
Normal file
192
app/pages/settings/preferences/index.vue
Normal file
|
@ -0,0 +1,192 @@
|
|||
<script setup lang="ts">
|
||||
const { t } = useI18n()
|
||||
|
||||
useHydratedHead({
|
||||
title: () => `${t('settings.preferences.label')} | ${t('nav.settings')}`,
|
||||
})
|
||||
|
||||
const userSettings = useUserSettings()
|
||||
</script>
|
||||
|
||||
<template>
|
||||
<MainContent back-on-small-screen>
|
||||
<template #title>
|
||||
<h1 text-lg font-bold flex items-center gap-2 @click="$scrollToTop">
|
||||
{{ $t('settings.preferences.label') }}
|
||||
</h1>
|
||||
</template>
|
||||
<section>
|
||||
<h2 px6 py4 mt2 font-bold text-xl flex="~ gap-1" items-center sr-only>
|
||||
<span aria-hidden="true" block i-ri-equalizer-line />
|
||||
{{ $t('settings.preferences.label') }}
|
||||
</h2>
|
||||
<SettingsToggleItem
|
||||
:checked="getPreferences(userSettings, 'hideAltIndicatorOnPosts')"
|
||||
@click="togglePreferences('hideAltIndicatorOnPosts')"
|
||||
>
|
||||
{{ $t('settings.preferences.hide_alt_indi_on_posts') }}
|
||||
</SettingsToggleItem>
|
||||
<SettingsToggleItem
|
||||
:checked="getPreferences(userSettings, 'hideGifIndicatorOnPosts')"
|
||||
@click="togglePreferences('hideGifIndicatorOnPosts')"
|
||||
>
|
||||
{{ $t('settings.preferences.hide_gif_indi_on_posts') }}
|
||||
</SettingsToggleItem>
|
||||
<SettingsToggleItem
|
||||
:checked="getPreferences(userSettings, 'hideAccountHoverCard')"
|
||||
@click="togglePreferences('hideAccountHoverCard')"
|
||||
>
|
||||
{{ $t('settings.preferences.hide_account_hover_card') }}
|
||||
</SettingsToggleItem>
|
||||
<SettingsToggleItem
|
||||
:checked="getPreferences(userSettings, 'hideTagHoverCard')"
|
||||
@click="togglePreferences('hideTagHoverCard')"
|
||||
>
|
||||
{{ $t('settings.preferences.hide_tag_hover_card') }}
|
||||
</SettingsToggleItem>
|
||||
<SettingsToggleItem
|
||||
:checked="getPreferences(userSettings, 'enableAutoplay')"
|
||||
:disabled="getPreferences(userSettings, 'enableDataSaving')"
|
||||
@click="togglePreferences('enableAutoplay')"
|
||||
>
|
||||
{{ $t('settings.preferences.enable_autoplay') }}
|
||||
</SettingsToggleItem>
|
||||
<SettingsToggleItem
|
||||
:checked="getPreferences(userSettings, 'unmuteVideos')"
|
||||
@click="togglePreferences('unmuteVideos')"
|
||||
>
|
||||
{{ $t('settings.preferences.unmute_videos') }}
|
||||
</SettingsToggleItem>
|
||||
<SettingsToggleItem
|
||||
:checked="getPreferences(userSettings, 'optimizeForLowPerformanceDevice')"
|
||||
@click="togglePreferences('optimizeForLowPerformanceDevice')"
|
||||
>
|
||||
{{ $t('settings.preferences.optimize_for_low_performance_device') }}
|
||||
</SettingsToggleItem>
|
||||
<SettingsToggleItem
|
||||
:checked="getPreferences(userSettings, 'enableDataSaving')"
|
||||
@click="togglePreferences('enableDataSaving')"
|
||||
>
|
||||
{{ $t("settings.preferences.enable_data_saving") }}
|
||||
<template #description>
|
||||
{{ $t("settings.preferences.enable_data_saving_description") }}
|
||||
</template>
|
||||
</SettingsToggleItem>
|
||||
<SettingsToggleItem
|
||||
:checked="getPreferences(userSettings, 'enablePinchToZoom')"
|
||||
@click="togglePreferences('enablePinchToZoom')"
|
||||
>
|
||||
{{ $t('settings.preferences.enable_pinch_to_zoom') }}
|
||||
</SettingsToggleItem>
|
||||
<SettingsToggleItem
|
||||
:checked="getPreferences(userSettings, 'useStarFavoriteIcon')"
|
||||
@click="togglePreferences('useStarFavoriteIcon')"
|
||||
>
|
||||
{{ $t('settings.preferences.use_star_favorite_icon') }}
|
||||
</SettingsToggleItem>
|
||||
</section>
|
||||
<section>
|
||||
<h2 px6 py4 mt2 font-bold text-xl flex="~ gap-1" items-center>
|
||||
<span aria-hidden="true" block i-ri-hearts-line />
|
||||
{{ $t('settings.preferences.wellbeing') }}
|
||||
</h2>
|
||||
<SettingsToggleItem
|
||||
:checked="getPreferences(userSettings, 'grayscaleMode')"
|
||||
@click="togglePreferences('grayscaleMode')"
|
||||
>
|
||||
{{ $t('settings.preferences.grayscale_mode') }}
|
||||
</SettingsToggleItem>
|
||||
<SettingsToggleItem
|
||||
:checked="getPreferences(userSettings, 'hideBoostCount')"
|
||||
@click="togglePreferences('hideBoostCount')"
|
||||
>
|
||||
{{ $t('settings.preferences.hide_boost_count') }}
|
||||
</SettingsToggleItem>
|
||||
<SettingsToggleItem
|
||||
:checked="getPreferences(userSettings, 'hideFavoriteCount')"
|
||||
@click="togglePreferences('hideFavoriteCount')"
|
||||
>
|
||||
{{ $t('settings.preferences.hide_favorite_count') }}
|
||||
</SettingsToggleItem>
|
||||
<SettingsToggleItem
|
||||
:checked="getPreferences(userSettings, 'hideReplyCount')"
|
||||
@click="togglePreferences('hideReplyCount')"
|
||||
>
|
||||
{{ $t('settings.preferences.hide_reply_count') }}
|
||||
</SettingsToggleItem>
|
||||
<SettingsToggleItem
|
||||
:checked="getPreferences(userSettings, 'hideFollowerCount')"
|
||||
@click="togglePreferences('hideFollowerCount')"
|
||||
>
|
||||
{{ $t('settings.preferences.hide_follower_count') }}
|
||||
</SettingsToggleItem>
|
||||
<SettingsToggleItem
|
||||
:checked="getPreferences(userSettings, 'hideUsernameEmojis')"
|
||||
@click="togglePreferences('hideUsernameEmojis')"
|
||||
>
|
||||
{{ $t("settings.preferences.hide_username_emojis") }}
|
||||
<template #description>
|
||||
{{ $t('settings.preferences.hide_username_emojis_description') }}
|
||||
</template>
|
||||
</SettingsToggleItem>
|
||||
<SettingsToggleItem
|
||||
:checked="getPreferences(userSettings, 'hideNews')"
|
||||
@click="togglePreferences('hideNews')"
|
||||
>
|
||||
{{ $t("settings.preferences.hide_news") }}
|
||||
</SettingsToggleItem>
|
||||
<SettingsToggleItem
|
||||
:checked="getPreferences(userSettings, 'zenMode')"
|
||||
@click="togglePreferences('zenMode')"
|
||||
>
|
||||
{{ $t("settings.preferences.zen_mode") }}
|
||||
<template #description>
|
||||
{{ $t('settings.preferences.zen_mode_description') }}
|
||||
</template>
|
||||
</SettingsToggleItem>
|
||||
</section>
|
||||
<section>
|
||||
<h2 px6 py4 mt2 font-bold text-xl flex="~ gap-1" items-center>
|
||||
<span aria-hidden="true" block i-ri-flask-line />
|
||||
{{ $t('settings.preferences.title') }}
|
||||
</h2>
|
||||
<!-- Embedded Media -->
|
||||
<SettingsToggleItem
|
||||
:checked="getPreferences(userSettings, 'experimentalEmbeddedMedia')"
|
||||
@click="togglePreferences('experimentalEmbeddedMedia')"
|
||||
>
|
||||
{{ $t('settings.preferences.embedded_media') }}
|
||||
<template #description>
|
||||
{{ $t('settings.preferences.embedded_media_description') }}
|
||||
</template>
|
||||
</SettingsToggleItem>
|
||||
<SettingsToggleItem
|
||||
:checked="getPreferences(userSettings, 'experimentalVirtualScroller')"
|
||||
@click="togglePreferences('experimentalVirtualScroller')"
|
||||
>
|
||||
{{ $t('settings.preferences.virtual_scroll') }}
|
||||
<template #description>
|
||||
{{ $t('settings.preferences.virtual_scroll_description') }}
|
||||
</template>
|
||||
</SettingsToggleItem>
|
||||
<SettingsToggleItem
|
||||
:checked="getPreferences(userSettings, 'experimentalGitHubCards')"
|
||||
@click="togglePreferences('experimentalGitHubCards')"
|
||||
>
|
||||
{{ $t('settings.preferences.github_cards') }}
|
||||
<template #description>
|
||||
{{ $t('settings.preferences.github_cards_description') }}
|
||||
</template>
|
||||
</SettingsToggleItem>
|
||||
<SettingsToggleItem
|
||||
:checked="getPreferences(userSettings, 'experimentalUserPicker')"
|
||||
@click="togglePreferences('experimentalUserPicker')"
|
||||
>
|
||||
{{ $t('settings.preferences.user_picker') }}
|
||||
<template #description>
|
||||
{{ $t('settings.preferences.user_picker_description') }}
|
||||
</template>
|
||||
</SettingsToggleItem>
|
||||
</section>
|
||||
</MainContent>
|
||||
</template>
|
252
app/pages/settings/profile/appearance.vue
Normal file
252
app/pages/settings/profile/appearance.vue
Normal file
|
@ -0,0 +1,252 @@
|
|||
<script setup lang="ts">
|
||||
import type { mastodon } from 'masto'
|
||||
import { useForm } from 'slimeform'
|
||||
|
||||
definePageMeta({
|
||||
middleware: 'auth',
|
||||
})
|
||||
|
||||
const { t } = useI18n()
|
||||
|
||||
useHydratedHead({
|
||||
title: () => `${t('settings.profile.appearance.title')} | ${t('nav.settings')}`,
|
||||
})
|
||||
|
||||
const { client } = useMasto()
|
||||
|
||||
const avatarInput = ref<any>()
|
||||
const headerInput = ref<any>()
|
||||
|
||||
const account = computed(() => currentUser.value?.account)
|
||||
|
||||
const onlineSrc = computed(() => ({
|
||||
avatar: account.value?.avatar || '',
|
||||
header: account.value?.header || '',
|
||||
}))
|
||||
|
||||
const { form, reset, submitter, isDirty, isError } = useForm({
|
||||
form: () => {
|
||||
// For complex types of objects, a deep copy is required to ensure correct comparison of initial and modified values
|
||||
const fieldsAttributes = Array.from({ length: maxAccountFieldCount.value }, (_, i) => {
|
||||
const field = { ...account.value?.fields?.[i] || { name: '', value: '' } }
|
||||
|
||||
field.value = convertMetadata(field.value)
|
||||
|
||||
return field
|
||||
})
|
||||
return {
|
||||
displayName: account.value?.displayName ?? '',
|
||||
note: account.value?.source.note.replaceAll('\r', '') ?? '',
|
||||
|
||||
avatar: null as null | File,
|
||||
header: null as null | File,
|
||||
|
||||
fieldsAttributes,
|
||||
|
||||
bot: account.value?.bot ?? false,
|
||||
locked: account.value?.locked ?? false,
|
||||
|
||||
// These look more like account and privacy settings than appearance settings
|
||||
// discoverable: false,
|
||||
// locked: false,
|
||||
}
|
||||
},
|
||||
})
|
||||
|
||||
const isCanSubmit = computed(() => !isError.value && isDirty.value)
|
||||
const failedMessages = ref<string[]>([])
|
||||
|
||||
const { submit, submitting } = submitter(async ({ dirtyFields }) => {
|
||||
if (!isCanSubmit.value)
|
||||
return
|
||||
|
||||
const res = await client.value.v1.accounts.updateCredentials(dirtyFields.value as mastodon.rest.v1.UpdateCredentialsParams)
|
||||
.then(account => ({ account }))
|
||||
.catch((error: Error) => ({ error }))
|
||||
|
||||
if ('error' in res) {
|
||||
console.error(res.error)
|
||||
failedMessages.value.push(res.error.message)
|
||||
return
|
||||
}
|
||||
|
||||
const server = currentUser.value!.server
|
||||
|
||||
if (!res.account.acct.includes('@'))
|
||||
res.account.acct = `${res.account.acct}@${server}`
|
||||
|
||||
cacheAccount(res.account, server, true)
|
||||
currentUser.value!.account = res.account
|
||||
reset()
|
||||
})
|
||||
|
||||
async function refreshInfo() {
|
||||
if (!currentUser.value)
|
||||
return
|
||||
// Keep the information to be edited up to date
|
||||
await refreshAccountInfo()
|
||||
if (!isDirty)
|
||||
reset()
|
||||
}
|
||||
|
||||
useDropZone(avatarInput, (files) => {
|
||||
if (files?.[0])
|
||||
form.avatar = files[0]
|
||||
})
|
||||
useDropZone(headerInput, (files) => {
|
||||
if (files?.[0])
|
||||
form.header = files[0]
|
||||
})
|
||||
|
||||
onHydrated(refreshInfo)
|
||||
onReactivated(refreshInfo)
|
||||
</script>
|
||||
|
||||
<template>
|
||||
<MainContent back>
|
||||
<template #title>
|
||||
<div text-lg font-bold flex items-center gap-2 @click="$scrollToTop">
|
||||
<span>{{ $t('settings.profile.appearance.title') }}</span>
|
||||
</div>
|
||||
</template>
|
||||
|
||||
<form space-y-5 @submit.prevent="submit">
|
||||
<div v-if="account">
|
||||
<!-- banner -->
|
||||
<div of-hidden bg="gray-500/20" aspect="3">
|
||||
<CommonInputImage
|
||||
ref="headerInput"
|
||||
v-model="form.header"
|
||||
:original="onlineSrc.header"
|
||||
w-full h-full
|
||||
/>
|
||||
</div>
|
||||
<CommonCropImage v-model="form.header" :stencil-aspect-ratio="3 / 1" />
|
||||
|
||||
<!-- avatar -->
|
||||
<div px-4 flex="~ gap4">
|
||||
<CommonInputImage
|
||||
ref="avatarInput"
|
||||
v-model="form.avatar"
|
||||
:original="onlineSrc.avatar"
|
||||
mt--10
|
||||
rounded-full border="bg-base 4"
|
||||
w="sm:30 24" min-w="sm:30 24" h="sm:30 24"
|
||||
/>
|
||||
</div>
|
||||
<CommonCropImage v-model="form.avatar" />
|
||||
|
||||
<div px4>
|
||||
<div flex justify-between>
|
||||
<AccountDisplayName
|
||||
:account="{ ...account, displayName: form.displayName }"
|
||||
font-bold sm:text-2xl text-xl
|
||||
/>
|
||||
<div flex="~ row" items-center gap2>
|
||||
<label>
|
||||
<AccountLockIndicator show-label px2 py1>
|
||||
<template #prepend>
|
||||
<input v-model="form.locked" type="checkbox" cursor-pointer>
|
||||
</template>
|
||||
</AccountLockIndicator>
|
||||
</label>
|
||||
<label>
|
||||
<AccountBotIndicator show-label px2 py1>
|
||||
<template #prepend>
|
||||
<input v-model="form.bot" type="checkbox" cursor-pointer>
|
||||
</template>
|
||||
</AccountBotIndicator>
|
||||
</label>
|
||||
</div>
|
||||
</div>
|
||||
<AccountHandle :account="account" />
|
||||
</div>
|
||||
</div>
|
||||
|
||||
<div px4 py3 space-y-5>
|
||||
<!-- display name -->
|
||||
<label space-y-2 block>
|
||||
<p font-medium>
|
||||
{{ $t('settings.profile.appearance.display_name') }}
|
||||
</p>
|
||||
<input v-model="form.displayName" type="text" input-base>
|
||||
</label>
|
||||
|
||||
<!-- note -->
|
||||
<label space-y-2 block>
|
||||
<p font-medium>
|
||||
{{ $t('settings.profile.appearance.bio') }}
|
||||
</p>
|
||||
<textarea v-model="form.note" maxlength="500" min-h-10ex input-base />
|
||||
</label>
|
||||
|
||||
<!-- metadata -->
|
||||
|
||||
<SettingsProfileMetadata v-model="form" />
|
||||
|
||||
<!-- actions -->
|
||||
<div flex="~ gap2" justify-end>
|
||||
<button
|
||||
type="button"
|
||||
btn-text text-sm
|
||||
flex gap-x-2 items-center
|
||||
text-red
|
||||
@click="reset()"
|
||||
>
|
||||
<div aria-hidden="true" i-ri:eraser-line />
|
||||
{{ $t('action.reset') }}
|
||||
</button>
|
||||
|
||||
<button
|
||||
v-if="failedMessages.length === 0"
|
||||
type="submit"
|
||||
btn-solid rounded-full text-sm
|
||||
flex gap-x-2 items-center
|
||||
:disabled="submitting || !isCanSubmit"
|
||||
>
|
||||
<span v-if="submitting" aria-hidden="true" block animate-spin preserve-3d>
|
||||
<span block i-ri:loader-2-fill aria-hidden="true" />
|
||||
</span>
|
||||
<span v-else aria-hidden="true" block i-ri:save-line />
|
||||
{{ $t('action.save') }}
|
||||
</button>
|
||||
|
||||
<button
|
||||
v-else
|
||||
type="submit"
|
||||
btn-danger rounded-full text-sm
|
||||
flex gap-x-2 items-center
|
||||
>
|
||||
<span
|
||||
aria-hidden="true" block i-carbon:face-dizzy-filled
|
||||
/>
|
||||
<span>{{ $t('state.save_failed') }}</span>
|
||||
</button>
|
||||
</div>
|
||||
|
||||
<CommonErrorMessage v-if="failedMessages.length > 0" described-by="save-failed">
|
||||
<header id="save-failed" flex justify-between>
|
||||
<div flex items-center gap-x-2 font-bold>
|
||||
<div aria-hidden="true" i-ri:error-warning-fill />
|
||||
<p>{{ $t('state.save_failed') }}</p>
|
||||
</div>
|
||||
<CommonTooltip placement="bottom" :content="$t('action.clear_save_failed')">
|
||||
<button
|
||||
flex rounded-4 p1 hover:bg-active cursor-pointer transition-100 :aria-label="$t('action.clear_save_failed')"
|
||||
@click="failedMessages = []"
|
||||
>
|
||||
<span aria-hidden="true" w="1.75em" h="1.75em" i-ri:close-line />
|
||||
</button>
|
||||
</CommonTooltip>
|
||||
</header>
|
||||
<ol ps-2 sm:ps-1>
|
||||
<li v-for="(error, i) in failedMessages" :key="i" flex="~ col sm:row" gap-y-1 sm:gap-x-2>
|
||||
<strong>{{ i + 1 }}.</strong>
|
||||
<span>{{ error }}</span>
|
||||
</li>
|
||||
</ol>
|
||||
</CommonErrorMessage>
|
||||
</div>
|
||||
</form>
|
||||
</MainContent>
|
||||
</template>
|
31
app/pages/settings/profile/featured-tags.vue
Normal file
31
app/pages/settings/profile/featured-tags.vue
Normal file
|
@ -0,0 +1,31 @@
|
|||
<script setup lang="ts">
|
||||
definePageMeta({
|
||||
middleware: 'auth',
|
||||
})
|
||||
|
||||
const { t } = useI18n()
|
||||
|
||||
useHydratedHead({
|
||||
title: () => `${t('settings.profile.featured_tags.label')} | ${t('nav.settings')}`,
|
||||
})
|
||||
</script>
|
||||
|
||||
<template>
|
||||
<MainContent back>
|
||||
<template #title>
|
||||
<div text-lg font-bold flex items-center gap-2 @click="$scrollToTop">
|
||||
<div i-ri:test-tube-line />
|
||||
<span>{{ $t('settings.profile.featured_tags.label') }}</span>
|
||||
</div>
|
||||
</template>
|
||||
<div text-center mt-10>
|
||||
<h1 text-4xl>
|
||||
<span sr-only>{{ $t('settings.profile.featured_tags.under_construction') }}</span>
|
||||
🚧
|
||||
</h1>
|
||||
<h3 text-xl>
|
||||
{{ $t('settings.profile.featured_tags.label') }}
|
||||
</h3>
|
||||
</div>
|
||||
</MainContent>
|
||||
</template>
|
45
app/pages/settings/profile/index.vue
Normal file
45
app/pages/settings/profile/index.vue
Normal file
|
@ -0,0 +1,45 @@
|
|||
<script setup lang="ts">
|
||||
definePageMeta({
|
||||
middleware: 'auth',
|
||||
})
|
||||
|
||||
const { t } = useI18n()
|
||||
|
||||
useHydratedHead({
|
||||
title: () => `${t('settings.profile.label')} | ${t('nav.settings')}`,
|
||||
})
|
||||
</script>
|
||||
|
||||
<template>
|
||||
<MainContent back-on-small-screen>
|
||||
<template #title>
|
||||
<div text-lg font-bold flex items-center gap-2 @click="$scrollToTop">
|
||||
<span>{{ $t('settings.profile.label') }}</span>
|
||||
</div>
|
||||
</template>
|
||||
|
||||
<SettingsItem
|
||||
command large
|
||||
icon="i-ri:user-settings-line"
|
||||
:text="$t('settings.profile.appearance.label')"
|
||||
:description="$t('settings.profile.appearance.description')"
|
||||
to="/settings/profile/appearance"
|
||||
/>
|
||||
<SettingsItem
|
||||
command large
|
||||
icon="i-ri:hashtag"
|
||||
:text="$t('settings.profile.featured_tags.label')"
|
||||
:description="$t('settings.profile.featured_tags.description')"
|
||||
to="/settings/profile/featured-tags"
|
||||
/>
|
||||
<SettingsItem
|
||||
v-if="isHydrated && currentUser"
|
||||
command large
|
||||
icon="i-ri:settings-line"
|
||||
:text="$t('settings.account_settings.label')"
|
||||
:description="$t('settings.account_settings.description')"
|
||||
:to="`https://${currentUser!.server}/auth/edit`"
|
||||
external target="_blank"
|
||||
/>
|
||||
</MainContent>
|
||||
</template>
|
94
app/pages/settings/users/index.vue
Normal file
94
app/pages/settings/users/index.vue
Normal file
|
@ -0,0 +1,94 @@
|
|||
<script setup lang="ts">
|
||||
/* eslint-disable no-alert */
|
||||
import type { UserLogin } from '#shared/types'
|
||||
import { fileOpen } from 'browser-fs-access'
|
||||
|
||||
const { t } = useI18n()
|
||||
|
||||
useHydratedHead({
|
||||
title: () => `${t('settings.users.label')} | ${t('nav.settings')}`,
|
||||
})
|
||||
|
||||
const loggedInUsers = useUsers()
|
||||
|
||||
async function exportTokens() {
|
||||
if (import.meta.server)
|
||||
return
|
||||
|
||||
if (!confirm('Please aware that the tokens represent the **full access** to your accounts, and should be treated as sensitive information. Are you sure you want to export the tokens?'))
|
||||
return
|
||||
|
||||
const { saveAs } = await import('file-saver')
|
||||
const data = {
|
||||
'/': 'Generated by Elk, you can import it to Elk to restore the tokens.',
|
||||
'//': 'This file represents the **full access** to your accounts, and should be treated as sensitive information.',
|
||||
'version': 1,
|
||||
'origin': location.origin,
|
||||
'users': loggedInUsers.value,
|
||||
}
|
||||
const blob = new Blob([JSON.stringify(data, null, 2)], { type: 'application/json;charset=utf-8' })
|
||||
saveAs(blob, `elk-users-tokens-${new Date().toISOString().slice(0, 10)}.json`)
|
||||
}
|
||||
|
||||
async function importTokens() {
|
||||
if (import.meta.server)
|
||||
return
|
||||
const file = await fileOpen({
|
||||
description: 'Token File',
|
||||
mimeTypes: ['application/json'],
|
||||
})
|
||||
|
||||
try {
|
||||
const content = await file.text()
|
||||
const data = JSON.parse(content)
|
||||
if (data.version !== 1)
|
||||
throw new Error('Invalid version')
|
||||
const users = data.users as UserLogin[]
|
||||
const newUsers: UserLogin[] = []
|
||||
for (const user of users) {
|
||||
if (loggedInUsers.value.some(u => u.server === user.server && u.account.id === user.account.id))
|
||||
continue
|
||||
newUsers.push(user)
|
||||
}
|
||||
if (newUsers.length === 0) {
|
||||
alert('No new users found')
|
||||
return
|
||||
}
|
||||
if (!confirm(`Found ${newUsers.length} new users, are you sure you want to import them?`))
|
||||
return
|
||||
loggedInUsers.value = [...loggedInUsers.value, ...newUsers]
|
||||
}
|
||||
catch (e) {
|
||||
console.error(e)
|
||||
alert('Invalid Elk tokens file')
|
||||
}
|
||||
}
|
||||
</script>
|
||||
|
||||
<template>
|
||||
<MainContent back-on-small-screen>
|
||||
<template #title>
|
||||
<div text-lg font-bold flex items-center gap-2 @click="$scrollToTop">
|
||||
<span>{{ $t('settings.users.label') }}</span>
|
||||
</div>
|
||||
</template>
|
||||
<div p6>
|
||||
<template v-if="loggedInUsers.length">
|
||||
<div flex="~ col gap2">
|
||||
<div v-for="user of loggedInUsers" :key="user.account.id">
|
||||
<AccountInfo :account="user.account" :hover-card="false" />
|
||||
</div>
|
||||
</div>
|
||||
<div my4 border="t base" />
|
||||
<button btn-text flex="~ gap-2" items-center @click="exportTokens">
|
||||
<span block i-ri-download-2-line />
|
||||
{{ $t('settings.users.export') }}
|
||||
</button>
|
||||
</template>
|
||||
<button btn-text flex="~ gap-2" items-center @click="importTokens">
|
||||
<span block i-ri-upload-2-line />
|
||||
{{ $t('settings.users.import') }}
|
||||
</button>
|
||||
</div>
|
||||
</MainContent>
|
||||
</template>
|
38
app/pages/share-target.vue
Normal file
38
app/pages/share-target.vue
Normal file
|
@ -0,0 +1,38 @@
|
|||
<script setup lang="ts">
|
||||
definePageMeta({
|
||||
middleware: () => {
|
||||
if (!useAppConfig().pwaEnabled)
|
||||
return navigateTo('/')
|
||||
},
|
||||
})
|
||||
|
||||
useWebShareTarget()
|
||||
|
||||
const pwaIsInstalled = import.meta.client && !!useNuxtApp().$pwa?.isInstalled
|
||||
</script>
|
||||
|
||||
<template>
|
||||
<MainContent>
|
||||
<template #title>
|
||||
<NuxtLink to="/share-target" flex items-center gap-2>
|
||||
<div i-ri:share-line />
|
||||
<span>{{ $t('share_target.title') }}</span>
|
||||
</NuxtLink>
|
||||
</template>
|
||||
<slot>
|
||||
<div flex="~ col" px5 py2 gap-y-4>
|
||||
<div
|
||||
v-if="!pwaIsInstalled || !currentUser"
|
||||
role="alert"
|
||||
gap-1
|
||||
p-2
|
||||
text-red-600 dark:text-red-400
|
||||
border="~ base rounded red-600 dark:red-400"
|
||||
>
|
||||
{{ $t('share_target.hint') }}
|
||||
</div>
|
||||
<div>{{ $t('share_target.description') }}</div>
|
||||
</div>
|
||||
</slot>
|
||||
</MainContent>
|
||||
</template>
|
Loading…
Add table
Add a link
Reference in a new issue