Refactor project structure and update dependencies
This commit is contained in:
36
nuxt/app/components/AppHeader.vue
Normal file
36
nuxt/app/components/AppHeader.vue
Normal file
@@ -0,0 +1,36 @@
|
||||
<script setup lang="ts">
|
||||
const items = [{
|
||||
label: 'Documentation',
|
||||
icon: 'i-heroicons-book-open',
|
||||
to: 'https://ui.nuxt.com/getting-started',
|
||||
}, {
|
||||
label: 'Pro',
|
||||
icon: 'i-heroicons-square-3-stack-3d',
|
||||
to: 'https://ui.nuxt.com/pro',
|
||||
}, {
|
||||
label: 'Releases',
|
||||
icon: 'i-heroicons-rocket-launch',
|
||||
to: 'https://github.com/nuxt/ui/releases',
|
||||
target: '_blank',
|
||||
}]
|
||||
</script>
|
||||
|
||||
<template>
|
||||
<header
|
||||
class="bg-background/75 backdrop-blur -mb-px sticky top-0 z-50 border-b border-dashed border-gray-200/80 dark:border-gray-800/80"
|
||||
>
|
||||
<UContainer class="flex items-center justify-between gap-3 h-16 py-2">
|
||||
<AppLogo class="lg:flex-1" />
|
||||
|
||||
<UNavigationMenu
|
||||
orientation="horizontal"
|
||||
:items="items"
|
||||
class="hidden lg:block"
|
||||
/>
|
||||
|
||||
<div class="flex items-center justify-end gap-3 lg:flex-1">
|
||||
<UserDropdown />
|
||||
</div>
|
||||
</UContainer>
|
||||
</header>
|
||||
</template>
|
||||
8
nuxt/app/components/AppLogo.vue
Normal file
8
nuxt/app/components/AppLogo.vue
Normal file
@@ -0,0 +1,8 @@
|
||||
<template>
|
||||
<NuxtLink
|
||||
to="/"
|
||||
class="font-bold text-xl"
|
||||
>
|
||||
<span class="text-primary-500">Nuxt</span> Breeze
|
||||
</NuxtLink>
|
||||
</template>
|
||||
33
nuxt/app/components/AppNavigation.vue
Normal file
33
nuxt/app/components/AppNavigation.vue
Normal file
@@ -0,0 +1,33 @@
|
||||
<script setup lang="ts">
|
||||
const links = [
|
||||
{
|
||||
label: 'Company',
|
||||
icon: 'i-heroicons-building-office-2',
|
||||
children: [
|
||||
{
|
||||
label: 'Overview',
|
||||
to: '/login',
|
||||
icon: 'i-heroicons-eye',
|
||||
},
|
||||
{
|
||||
label: 'Add Company',
|
||||
to: '/login',
|
||||
icon: 'i-heroicons-plus-circle',
|
||||
},
|
||||
],
|
||||
},
|
||||
{
|
||||
label: 'People',
|
||||
to: '/login',
|
||||
icon: 'i-heroicons-user-group',
|
||||
},
|
||||
]
|
||||
</script>
|
||||
|
||||
<template>
|
||||
<UNavigationMenu
|
||||
class="data-[orientation=vertical]:w-48"
|
||||
orientation="vertical"
|
||||
:items="links"
|
||||
/>
|
||||
</template>
|
||||
35
nuxt/app/components/UserDropdown.vue
Normal file
35
nuxt/app/components/UserDropdown.vue
Normal file
@@ -0,0 +1,35 @@
|
||||
<script setup lang="ts">
|
||||
const links = [
|
||||
{
|
||||
label: 'Account',
|
||||
to: '/account',
|
||||
icon: 'i-heroicons-user-solid',
|
||||
},
|
||||
{
|
||||
label: 'Logout',
|
||||
to: '/logout',
|
||||
icon: 'i-heroicons-arrow-left-on-rectangle',
|
||||
}]
|
||||
</script>
|
||||
|
||||
<template>
|
||||
<UPopover>
|
||||
<UButton
|
||||
icon="i-heroicons-user-solid"
|
||||
color="gray"
|
||||
variant="ghost"
|
||||
/>
|
||||
<template #content>
|
||||
<div class="p-4">
|
||||
<UNavigationMenu
|
||||
:items="links"
|
||||
:orientation="'vertical'"
|
||||
/>
|
||||
</div>
|
||||
</template>
|
||||
</UPopover>
|
||||
</template>
|
||||
|
||||
<style scoped>
|
||||
|
||||
</style>
|
||||
109
nuxt/app/components/account/AccountDeviceTable.vue
Normal file
109
nuxt/app/components/account/AccountDeviceTable.vue
Normal file
@@ -0,0 +1,109 @@
|
||||
<script setup lang="ts">
|
||||
import type { TableColumn } from '#ui/components/Table.vue'
|
||||
import type { Device, IDevicesResponse } from '~/types/device'
|
||||
|
||||
const UButton = resolveComponent('UButton')
|
||||
const UBadge = resolveComponent('UBadge')
|
||||
const UDropdownMenu = resolveComponent('UDropdownMenu')
|
||||
|
||||
const dayjs = useDayjs()
|
||||
const auth = useAuthStore()
|
||||
const loading = ref(false)
|
||||
const devices = ref<Device[]>([])
|
||||
|
||||
async function fetchData() {
|
||||
loading.value = true
|
||||
|
||||
const response = await $fetch<IDevicesResponse>('devices')
|
||||
|
||||
if (response.ok) {
|
||||
devices.value = response.devices
|
||||
}
|
||||
|
||||
loading.value = false
|
||||
}
|
||||
|
||||
const columns: TableColumn<Device>[] = [
|
||||
{
|
||||
accessorKey: 'name',
|
||||
header: 'Device',
|
||||
cell: ({ row }) => {
|
||||
return h('div', { class: 'font-semibold' }, [
|
||||
row.original.name,
|
||||
row.original.is_current && h(UBadge, {
|
||||
label: 'active',
|
||||
color: 'primary',
|
||||
variant: 'soft',
|
||||
size: 'sm',
|
||||
class: 'ms-1',
|
||||
}),
|
||||
h('div', { class: 'font-medium text-sm' }, `IP: ${row.original.ip}`),
|
||||
])
|
||||
},
|
||||
},
|
||||
{
|
||||
accessorKey: 'last_used_at',
|
||||
header: 'Last used at',
|
||||
cell: ({ row }) => {
|
||||
return dayjs(row.original.last_used_at).fromNow()
|
||||
},
|
||||
},
|
||||
{
|
||||
accessorKey: 'actions',
|
||||
header: 'Actions',
|
||||
cell: ({ row }) => {
|
||||
return h('div', { class: 'flex justify-end' },
|
||||
h(UDropdownMenu, {
|
||||
items: items(row.original),
|
||||
}, () => h(UButton, {
|
||||
icon: 'i-heroicons-ellipsis-vertical-20-solid',
|
||||
variant: 'ghost',
|
||||
color: 'neutral',
|
||||
})),
|
||||
)
|
||||
},
|
||||
},
|
||||
]
|
||||
|
||||
const items = (row: Device) => [
|
||||
[
|
||||
{
|
||||
label: 'Delete',
|
||||
icon: 'i-heroicons-trash-20-solid',
|
||||
onSelect: async () => {
|
||||
await $fetch<never>('devices/disconnect', {
|
||||
method: 'POST',
|
||||
body: {
|
||||
hash: row.hash,
|
||||
},
|
||||
async onResponse({ response }) {
|
||||
if (response._data?.ok) {
|
||||
await fetchData()
|
||||
await auth.fetchUser()
|
||||
}
|
||||
},
|
||||
})
|
||||
},
|
||||
},
|
||||
],
|
||||
]
|
||||
|
||||
if (import.meta.client) {
|
||||
fetchData()
|
||||
}
|
||||
</script>
|
||||
|
||||
<template>
|
||||
<ClientOnly>
|
||||
<UTable
|
||||
:data="devices"
|
||||
:columns="columns"
|
||||
size="lg"
|
||||
:loading="loading"
|
||||
/>
|
||||
</ClientOnly>
|
||||
</template>
|
||||
|
||||
<style scoped>
|
||||
|
||||
</style>
|
||||
125
nuxt/app/components/account/AccountUpdatePassword.vue
Normal file
125
nuxt/app/components/account/AccountUpdatePassword.vue
Normal file
@@ -0,0 +1,125 @@
|
||||
<script lang="ts" setup>
|
||||
import type { IAccountChangePasswordResponse, IVerificationNotificationResponse } from '~/types/account'
|
||||
|
||||
const form = ref()
|
||||
const auth = useAuthStore()
|
||||
|
||||
const state = reactive({
|
||||
current_password: '',
|
||||
password: '',
|
||||
password_confirmation: '',
|
||||
})
|
||||
|
||||
const { refresh: onSubmit, status: accountPasswordStatus } = useFetch<IAccountChangePasswordResponse>('account/password', {
|
||||
method: 'POST',
|
||||
body: state,
|
||||
immediate: false,
|
||||
watch: false,
|
||||
async onResponse({ response }) {
|
||||
if (response?.status === 422) {
|
||||
form.value.setErrors(response._data?.errors)
|
||||
}
|
||||
else if (response._data?.ok) {
|
||||
useToast().add({
|
||||
icon: 'i-heroicons-check-circle-20-solid',
|
||||
title: 'The password was successfully updated.',
|
||||
color: 'primary',
|
||||
})
|
||||
|
||||
state.current_password = ''
|
||||
state.password = ''
|
||||
state.password_confirmation = ''
|
||||
}
|
||||
},
|
||||
})
|
||||
|
||||
const { refresh: sendResetPasswordEmail, status: resetPasswordEmailStatus } = useFetch<IVerificationNotificationResponse>('verification-notification', {
|
||||
method: 'POST',
|
||||
body: { email: auth.user.email },
|
||||
immediate: false,
|
||||
watch: false,
|
||||
onResponse({ response }) {
|
||||
if (response._data?.ok) {
|
||||
useToast().add({
|
||||
icon: 'i-heroicons-check-circle-20-solid',
|
||||
title: 'A link to reset your password has been sent to your email.',
|
||||
color: 'primary',
|
||||
})
|
||||
}
|
||||
},
|
||||
})
|
||||
</script>
|
||||
|
||||
<template>
|
||||
<div>
|
||||
<UForm
|
||||
v-if="auth.user.has_password"
|
||||
ref="form"
|
||||
:state="state"
|
||||
class="space-y-4"
|
||||
@submit="onSubmit"
|
||||
>
|
||||
<UFormField
|
||||
label="Current Password"
|
||||
name="current_password"
|
||||
required
|
||||
>
|
||||
<UInput
|
||||
v-model="state.current_password"
|
||||
type="password"
|
||||
autocomplete="off"
|
||||
/>
|
||||
</UFormField>
|
||||
|
||||
<UFormField
|
||||
label="New Password"
|
||||
name="password"
|
||||
hint="min 8 characters"
|
||||
:ui="{ hint: 'text-xs text-gray-500 dark:text-gray-400' }"
|
||||
required
|
||||
>
|
||||
<UInput
|
||||
v-model="state.password"
|
||||
type="password"
|
||||
autocomplete="off"
|
||||
/>
|
||||
</UFormField>
|
||||
|
||||
<UFormField
|
||||
label="Repeat Password"
|
||||
name="password_confirmation"
|
||||
required
|
||||
>
|
||||
<UInput
|
||||
v-model="state.password_confirmation"
|
||||
type="password"
|
||||
autocomplete="off"
|
||||
/>
|
||||
</UFormField>
|
||||
|
||||
<div class="pt-2">
|
||||
<UButton
|
||||
type="submit"
|
||||
label="Save"
|
||||
:loading="accountPasswordStatus === 'pending'"
|
||||
/>
|
||||
</div>
|
||||
</UForm>
|
||||
|
||||
<UAlert
|
||||
v-else
|
||||
icon="i-heroicons-information-circle-20-solid"
|
||||
title="Send a link to your email to reset your password."
|
||||
description="To create a password for your account, you must go through the password recovery process."
|
||||
:actions="[
|
||||
{
|
||||
label: 'Send link to Email',
|
||||
variant: 'solid',
|
||||
color: 'neutral',
|
||||
loading: resetPasswordEmailStatus === 'pending',
|
||||
onClick: () => sendResetPasswordEmail(),
|
||||
},
|
||||
]"
|
||||
/>
|
||||
</div>
|
||||
</template>
|
||||
128
nuxt/app/components/account/AccountUpdateProfile.vue
Normal file
128
nuxt/app/components/account/AccountUpdateProfile.vue
Normal file
@@ -0,0 +1,128 @@
|
||||
<script lang="ts" setup>
|
||||
import type { IAccountUpdateResponse, IVerificationNotificationResponse } from '~/types/account'
|
||||
|
||||
const form = ref()
|
||||
const auth = useAuthStore()
|
||||
|
||||
const state = reactive({
|
||||
...{
|
||||
email: auth.user.email,
|
||||
name: auth.user.name,
|
||||
avatar: auth.user.avatar,
|
||||
},
|
||||
})
|
||||
|
||||
const { refresh: sendEmailVerification, status: resendEmailStatus } = useFetch<IVerificationNotificationResponse>('verification-notification', {
|
||||
method: 'POST',
|
||||
body: { email: state.email },
|
||||
immediate: false,
|
||||
watch: false,
|
||||
onResponse({ response }) {
|
||||
if (response._data?.ok) {
|
||||
useToast().add({
|
||||
icon: 'i-heroicons-check-circle-20-solid',
|
||||
title: response._data.message,
|
||||
color: 'primary',
|
||||
})
|
||||
}
|
||||
},
|
||||
})
|
||||
|
||||
const { refresh: onSubmit, status: accountUpdateStatus } = useFetch<IAccountUpdateResponse>('account/update', {
|
||||
method: 'POST',
|
||||
body: state,
|
||||
immediate: false,
|
||||
watch: false,
|
||||
async onResponse({ response }) {
|
||||
if (response?.status === 422) {
|
||||
form.value.setErrors(response._data?.errors)
|
||||
}
|
||||
else if (response._data?.ok) {
|
||||
useToast().add({
|
||||
icon: 'i-heroicons-check-circle-20-solid',
|
||||
title: 'Account details have been successfully updated.',
|
||||
color: 'primary',
|
||||
})
|
||||
|
||||
await auth.fetchUser()
|
||||
|
||||
state.name = auth.user.name
|
||||
state.email = auth.user.email
|
||||
state.avatar = auth.user.avatar
|
||||
}
|
||||
},
|
||||
})
|
||||
</script>
|
||||
|
||||
<template>
|
||||
<UForm
|
||||
ref="form"
|
||||
:state="state"
|
||||
class="space-y-4"
|
||||
@submit="onSubmit"
|
||||
>
|
||||
<UFormField
|
||||
label=""
|
||||
name="avatar"
|
||||
class="flex"
|
||||
>
|
||||
<InputUploadAvatar
|
||||
v-model="state.avatar"
|
||||
accept=".png, .jpg, .jpeg, .webp"
|
||||
entity="avatars"
|
||||
max-size="2"
|
||||
/>
|
||||
</UFormField>
|
||||
|
||||
<UFormField
|
||||
label="Name"
|
||||
name="name"
|
||||
required
|
||||
>
|
||||
<UInput
|
||||
v-model="state.name"
|
||||
type="text"
|
||||
/>
|
||||
</UFormField>
|
||||
|
||||
<UFormField
|
||||
label="Email"
|
||||
name="email"
|
||||
required
|
||||
>
|
||||
<UInput
|
||||
v-model="state.email"
|
||||
placeholder="you@example.com"
|
||||
icon="i-heroicons-envelope"
|
||||
trailing
|
||||
type="email"
|
||||
/>
|
||||
</UFormField>
|
||||
|
||||
<UAlert
|
||||
v-if="auth.user.must_verify_email"
|
||||
variant="subtle"
|
||||
color="warning"
|
||||
icon="i-heroicons-information-circle-20-solid"
|
||||
title="Please confirm your email address."
|
||||
description="A confirmation email has been sent to your email address. Please click on the confirmation link in the email to verify your email address."
|
||||
:actions="[
|
||||
{
|
||||
label: 'Resend verification email',
|
||||
variant: 'solid',
|
||||
color: 'neutral',
|
||||
loading: resendEmailStatus === 'pending',
|
||||
onClick: () => sendEmailVerification(),
|
||||
},
|
||||
]"
|
||||
/>
|
||||
|
||||
<div class="pt-2">
|
||||
<UButton
|
||||
type="submit"
|
||||
label="Save"
|
||||
:loading="accountUpdateStatus === 'pending'"
|
||||
/>
|
||||
</div>
|
||||
</UForm>
|
||||
</template>
|
||||
57
nuxt/app/components/auth/AuthForgotPasswordForm.vue
Normal file
57
nuxt/app/components/auth/AuthForgotPasswordForm.vue
Normal file
@@ -0,0 +1,57 @@
|
||||
<script setup lang="ts">
|
||||
import type { IForgotPasswordResponse } from '~/types/account'
|
||||
|
||||
const form = useTemplateRef('form')
|
||||
|
||||
const state = reactive({
|
||||
email: '',
|
||||
})
|
||||
|
||||
const { refresh: onSubmit, status: forgotStatus } = useFetch<IForgotPasswordResponse>('forgot-password', {
|
||||
method: 'POST',
|
||||
body: state,
|
||||
immediate: false,
|
||||
watch: false,
|
||||
async onResponse({ response }) {
|
||||
if (response?.status === 422) {
|
||||
form.value.setErrors(response._data?.errors)
|
||||
}
|
||||
else if (response._data?.ok) {
|
||||
useToast().add({
|
||||
title: 'Success',
|
||||
description: response._data.message,
|
||||
color: 'primary',
|
||||
})
|
||||
}
|
||||
},
|
||||
})
|
||||
</script>
|
||||
|
||||
<template>
|
||||
<UForm
|
||||
ref="form"
|
||||
:state="state"
|
||||
class="space-y-8"
|
||||
@submit="onSubmit"
|
||||
>
|
||||
<UFormField
|
||||
label="Email"
|
||||
name="email"
|
||||
>
|
||||
<UInput
|
||||
v-model="state.email"
|
||||
class="w-full"
|
||||
/>
|
||||
</UFormField>
|
||||
|
||||
<UButton
|
||||
block
|
||||
size="md"
|
||||
type="submit"
|
||||
:loading="forgotStatus === 'pending'"
|
||||
icon="i-heroicons-envelope"
|
||||
>
|
||||
Reset Password
|
||||
</UButton>
|
||||
</UForm>
|
||||
</template>
|
||||
173
nuxt/app/components/auth/AuthLoginForm.vue
Normal file
173
nuxt/app/components/auth/AuthLoginForm.vue
Normal file
@@ -0,0 +1,173 @@
|
||||
<script setup lang="ts">
|
||||
import type { IAccountLoginResponse, IAccountProvider, IAccountProviderData } from '~/types/account'
|
||||
import type { ButtonProps } from '#ui/components/Button.vue'
|
||||
|
||||
const config = useRuntimeConfig()
|
||||
const router = useRouter()
|
||||
const auth = useAuthStore()
|
||||
const form = useTemplateRef('form')
|
||||
|
||||
const state = reactive({
|
||||
email: '',
|
||||
password: '',
|
||||
remember: false,
|
||||
})
|
||||
|
||||
const { refresh: onSubmit, status: loginStatus } = useFetch<IAccountLoginResponse>('login', {
|
||||
method: 'POST',
|
||||
body: state,
|
||||
immediate: false,
|
||||
watch: false,
|
||||
async onResponse({ response }) {
|
||||
if (response?.status === 422) {
|
||||
form?.value?.setErrors(response._data?.errors)
|
||||
}
|
||||
else if (response._data?.ok) {
|
||||
auth.token = response._data.token
|
||||
|
||||
await auth.fetchUser()
|
||||
await router.push('/')
|
||||
}
|
||||
},
|
||||
})
|
||||
|
||||
const providers = ref<{ [key: string]: IAccountProvider }>(
|
||||
Object.fromEntries(
|
||||
Object.entries(config.public.providers).map(([key, provider]) => [
|
||||
key,
|
||||
{
|
||||
...provider,
|
||||
color: provider.color as ButtonProps['color'],
|
||||
},
|
||||
]),
|
||||
),
|
||||
)
|
||||
|
||||
async function handleMessage(event: { data: IAccountProviderData }): Promise<void> {
|
||||
const provider = event.data.provider as string
|
||||
|
||||
if (Object.keys(providers.value).includes(provider) && event.data.token) {
|
||||
if (providers?.value[provider]?.loading) {
|
||||
providers.value[provider].loading = false
|
||||
}
|
||||
auth.token = event.data.token
|
||||
|
||||
await auth.fetchUser()
|
||||
await router.push('/')
|
||||
}
|
||||
else if (event.data.message) {
|
||||
useToast().add({
|
||||
icon: 'i-heroicons-exclamation-circle-solid',
|
||||
color: 'error',
|
||||
title: event.data.message,
|
||||
})
|
||||
}
|
||||
}
|
||||
|
||||
function loginVia(provider: string): void {
|
||||
providers.value[provider]!.loading = true
|
||||
|
||||
const width = 640
|
||||
const height = 660
|
||||
const left = window.screen.width / 2 - width / 2
|
||||
const top = window.screen.height / 2 - height / 2
|
||||
|
||||
const popup = window.open(
|
||||
`${config.public.apiBase}${config.public.apiPrefix}/login/${provider}/redirect`,
|
||||
'Sign In',
|
||||
`toolbar=no, location=no, directories=no, status=no, menubar=no, scollbars=no, resizable=no, copyhistory=no, width=${width},height=${height},top=${top},left=${left}`,
|
||||
)
|
||||
|
||||
const interval = setInterval(() => {
|
||||
if (!popup || popup.closed) {
|
||||
clearInterval(interval)
|
||||
providers.value[provider]!.loading = false
|
||||
}
|
||||
}, 500)
|
||||
}
|
||||
|
||||
onMounted(() => window.addEventListener('message', handleMessage))
|
||||
onBeforeUnmount(() => window.removeEventListener('message', handleMessage))
|
||||
</script>
|
||||
|
||||
<template>
|
||||
<UForm
|
||||
ref="form"
|
||||
:state="state"
|
||||
class="space-y-4"
|
||||
@submit="onSubmit"
|
||||
>
|
||||
<UFormField
|
||||
label="Email"
|
||||
name="email"
|
||||
>
|
||||
<UInput
|
||||
v-model="state.email"
|
||||
class="w-full"
|
||||
/>
|
||||
</UFormField>
|
||||
|
||||
<UFormField
|
||||
label="Password"
|
||||
name="password"
|
||||
>
|
||||
<UInput
|
||||
v-model="state.password"
|
||||
type="password"
|
||||
class="w-full"
|
||||
/>
|
||||
</UFormField>
|
||||
|
||||
<div class="flex items-center justify-between">
|
||||
<UCheckbox
|
||||
id="remember-me"
|
||||
v-model="state.remember"
|
||||
label="Remember me"
|
||||
name="remember-me"
|
||||
/>
|
||||
|
||||
<div class="text-sm leading-6">
|
||||
<NuxtLink
|
||||
to="/forgot-password"
|
||||
class="text-primary hover:text-primary-300 font-semibold"
|
||||
>
|
||||
Forgot
|
||||
password?
|
||||
</NuxtLink>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
<UButton
|
||||
block
|
||||
size="md"
|
||||
type="submit"
|
||||
:loading="loginStatus === 'pending'"
|
||||
icon="i-heroicons-arrow-right-on-rectangle"
|
||||
>
|
||||
Login
|
||||
</UButton>
|
||||
</UForm>
|
||||
<USeparator
|
||||
v-if="Object.keys(providers).length > 0"
|
||||
color="neutral"
|
||||
label="Login with"
|
||||
class="my-4"
|
||||
/>
|
||||
<div class="flex gap-4">
|
||||
<UButton
|
||||
v-for="(provider, key) in providers"
|
||||
:key="key"
|
||||
:loading="provider.loading"
|
||||
:icon="provider.icon"
|
||||
:color="provider.color"
|
||||
:label="provider.name"
|
||||
size="lg"
|
||||
class="w-full flex items-center justify-center"
|
||||
@click="loginVia(key as string)"
|
||||
/>
|
||||
</div>
|
||||
</template>
|
||||
|
||||
<style scoped>
|
||||
|
||||
</style>
|
||||
91
nuxt/app/components/auth/AuthResetPasswordForm.vue
Normal file
91
nuxt/app/components/auth/AuthResetPasswordForm.vue
Normal file
@@ -0,0 +1,91 @@
|
||||
<script setup lang="ts">
|
||||
import type { IAccountResetPasswordResponse } from '~/types/account'
|
||||
|
||||
const router = useRouter()
|
||||
const route = useRoute()
|
||||
const auth = useAuthStore()
|
||||
const form = useTemplateRef('form')
|
||||
|
||||
const state = reactive({
|
||||
email: route.query.email as string,
|
||||
token: route.params.token,
|
||||
password: '',
|
||||
password_confirmation: '',
|
||||
})
|
||||
|
||||
const { refresh: onSubmit, status: resetStatus } = useFetch<IAccountResetPasswordResponse>('reset-password', {
|
||||
method: 'POST',
|
||||
body: state,
|
||||
immediate: false,
|
||||
watch: false,
|
||||
async onResponse({ response }) {
|
||||
if (response?.status === 422) {
|
||||
form?.value?.setErrors(response._data?.errors)
|
||||
}
|
||||
else if (response._data?.ok) {
|
||||
useToast().add({
|
||||
title: 'Success',
|
||||
description: response._data.message,
|
||||
color: 'primary',
|
||||
})
|
||||
|
||||
if (auth.isLoggedIn) {
|
||||
await auth.fetchUser()
|
||||
await router.push('/')
|
||||
}
|
||||
else {
|
||||
await router.push('/login')
|
||||
}
|
||||
}
|
||||
},
|
||||
})
|
||||
</script>
|
||||
|
||||
<template>
|
||||
<UForm
|
||||
ref="form"
|
||||
:state="state"
|
||||
class="space-y-4"
|
||||
@submit="onSubmit"
|
||||
>
|
||||
<UFormField
|
||||
label="Email"
|
||||
name="email"
|
||||
>
|
||||
<UInput
|
||||
v-model="state.email"
|
||||
disabled
|
||||
/>
|
||||
</UFormField>
|
||||
|
||||
<UFormField
|
||||
label="Password"
|
||||
name="password"
|
||||
>
|
||||
<UInput
|
||||
v-model="state.password"
|
||||
type="password"
|
||||
/>
|
||||
</UFormField>
|
||||
|
||||
<UFormField
|
||||
label="Confirm Password"
|
||||
name="password_confirmation"
|
||||
>
|
||||
<UInput
|
||||
v-model="state.password_confirmation"
|
||||
type="password"
|
||||
/>
|
||||
</UFormField>
|
||||
|
||||
<UButton
|
||||
block
|
||||
size="md"
|
||||
type="submit"
|
||||
:loading="resetStatus === 'pending'"
|
||||
icon="i-heroicon-lock-closed"
|
||||
>
|
||||
Change Password
|
||||
</UButton>
|
||||
</UForm>
|
||||
</template>
|
||||
131
nuxt/app/components/input/UploadAvatar.vue
Normal file
131
nuxt/app/components/input/UploadAvatar.vue
Normal file
@@ -0,0 +1,131 @@
|
||||
<script lang="ts" setup>
|
||||
import type { IUploadResponse } from '~/types/account'
|
||||
|
||||
interface Props {
|
||||
modelValue: string
|
||||
entity: string
|
||||
accept: string
|
||||
maxSize: number
|
||||
}
|
||||
|
||||
const props = defineProps<Props>()
|
||||
|
||||
const emit = defineEmits<{
|
||||
'update:modelValue': [value: string]
|
||||
}>()
|
||||
|
||||
const { $storage } = useNuxtApp()
|
||||
|
||||
const value = computed({
|
||||
get(): string {
|
||||
return props.modelValue
|
||||
},
|
||||
set(value: string) {
|
||||
emit('update:modelValue', value)
|
||||
},
|
||||
})
|
||||
|
||||
const inputRef = ref<HTMLInputElement>()
|
||||
const loading = ref<boolean>(false)
|
||||
|
||||
const onSelect = async (e: Event) => {
|
||||
const target = e.target as HTMLInputElement
|
||||
const file = target.files?.[0]
|
||||
if (!file) return
|
||||
target.value = ''
|
||||
|
||||
if (file.size > props.maxSize * 1024 * 1024) {
|
||||
return useToast().add({
|
||||
title: 'File is too large.',
|
||||
color: 'error',
|
||||
icon: 'i-heroicons-exclamation-circle-solid',
|
||||
})
|
||||
}
|
||||
|
||||
loading.value = true
|
||||
|
||||
const formData = new FormData()
|
||||
formData.append('image', file)
|
||||
|
||||
await $fetch<IUploadResponse>('upload', {
|
||||
method: 'POST',
|
||||
body: formData,
|
||||
params: {
|
||||
entity: props.entity,
|
||||
width: 300,
|
||||
height: 300,
|
||||
},
|
||||
ignoreResponseError: true,
|
||||
onResponse({ response }) {
|
||||
if (response.status !== 200) {
|
||||
useToast().add({
|
||||
icon: 'i-heroicons-exclamation-circle-solid',
|
||||
color: 'error',
|
||||
title: response._data?.message ?? response.statusText ?? 'Something went wrong',
|
||||
})
|
||||
}
|
||||
else if (response._data?.ok) {
|
||||
value.value = response._data?.path
|
||||
}
|
||||
|
||||
loading.value = false
|
||||
},
|
||||
})
|
||||
}
|
||||
</script>
|
||||
|
||||
<template>
|
||||
<div class="flex gap-6">
|
||||
<div class="relative flex">
|
||||
<UAvatar
|
||||
:src="$storage(value)"
|
||||
size="3xl"
|
||||
img-class="object-cover"
|
||||
/>
|
||||
|
||||
<UTooltip
|
||||
text="Upload avatar"
|
||||
class="absolute top-0 end-0 -m-2"
|
||||
:popper="{ placement: 'right' }"
|
||||
>
|
||||
<UButton
|
||||
type="button"
|
||||
color="neutral"
|
||||
variant="link"
|
||||
icon="i-heroicons-cloud-arrow-up"
|
||||
size="xs"
|
||||
:ui="{ base: 'rounded-full' }"
|
||||
:loading="loading"
|
||||
@click="inputRef.click()"
|
||||
/>
|
||||
</UTooltip>
|
||||
<UTooltip
|
||||
text="Delete avatar"
|
||||
class="absolute bottom-0 end-0 -m-2"
|
||||
:popper="{ placement: 'right' }"
|
||||
>
|
||||
<UButton
|
||||
type="button"
|
||||
color="neutral"
|
||||
variant="link"
|
||||
icon="i-heroicons-x-mark-20-solid"
|
||||
size="xs"
|
||||
:ui="{ base: 'rounded-full' }"
|
||||
:disabled="loading"
|
||||
@click="value = ''"
|
||||
/>
|
||||
</UTooltip>
|
||||
<input
|
||||
ref="inputRef"
|
||||
type="file"
|
||||
class="hidden"
|
||||
:accept="accept"
|
||||
@change="onSelect"
|
||||
>
|
||||
</div>
|
||||
<div class="text-sm opacity-80">
|
||||
<div>Max upload size: {{ maxSize }}Mb</div>
|
||||
<div>Accepted formats: {{ accept }}</div>
|
||||
</div>
|
||||
</div>
|
||||
</template>
|
||||
25
nuxt/app/components/modal/Demo.vue
Normal file
25
nuxt/app/components/modal/Demo.vue
Normal file
@@ -0,0 +1,25 @@
|
||||
<script lang="ts" setup>
|
||||
const modal = useModal()
|
||||
</script>
|
||||
|
||||
<template>
|
||||
<UModal>
|
||||
<UCard>
|
||||
<template #header>
|
||||
<div class="text-2xl leading-tight font-black">
|
||||
Welcome to LaravelNuxt
|
||||
</div>
|
||||
</template>
|
||||
|
||||
<USkeleton class="w-full h-60" />
|
||||
|
||||
<template #footer>
|
||||
<UButton
|
||||
label="Close"
|
||||
color="gray"
|
||||
@click="modal.close"
|
||||
/>
|
||||
</template>
|
||||
</UCard>
|
||||
</UModal>
|
||||
</template>
|
||||
Reference in New Issue
Block a user