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

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

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

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

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

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

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

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

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

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

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

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