<script lang="ts" setup> 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>