feat: added a profile settings and settings nav (#432)
This commit is contained in:
parent
c8a7e6e7e7
commit
613c5315b3
23 changed files with 698 additions and 7 deletions
|
@ -62,6 +62,8 @@ watchEffect(() => {
|
|||
namedFields.value = named
|
||||
iconFields.value = icons
|
||||
})
|
||||
|
||||
const isSelf = $computed(() => currentUser.value?.account.id === account.id)
|
||||
</script>
|
||||
|
||||
<template>
|
||||
|
@ -91,6 +93,15 @@ watchEffect(() => {
|
|||
<div absolute top-18 right-0 flex gap-2 items-center>
|
||||
<AccountMoreButton :account="account" :command="command" />
|
||||
<AccountFollowButton :account="account" :command="command" />
|
||||
<!-- Edit profile -->
|
||||
<NuxtLink
|
||||
v-if="isSelf"
|
||||
to="/settings/profile/appearance"
|
||||
gap-1 items-center border="1" rounded-full flex="~ gap2 center" font-500 w-30 h-fit py1
|
||||
hover="border-primary text-primary bg-active"
|
||||
>
|
||||
{{ $t('settings.profile.appearance.title') }}
|
||||
</NuxtLink>
|
||||
<!-- <button flex gap-1 items-center w-full rounded op75 hover="op100 text-purple" group>
|
||||
<div rounded p2 group-hover="bg-rose/10">
|
||||
<div i-ri:bell-line />
|
||||
|
|
109
components/common/CommonCropImage.vue
Normal file
109
components/common/CommonCropImage.vue
Normal file
|
@ -0,0 +1,109 @@
|
|||
<script lang="ts" setup>
|
||||
import type { Boundaries } from 'vue-advanced-cropper'
|
||||
import { Cropper } from 'vue-advanced-cropper'
|
||||
import 'vue-advanced-cropper/dist/style.css'
|
||||
|
||||
export interface Props {
|
||||
/** Images to be cropped */
|
||||
modelValue?: File
|
||||
/** Crop frame aspect ratio (width/height), default 1/1 */
|
||||
stencilAspectRatio?: number
|
||||
/** The ratio of the longest edge of the cut box to the length of the cut screen, default 0.9, not more than 1 */
|
||||
stencilSizePercentage?: number
|
||||
}
|
||||
const props = withDefaults(defineProps<Props>(), {
|
||||
stencilAspectRatio: 1 / 1,
|
||||
stencilSizePercentage: 0.9,
|
||||
})
|
||||
|
||||
const emits = defineEmits<{
|
||||
(event: 'update:modelValue', value: File): void
|
||||
}>()
|
||||
|
||||
const vmFile = useVModel(props, 'modelValue', emits, { passive: true })
|
||||
|
||||
const cropperDialog = ref(false)
|
||||
|
||||
const cropper = ref<InstanceType<typeof Cropper>>()
|
||||
|
||||
const cropperFlag = ref(false)
|
||||
|
||||
const cropperImage = reactive({
|
||||
src: '',
|
||||
type: 'image/jpg',
|
||||
})
|
||||
|
||||
const stencilSize = ({ boundaries }: { boundaries: Boundaries }) => {
|
||||
return {
|
||||
width: boundaries.width * props.stencilSizePercentage,
|
||||
height: boundaries.height * props.stencilSizePercentage,
|
||||
}
|
||||
}
|
||||
|
||||
watch(vmFile, (file, _, onCleanup) => {
|
||||
let expired = false
|
||||
onCleanup(() => expired = true)
|
||||
|
||||
if (file && !cropperFlag.value) {
|
||||
cropperDialog.value = true
|
||||
const reader = new FileReader()
|
||||
reader.readAsDataURL(file)
|
||||
reader.onload = (e) => {
|
||||
if (expired)
|
||||
return
|
||||
cropperImage.src = e.target?.result as string
|
||||
cropperImage.type = file.type
|
||||
}
|
||||
}
|
||||
cropperFlag.value = false
|
||||
})
|
||||
|
||||
const cropImage = () => {
|
||||
if (cropper.value && vmFile.value) {
|
||||
cropperFlag.value = true
|
||||
cropperDialog.value = false
|
||||
const { canvas } = cropper.value.getResult()
|
||||
canvas?.toBlob((blob) => {
|
||||
vmFile.value = new File([blob as any], `cropped${vmFile.value?.name}` as string, { type: blob?.type })
|
||||
}, cropperImage.type)
|
||||
}
|
||||
}
|
||||
</script>
|
||||
|
||||
<template>
|
||||
<ModalDialog v-model="cropperDialog" :use-v-if="false" max-w-500px flex>
|
||||
<div flex-1 w-0>
|
||||
<div text-lg text-center my2 px3>
|
||||
<h1>
|
||||
{{ $t('action.edit') }}
|
||||
</h1>
|
||||
</div>
|
||||
<div aspect-ratio-1>
|
||||
<Cropper
|
||||
ref="cropper"
|
||||
class="overflow-hidden w-full h-full"
|
||||
:src="cropperImage.src"
|
||||
:resize-image="{
|
||||
adjustStencil: false,
|
||||
}"
|
||||
:stencil-size="stencilSize"
|
||||
:stencil-props="{
|
||||
aspectRatio: props.stencilAspectRatio,
|
||||
movable: false,
|
||||
resizable: false,
|
||||
handlers: {},
|
||||
}"
|
||||
image-restriction="stencil"
|
||||
/>
|
||||
</div>
|
||||
<div m-4>
|
||||
<button
|
||||
btn-solid w-full rounded text-sm
|
||||
@click="cropImage()"
|
||||
>
|
||||
{{ $t('action.confirm') }}
|
||||
</button>
|
||||
</div>
|
||||
</div>
|
||||
</ModalDialog>
|
||||
</template>
|
121
components/common/CommonInputImage.vue
Normal file
121
components/common/CommonInputImage.vue
Normal file
|
@ -0,0 +1,121 @@
|
|||
<script lang="ts" setup>
|
||||
const props = withDefaults(defineProps<{
|
||||
modelValue?: File
|
||||
/** The image src before change */
|
||||
original?: string
|
||||
/** Allowed file types */
|
||||
allowedFileTypes?: string[]
|
||||
/** Allowed file size */
|
||||
allowedFileSize?: number
|
||||
|
||||
imgClass?: string
|
||||
|
||||
loading?: boolean
|
||||
}>(), {
|
||||
allowedFileTypes: () => ['image/jpeg', 'image/png'],
|
||||
allowedFileSize: 1024 * 1024 * 5, // 5 MB
|
||||
})
|
||||
const emits = defineEmits<{
|
||||
(event: 'update:modelValue', value: File): void
|
||||
(event: 'error', code: number, message: string): void
|
||||
}>()
|
||||
|
||||
const vmFile = useVModel(props, 'modelValue', emits, { passive: true })
|
||||
|
||||
const { t } = useI18n()
|
||||
|
||||
const elInput = ref<HTMLInputElement>()
|
||||
|
||||
function clearInput() {
|
||||
if (elInput.value)
|
||||
elInput.value.value = ''
|
||||
}
|
||||
|
||||
function selectImage(e: Event) {
|
||||
const target = e.target as HTMLInputElement
|
||||
const image = target.files?.[0]
|
||||
if (!image) {
|
||||
vmFile.value = image
|
||||
}
|
||||
else if (!props.allowedFileTypes.includes(image.type)) {
|
||||
emits('error', 1, t('error.unsupported_file_format'))
|
||||
clearInput()
|
||||
}
|
||||
else if (image.size > props.allowedFileSize) {
|
||||
emits('error', 2, t('error.file_size_cannot_exceed_n_mb', [5]))
|
||||
clearInput()
|
||||
}
|
||||
else {
|
||||
vmFile.value = image
|
||||
clearInput()
|
||||
}
|
||||
}
|
||||
|
||||
const defaultImage = computed(() => props.original || '')
|
||||
/** Preview of selected images */
|
||||
const previewImage = ref('')
|
||||
/** The current images on display */
|
||||
const imageSrc = computed<string>(() => previewImage.value || defaultImage.value)
|
||||
|
||||
// Update the preview image when the input file change
|
||||
watch(vmFile, (image, _, onCleanup) => {
|
||||
let expired = false
|
||||
onCleanup(() => expired = true)
|
||||
|
||||
if (image) {
|
||||
const reader = new FileReader()
|
||||
reader.readAsDataURL(image)
|
||||
reader.onload = (e) => {
|
||||
if (expired)
|
||||
return
|
||||
previewImage.value = e.target?.result as string
|
||||
}
|
||||
}
|
||||
else {
|
||||
previewImage.value = ''
|
||||
clearInput()
|
||||
}
|
||||
})
|
||||
|
||||
defineExpose({
|
||||
clearInput,
|
||||
})
|
||||
</script>
|
||||
|
||||
<template>
|
||||
<label
|
||||
class="bg-slate-500/10 focus-within:(outline outline-primary)"
|
||||
relative
|
||||
flex justify-center items-center
|
||||
cursor-pointer
|
||||
of-hidden
|
||||
>
|
||||
<img
|
||||
v-if="imageSrc"
|
||||
:src="imageSrc"
|
||||
:class="imgClass || ''"
|
||||
object-cover
|
||||
w-full
|
||||
h-full
|
||||
>
|
||||
<div absolute bg="black/50" text-white rounded-full text-xl w12 h12 flex justify-center items-center hover="bg-black/40 text-primary">
|
||||
<div i-ri:upload-line />
|
||||
</div>
|
||||
|
||||
<div
|
||||
v-if="loading"
|
||||
absolute inset-0
|
||||
bg="black/30" text-white
|
||||
flex justify-center items-center
|
||||
>
|
||||
<div class="i-ri:loader-4-line animate-spin animate-duration-[2.5s]" text-4xl />
|
||||
</div>
|
||||
<input
|
||||
ref="elInput"
|
||||
type="file"
|
||||
absolute opacity-0 inset-0 z--1
|
||||
:accept="allowedFileTypes.join(',')"
|
||||
@change="selectImage"
|
||||
>
|
||||
</label>
|
||||
</template>
|
|
@ -21,5 +21,6 @@ const { notifications } = useNotifications()
|
|||
<NavSideItem :text="$t('nav_side.conversations')" to="/conversations" icon="i-ri:at-line" user-only />
|
||||
<NavSideItem :text="$t('nav_side.favourites')" to="/favourites" icon="i-ri:heart-3-line" user-only />
|
||||
<NavSideItem :text="$t('nav_side.bookmarks')" to="/bookmarks" icon="i-ri:bookmark-line " user-only />
|
||||
<NavSideItem :text="$t('nav_side.settings')" to="/settings" icon="i-ri:settings-4-line " user-only />
|
||||
</nav>
|
||||
</template>
|
||||
|
|
60
components/settings/SettingsNavItem.vue
Normal file
60
components/settings/SettingsNavItem.vue
Normal file
|
@ -0,0 +1,60 @@
|
|||
<script lang="ts" setup>
|
||||
const props = defineProps<{
|
||||
text?: string
|
||||
icon?: string
|
||||
to: string | Record<string, string>
|
||||
command?: boolean
|
||||
}>()
|
||||
|
||||
const router = useRouter()
|
||||
|
||||
if (props.command) {
|
||||
useCommand({
|
||||
scope: 'Settings',
|
||||
|
||||
name: () => props.text ?? (typeof props.to === 'string' ? props.to as string : props.to.name),
|
||||
icon: () => props.icon || '',
|
||||
|
||||
onActivate() {
|
||||
router.push(props.to)
|
||||
},
|
||||
})
|
||||
}
|
||||
</script>
|
||||
|
||||
<template>
|
||||
<NuxtLink
|
||||
:to="to"
|
||||
exact-active-class="text-primary"
|
||||
block w-full group focus:outline-none
|
||||
@click="$scrollToTop"
|
||||
>
|
||||
<div
|
||||
w-full flex w-fit px5 py3 md:gap2 gap4 items-center
|
||||
transition-250 group-hover:bg-active
|
||||
group-focus-visible:ring="2 current"
|
||||
>
|
||||
<div flex-1 flex items-center md:gap2 gap4>
|
||||
<div
|
||||
flex items-center justify-center
|
||||
:class="$slots.description ? 'w-12 h-12' : ''"
|
||||
>
|
||||
<slot name="icon">
|
||||
<div v-if="icon" :class="icon" md:text-size-inherit text-xl />
|
||||
</slot>
|
||||
</div>
|
||||
<div space-y-1>
|
||||
<p>
|
||||
<slot>
|
||||
<span>{{ text }}</span>
|
||||
</slot>
|
||||
</p>
|
||||
<p v-if="$slots.description" text-sm text-secondary>
|
||||
<slot name="description" />
|
||||
</p>
|
||||
</div>
|
||||
</div>
|
||||
<div i-ri:arrow-right-s-line text-xl text-secondary-light />
|
||||
</div>
|
||||
</NuxtLink>
|
||||
</template>
|
|
@ -44,6 +44,13 @@ const switchUser = (user: UserLogin) => {
|
|||
icon="i-ri:user-add-line"
|
||||
@click="openSigninDialog"
|
||||
/>
|
||||
|
||||
<NuxtLink to="/settings">
|
||||
<CommonDropdownItem
|
||||
:text="$t('nav_side.settings')"
|
||||
icon="i-ri:settings-4-line"
|
||||
/>
|
||||
</NuxtLink>
|
||||
<CommonDropdownItem
|
||||
v-if="isMastoInitialised && currentUser"
|
||||
:text="$t('user.sign_out_account', [getFullHandle(currentUser.account)])"
|
||||
|
|
Loading…
Add table
Add a link
Reference in a new issue