main
Flycro 2023-11-07 19:02:50 +01:00
commit aac376bb56
30 changed files with 852 additions and 0 deletions

24
.gitignore vendored Normal file
View File

@ -0,0 +1,24 @@
# Nuxt dev/build outputs
.output
.data
.nuxt
.nitro
.cache
dist
# Node dependencies
node_modules
# Logs
logs
*.log
# Misc
.DS_Store
.fleet
.idea
# Local env files
.env
.env.*
!.env.example

75
README.md Normal file
View File

@ -0,0 +1,75 @@
# Nuxt 3 Minimal Starter
Look at the [Nuxt 3 documentation](https://nuxt.com/docs/getting-started/introduction) to learn more.
## Setup
Make sure to install the dependencies:
```bash
# npm
npm install
# pnpm
pnpm install
# yarn
yarn install
# bun
bun install
```
## Development Server
Start the development server on `http://localhost:3000`:
```bash
# npm
npm run dev
# pnpm
pnpm run dev
# yarn
yarn dev
# bun
bun run dev
```
## Production
Build the application for production:
```bash
# npm
npm run build
# pnpm
pnpm run build
# yarn
yarn build
# bun
bun run build
```
Locally preview production build:
```bash
# npm
npm run preview
# pnpm
pnpm run preview
# yarn
yarn preview
# bun
bun run preview
```
Check out the [deployment documentation](https://nuxt.com/docs/getting-started/deployment) for more information.

6
app.config.ts Normal file
View File

@ -0,0 +1,6 @@
export default defineAppConfig({
ui: {
primary: 'sky',
gray: 'cool',
},
})

7
app.vue Normal file
View File

@ -0,0 +1,7 @@
<template>
<NuxtLayout>
<NuxtPage />
</NuxtLayout>
<UNotifications />
</template>

BIN
bun.lockb Executable file

Binary file not shown.

38
components/Header.vue Normal file
View File

@ -0,0 +1,38 @@
<script setup lang="ts">
import type { NavItem } from '@nuxt/content/dist/runtime/types'
const navigation = inject<Ref<NavItem[]>>('navigation')
const links = [{
label: 'Documentation',
icon: 'i-heroicons-book-open',
to: '/getting-started',
}, {
label: 'Pro',
icon: 'i-heroicons-square-3-stack-3d',
to: '/pro',
}, {
label: 'Releases',
icon: 'i-heroicons-rocket-launch',
to: 'https://github.com/nuxt/ui/releases',
target: '_blank',
}]
</script>
<template>
<UHeader :links="links">
<template #logo>
<Logo class="h-6 w-auto" />
</template>
<template #right>
<UColorModeButton />
<UButton to="https://github.com/nuxt/ui" target="_blank" icon="i-simple-icons-github" color="gray" variant="ghost" />
</template>
<template #panel>
<UNavigationTree :links="mapContentNavigation(navigation)" />
</template>
</UHeader>
</template>

5
components/Logo.vue Normal file
View File

@ -0,0 +1,5 @@
<template>
<div>
<span class="text-primary">Nuxt</span> Breeze
</div>
</template>

31
components/Navigation.vue Normal file
View File

@ -0,0 +1,31 @@
<script setup lang="ts">
const links = [
{
label: 'Company',
icon: 'i-heroicons-building-office-2',
children: [
{
label: 'Overview',
to: '/partner/overview',
icon: 'i-heroicons-eye',
},
{
label: 'Add Company',
to: '/pro/components/docs/docs-search',
icon: 'i-heroicons-plus-circle',
},
],
},
{
label: 'People',
to: '/pro/components/docs/docs-search-button',
icon: 'i-heroicons-user-group',
},
]
</script>
<template>
<UContainer>
<UNavigationTree :links="links" />
</UContainer>
</template>

112
composables/useAuth.ts Normal file
View File

@ -0,0 +1,112 @@
export interface User {
name: string
email?: string
}
export interface LoginCredentials {
email: string
password: string
remember?: boolean
}
export interface RegisterCredentials {
name: string
email: string
password: string
password_confirmation: string
}
export interface ResetPasswordCredentials {
email: string
password: string
password_confirmation: string
token: string
}
// Value is initialized in: ~/plugins/auth.ts
export function useUser<T = User>() {
return useState<T | undefined | null>('user', () => undefined)
}
export function useAuth<T = User>() {
const router = useRouter()
const user = useUser<T>()
const isLoggedIn = computed(() => !!user.value)
async function refresh() {
try {
user.value = await fetchCurrentUser()
}
catch {
user.value = null
}
}
async function login(credentials: LoginCredentials) {
if (isLoggedIn.value) { return }
await $larafetch('/login', { method: 'post', body: credentials })
await refresh()
}
async function register(credentials: RegisterCredentials) {
await $larafetch('/register', { method: 'post', body: credentials })
await refresh()
}
async function resendEmailVerification() {
return await $larafetch<{ status: string }>(
'/email/verification-notification',
{
method: 'post',
},
)
}
async function logout() {
if (!isLoggedIn.value) { return }
await $larafetch('/logout', { method: 'post' })
user.value = null
await router.push('/login')
}
async function forgotPassword(email: string) {
return await $larafetch<{ status: string }>('/forgot-password', {
method: 'post',
body: { email },
})
}
async function resetPassword(credentials: ResetPasswordCredentials) {
return await $larafetch<{ status: string }>('/reset-password', {
method: 'post',
body: credentials,
})
}
return {
user,
isLoggedIn,
login,
register,
resendEmailVerification,
logout,
forgotPassword,
resetPassword,
refresh,
}
}
export async function fetchCurrentUser<T = User>() {
try {
return await $larafetch<T>('/api/user')
}
catch (error: any) {
if ([401, 419].includes(error?.response?.status)) { return null }
if (error?.response?.status === undefined) { return null }
throw error
}
}

View File

@ -0,0 +1,25 @@
import type { UseFetchOptions } from 'nuxt/app'
export function useLarafetch<T>(
url: string | (() => string),
options: UseFetchOptions<T> = {},
) {
return useFetch(url, {
$fetch: $larafetch,
async onResponseError({ response }) {
const status = response.status
if ([500].includes(status)) {
console.error('[Laravel Error]', response.statusText, response._data)
}
if ([401, 419].includes(status)) {
navigateTo('/login')
}
if ([409].includes(status)) {
navigateTo('/verify-email')
}
},
...options,
})
}

61
composables/useSubmit.ts Normal file
View File

@ -0,0 +1,61 @@
import type { FormError } from '@nuxt/ui/dist/runtime/types'
export interface UseSubmitOptions {
onSuccess?: (result: any) => any
onError?: (error: Error) => any
}
export function useSubmit<T>(
fetchable: () => Promise<T>,
options: UseSubmitOptions = {},
) {
const validationErrors = ref<FormError[]>([])
const error = ref<Error | null>(null)
const inProgress = ref(false)
const succeeded = ref<boolean | null>(null)
async function submit() {
validationErrors.value = []
error.value = null
inProgress.value = true
succeeded.value = null
try {
const data = await fetchable()
succeeded.value = true
options?.onSuccess?.(data)
return data
}
catch (e: any) {
error.value = e
succeeded.value = false
options?.onError?.(e)
if (e.data?.errors) {
const errorsArray: FormError[] = []
for (const path in e.data.errors) {
const messages = e.data.errors[path]
messages.forEach((message: string) => {
errorsArray.push({ path, message })
})
}
validationErrors.value = errorsArray
}
else {
validationErrors.value = []
}
if (e.response?.status !== 422) { throw e }
}
finally {
inProgress.value = false
}
}
return {
submit,
inProgress,
succeeded,
validationErrors,
error,
}
}

17
eslint.config.js Normal file
View File

@ -0,0 +1,17 @@
import antfu from '@antfu/eslint-config'
import { FlatCompat } from '@eslint/eslintrc'
const compat = new FlatCompat()
export default antfu({
rules: {
'curly': ['error', 'all'],
'node/prefer-global/process': ['error', 'always'],
},
}, ...compat.config({
extends: ['plugin:tailwindcss/recommended'],
rules: {
'tailwindcss/no-custom-classname': 'off',
'tailwindcss/migration-from-tailwind-2': 'off',
},
}))

17
layouts/app.vue Normal file
View File

@ -0,0 +1,17 @@
<template>
<div>
<Header />
<UPage>
<template #left>
<UAside class="lg:static">
<Navigation />
</UAside>
</template>
<UPageBody>
<UContainer>
<slot />
</UContainer>
</UPageBody>
</UPage>
</div>
</template>

5
layouts/default.vue Normal file
View File

@ -0,0 +1,5 @@
<template>
<div>
<slot />
</div>
</template>

4
middleware/auth.ts Normal file
View File

@ -0,0 +1,4 @@
export default defineNuxtRouteMiddleware(async () => {
const user = useUser();
if (!user.value) return navigateTo("/login", { replace: true });
});

4
middleware/guest.ts Normal file
View File

@ -0,0 +1,4 @@
export default defineNuxtRouteMiddleware(async () => {
const user = useUser();
if (user.value) return navigateTo("/", { replace: true });
});

9
middleware/unverified.ts Normal file
View File

@ -0,0 +1,9 @@
export default defineNuxtRouteMiddleware(() => {
const user = useUser();
if (!user.value) return navigateTo("/login");
// @ts-ignore
if (user.value.email_verified_at || user.value.is_verified)
return navigateTo("/");
});

9
middleware/verified.ts Normal file
View File

@ -0,0 +1,9 @@
export default defineNuxtRouteMiddleware(() => {
const user = useUser();
if (!user.value) return navigateTo("/login");
// @ts-ignore
if (!(user.value.email_verified_at || user.value.is_verified))
return navigateTo("/verify-email");
});

15
nuxt.config.ts Normal file
View File

@ -0,0 +1,15 @@
// https://nuxt.com/docs/api/configuration/nuxt-config
export default defineNuxtConfig({
extends: ['@nuxt/ui-pro'],
modules: ['@nuxt/ui'],
runtimeConfig: {
public: {
backendUrl: 'http://localhost',
frontendUrl: 'http://localhost:3000',
},
},
imports: {
dirs: ['./utils'],
},
devtools: { enabled: true },
})

28
package.json Normal file
View File

@ -0,0 +1,28 @@
{
"name": "nuxt-app",
"type": "module",
"private": true,
"scripts": {
"build": "nuxt build",
"dev": "nuxt dev",
"lint": "eslint .",
"lint:fix": "eslint . --fix",
"generate": "nuxt generate",
"preview": "nuxt preview",
"postinstall": "nuxt prepare"
},
"devDependencies": {
"@antfu/eslint-config": "^1.1.0",
"@iconify-json/heroicons": "^1.1.13",
"@iconify-json/logos": "^1.1.37",
"@iconify-json/simple-icons": "^1.1.76",
"@nuxt/devtools": "latest",
"@nuxt/ui-pro": "^0.4.2",
"eslint": "^8.53.0",
"eslint-plugin-tailwindcss": "^3.13.0",
"nuxt": "^3.8.1",
"typescript": "^5.2.2",
"vue": "^3.3.8",
"vue-router": "^4.2.5"
}
}

View File

@ -0,0 +1,72 @@
<script setup lang="ts">
definePageMeta({ middleware: ['guest'] })
const router = useRouter()
const { forgotPassword } = useAuth()
const form = ref()
const toast = useToast()
const state = ref({
email: '' as string,
})
const status = ref('')
const {
submit,
inProgress,
validationErrors,
} = useSubmit(
() => {
status.value = ''
return forgotPassword(state.value.email)
},
{
onSuccess: () => router.push('/login'),
onError: error => toast.add({ title: 'Error', description: error.message, color: 'red' }),
},
)
watch(validationErrors, async (errors) => {
if (errors.length > 0) {
await form.value?.setErrors(errors)
}
})
</script>
<template>
<UMain>
<UPage>
<div class="mx-auto flex min-h-screen w-full items-center justify-center">
<UCard class="w-96">
<template #header>
<div class="space-y-4 text-center ">
<h1 class="text-2xl font-bold">
Forgot Password
</h1>
<p class="text-sm">
Remember your password? <NuxtLink to="/login" class="text-primary hover:text-primary-300 font-semibold">
Login here
</NuxtLink>
</p>
</div>
</template>
<UForm ref="form" :state="state" class="space-y-8" @submit="submit">
<UFormGroup label="Email" name="email">
<UInput v-model="state.email" />
</UFormGroup>
<UButton block size="md" type="submit" :loading="inProgress" icon="i-heroicons-envelope">
Reset Password
</UButton>
</UForm>
<!-- <UDivider label="OR" class=" my-4"/> -->
</UCard>
</div>
</UPage>
</UMain>
</template>

11
pages/index.vue Normal file
View File

@ -0,0 +1,11 @@
<script setup lang="ts">
definePageMeta({ middleware: ['auth'], layout: 'app' })
</script>
<template>
<div>
This is the Page Content
</div>
</template>
<style scoped></style>

89
pages/login/index.vue Normal file
View File

@ -0,0 +1,89 @@
<script setup lang="ts">
definePageMeta({ middleware: ['guest'] })
const router = useRouter()
const route = useRoute()
const { login } = useAuth()
const toast = useToast()
const form = ref()
const state = ref<LoginCredentials>({
email: '',
password: '',
remember: false,
})
const status = ref(
(route.query.reset ?? '').length > 0 ? atob(route.query.reset as string) : '',
)
const {
submit,
inProgress,
validationErrors,
} = useSubmit(
() => {
status.value = ''
return login(state.value)
},
{
onSuccess: () => router.push('/'),
onError: (error) => {
toast.add({ title: 'Error', description: error.message, color: 'red' })
},
},
)
watch(validationErrors, async (errors) => {
if (errors.length > 0) {
await form.value?.setErrors(errors)
}
})
</script>
<template>
<UMain>
<UPage>
<div class="mx-auto flex min-h-screen w-full items-center justify-center">
<UCard class="w-96">
<template #header>
<h1 class="text-center text-2xl font-bold">
Login
</h1>
</template>
<UForm ref="form" :state="state" class="space-y-4" @submit="submit">
<UFormGroup label="Email" name="email">
<UInput v-model="state.email" />
</UFormGroup>
<UFormGroup label="Password" name="password">
<UInput v-model="state.password" type="password" />
</UFormGroup>
<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="inProgress" icon="i-heroicons-arrow-right-on-rectangle">
Login
</UButton>
</UForm>
<!-- <UDivider label="OR" class=" my-4"/> -->
</UCard>
</div>
</UPage>
</UMain>
</template>
<style scoped></style>

View File

@ -0,0 +1,87 @@
<script setup lang="ts">
definePageMeta({ middleware: ['guest'] })
const router = useRouter()
const route = useRoute()
const { resetPassword } = useAuth()
if (!route.query.email) {
router.push('/')
}
const toast = useToast()
const state = ref<ResetPasswordCredentials>({
email: route.query.email as string,
password: '',
password_confirmation: '',
token: route.params.token as string,
})
const status = ref(
(route.query.reset ?? '').length > 0 ? atob(route.query.reset as string) : '',
)
const form = ref()
const {
submit,
inProgress,
validationErrors,
} = useSubmit(
() => {
status.value = ''
return resetPassword(state.value)
},
{
onSuccess: () => router.push('/'),
onError: (error) => {
toast.add({ title: 'Error', description: error.message, color: 'red' })
},
},
)
watch(validationErrors, async (errors) => {
if (errors.length > 0) {
await form.value?.setErrors(errors)
}
})
</script>
<template>
<UMain>
<UPage>
<div class="mx-auto flex min-h-screen w-full items-center justify-center">
<UCard class="w-96">
<template #header>
<h1 class="text-center text-2xl font-bold">
Reset Password
</h1>
</template>
<UForm ref="form" :state="state" class="space-y-4" @submit="submit">
<UFormGroup label="Email" name="email">
<UInput v-model="state.email" disabled />
</UFormGroup>
<UFormGroup label="Password" name="password">
<UInput v-model="state.password" type="password" />
</UFormGroup>
<UFormGroup label="Confirm Password" name="password_confirmation">
<UInput v-model="state.password_confirmation" type="password" />
</UFormGroup>
<UButton block size="md" type="submit" :loading="inProgress" icon="i-heroicon-lock-closed">
Change Password
</UButton>
</UForm>
<!-- <UDivider label="OR" class=" my-4"/> -->
</UCard>
</div>
</UPage>
</UMain>
</template>
<style scoped></style>

10
plugins/auth.ts Normal file
View File

@ -0,0 +1,10 @@
import { fetchCurrentUser, useUser } from '~/composables/useAuth'
export default defineNuxtPlugin(async () => {
const user = useUser()
// Skip if already initialized on server
if (user.value !== undefined) { return }
user.value = await fetchCurrentUser()
})

17
plugins/error-handler.ts Normal file
View File

@ -0,0 +1,17 @@
import { FetchError } from 'ofetch'
export default defineNuxtPlugin(async (nuxtApp) => {
nuxtApp.hook('vue:error', (error) => {
if (!(error instanceof FetchError)) { throw error }
const status = error.response?.status ?? -1
if ([401, 419].includes(status)) {
navigateTo('/login')
}
if ([409].includes(status)) {
navigateTo('/verify-email')
}
})
})

BIN
public/favicon.ico Normal file

Binary file not shown.

After

Width:  |  Height:  |  Size: 4.2 KiB

3
server/tsconfig.json Normal file
View File

@ -0,0 +1,3 @@
{
"extends": "../.nuxt/tsconfig.server.json"
}

4
tsconfig.json Normal file
View File

@ -0,0 +1,4 @@
{
// https://nuxt.com/docs/guide/concepts/typescript
"extends": "./.nuxt/tsconfig.json"
}

67
utils/$larafetch.ts Normal file
View File

@ -0,0 +1,67 @@
import { $fetch } from 'ofetch'
import { parseCookies } from 'h3'
const CSRF_COOKIE = 'XSRF-TOKEN'
const CSRF_HEADER = 'X-XSRF-TOKEN'
export const $larafetch = $fetch.create({
credentials: 'include',
async onRequest({ options }) {
const { backendUrl, frontendUrl } = useRuntimeConfig().public
const event = process.nitro ? useEvent() : null
let token = event
? parseCookies(event)[CSRF_COOKIE]
: useCookie(CSRF_COOKIE).value
// on client initiate a csrf request and get it from the cookie set by laravel
if (
process.client
&& ['post', 'delete', 'put', 'patch'].includes(
options?.method?.toLowerCase() ?? '',
)
) {
token = await initCsrf()
}
let headers: any = {
accept: 'application/json',
...options?.headers,
...(token && { [CSRF_HEADER]: token }),
}
if (process.server) {
const cookieString = event
? event.headers.get('cookie')
: useRequestHeaders(['cookie']).cookie
headers = {
...headers,
...(cookieString && { cookie: cookieString }),
referer: frontendUrl,
}
}
options.headers = headers
options.baseURL = backendUrl
},
async onResponseError({ response }) {
const status = response.status
if ([500].includes(status)) {
console.error('[Laravel Error]', response.statusText, response._data)
}
},
})
async function initCsrf() {
const { backendUrl } = useRuntimeConfig().public
const existingToken = useCookie(CSRF_COOKIE).value
if (existingToken) { return existingToken }
await $fetch('/sanctum/csrf-cookie', {
baseURL: backendUrl,
credentials: 'include',
})
return useCookie(CSRF_COOKIE).value
}