Initial commit
This commit is contained in:
87
frontend/src/components/Forms/LoginForm.vue
Normal file
87
frontend/src/components/Forms/LoginForm.vue
Normal file
@@ -0,0 +1,87 @@
|
||||
<script lang="ts" setup>
|
||||
import { ref } from 'vue';
|
||||
import { Auth } from '../../connectors/auth.connector';
|
||||
import router from '../../router';
|
||||
|
||||
const email = ref('');
|
||||
const password = ref('');
|
||||
|
||||
const loginFailure = ref<boolean>(false);
|
||||
|
||||
async function login() {
|
||||
const success = await Auth.Login(email.value, password.value);
|
||||
|
||||
if (success) {
|
||||
router.push('/search');
|
||||
return;
|
||||
}
|
||||
|
||||
loginFailure.value = true;
|
||||
}
|
||||
|
||||
</script>
|
||||
|
||||
<template>
|
||||
<v-form @submit.prevent="login">
|
||||
<v-card
|
||||
class="mx-auto pa-12 pb-8"
|
||||
elevation="8"
|
||||
max-width="448"
|
||||
rounded="lg"
|
||||
>
|
||||
<div class="text-subtitle-1 text-medium-emphasis">Account</div>
|
||||
|
||||
<v-text-field
|
||||
density="compact"
|
||||
placeholder="Email address"
|
||||
prepend-inner-icon="mdi-email-outline"
|
||||
variant="outlined"
|
||||
v-model="email"
|
||||
:error="loginFailure"
|
||||
@update:model-value="() => loginFailure = false"
|
||||
></v-text-field>
|
||||
|
||||
<div class="text-subtitle-1 text-medium-emphasis d-flex align-center justify-space-between">
|
||||
Password
|
||||
</div>
|
||||
|
||||
<v-text-field
|
||||
type="password"
|
||||
density="compact"
|
||||
placeholder="Enter your password"
|
||||
prepend-inner-icon="mdi-lock-outline"
|
||||
variant="outlined"
|
||||
v-model="password"
|
||||
:error="loginFailure"
|
||||
@update:model-value="() => loginFailure = false"
|
||||
></v-text-field>
|
||||
|
||||
<v-label
|
||||
v-if="loginFailure"
|
||||
class="text-red">
|
||||
Login failed, please try again.
|
||||
</v-label>
|
||||
|
||||
<v-btn
|
||||
block
|
||||
class="mb-8"
|
||||
color="blue"
|
||||
size="large"
|
||||
variant="tonal"
|
||||
type="submit"
|
||||
>
|
||||
Log In
|
||||
</v-btn>
|
||||
|
||||
<v-card-text class="text-center">
|
||||
<a
|
||||
class="text-blue text-decoration-none"
|
||||
href="/register"
|
||||
rel="noopener noreferrer"
|
||||
>
|
||||
Sign up now <v-icon icon="mdi-chevron-right"></v-icon>
|
||||
</a>
|
||||
</v-card-text>
|
||||
</v-card>
|
||||
</v-form>
|
||||
</template>
|
||||
90
frontend/src/components/Forms/RegisterForm.vue
Normal file
90
frontend/src/components/Forms/RegisterForm.vue
Normal file
@@ -0,0 +1,90 @@
|
||||
<script lang="ts" setup>
|
||||
import { ref } from 'vue';
|
||||
import { Auth } from '../../connectors/auth.connector';
|
||||
import router from '../../router';
|
||||
|
||||
const email = ref('');
|
||||
const password = ref('');
|
||||
const confirmPassword = ref('');
|
||||
|
||||
const errors = ref<string[]>([]);
|
||||
|
||||
async function register() {
|
||||
if (password.value !== confirmPassword.value) {
|
||||
return;
|
||||
}
|
||||
|
||||
const result = await Auth.Register(email.value, password.value);
|
||||
|
||||
if (result.success) {
|
||||
router.push('/login');
|
||||
return;
|
||||
}
|
||||
|
||||
errors.value = Object.values(result.errors ?? []).flat();
|
||||
}
|
||||
|
||||
</script>
|
||||
|
||||
<template>
|
||||
<v-form @submit.prevent="register">
|
||||
<v-card
|
||||
class="mx-auto pa-12 pb-8"
|
||||
elevation="8"
|
||||
max-width="448"
|
||||
rounded="lg"
|
||||
>
|
||||
<div class="text-subtitle-1 text-medium-emphasis">Account</div>
|
||||
|
||||
<v-text-field
|
||||
density="compact"
|
||||
placeholder="Email address"
|
||||
prepend-inner-icon="mdi-email-outline"
|
||||
variant="outlined"
|
||||
v-model="email"
|
||||
:error="errors.length > 0"
|
||||
></v-text-field>
|
||||
|
||||
<div class="text-subtitle-1 text-medium-emphasis d-flex align-center justify-space-between">
|
||||
Password
|
||||
</div>
|
||||
|
||||
<v-text-field
|
||||
type="password"
|
||||
density="compact"
|
||||
placeholder="Enter your password"
|
||||
prepend-inner-icon="mdi-lock-outline"
|
||||
variant="outlined"
|
||||
v-model="password"
|
||||
:error="errors.length > 0"
|
||||
></v-text-field>
|
||||
|
||||
<v-text-field
|
||||
type="password"
|
||||
density="compact"
|
||||
placeholder="Re-Enter your password"
|
||||
prepend-inner-icon="mdi-lock-outline"
|
||||
variant="outlined"
|
||||
v-model="confirmPassword"
|
||||
:error="errors.length > 0"
|
||||
></v-text-field>
|
||||
|
||||
<v-label
|
||||
v-if="errors"
|
||||
class="text-red">
|
||||
{{ errors.join('\n') }}
|
||||
</v-label>
|
||||
|
||||
<v-btn
|
||||
block
|
||||
class="mb-8"
|
||||
color="blue"
|
||||
size="large"
|
||||
variant="tonal"
|
||||
type="submit"
|
||||
>
|
||||
Sign Up
|
||||
</v-btn>
|
||||
</v-card>
|
||||
</v-form>
|
||||
</template>
|
||||
30
frontend/src/components/Inputs/SearchField.vue
Normal file
30
frontend/src/components/Inputs/SearchField.vue
Normal file
@@ -0,0 +1,30 @@
|
||||
<script setup lang="ts">
|
||||
import { ref, watch } from 'vue';
|
||||
import { Debounce } from '@/utils/debounce';
|
||||
|
||||
type Emits = {
|
||||
(e: 'onSearch', value: string): void,
|
||||
};
|
||||
|
||||
const emit = defineEmits<Emits>();
|
||||
|
||||
const searchContent = ref('');
|
||||
|
||||
const debounce = new Debounce();
|
||||
watch(searchContent, () => debounce.debounce(() => emit('onSearch', searchContent.value), 200));
|
||||
</script>
|
||||
|
||||
<template>
|
||||
<v-text-field
|
||||
variant="solo"
|
||||
label="Search beer"
|
||||
append-inner-icon="mdi-magnify"
|
||||
single-line
|
||||
hide-details
|
||||
v-model="searchContent"
|
||||
/>
|
||||
</template>
|
||||
|
||||
<style scoped>
|
||||
|
||||
</style>
|
||||
33
frontend/src/components/Search.vue
Normal file
33
frontend/src/components/Search.vue
Normal file
@@ -0,0 +1,33 @@
|
||||
<script lang="ts" setup>
|
||||
import SearchField from '@/components/Inputs/SearchField.vue';
|
||||
import { ref } from 'vue';
|
||||
import { searchBeer } from '@/connectors/punk.connector';
|
||||
import SearchTable from '@/components/Tables/SearchTable.vue';
|
||||
import { useQuery } from '@tanstack/vue-query';
|
||||
|
||||
const searchValue = ref<string>('');
|
||||
|
||||
const { isLoading, error, data } = useQuery({
|
||||
queryKey: ['search', searchValue],
|
||||
queryFn: async () => {
|
||||
if (!searchValue.value) {
|
||||
return [];
|
||||
}
|
||||
|
||||
return await searchBeer(searchValue.value)
|
||||
},
|
||||
});
|
||||
</script>
|
||||
|
||||
<template>
|
||||
<v-container class="fill-height">
|
||||
<v-responsive class="align-center text-center fill-height">
|
||||
<search-field @on-search="async (value: string) => searchValue = value" />
|
||||
<search-table
|
||||
:beers="data ?? []"
|
||||
:error="error?.message"
|
||||
:loading="isLoading"
|
||||
/>
|
||||
</v-responsive>
|
||||
</v-container>
|
||||
</template>
|
||||
41
frontend/src/components/Tables/FavouritesTable.vue
Normal file
41
frontend/src/components/Tables/FavouritesTable.vue
Normal file
@@ -0,0 +1,41 @@
|
||||
<script setup lang="ts">
|
||||
import { Beer } from '@/models/Beer';
|
||||
|
||||
type Props = {
|
||||
beers: Beer[],
|
||||
loading: boolean,
|
||||
error?: string,
|
||||
};
|
||||
|
||||
const { beers } = defineProps<Props>();
|
||||
</script>
|
||||
|
||||
<template>
|
||||
<h1>Favourites</h1>
|
||||
<p v-if="loading">Loading...</p>
|
||||
<p v-else-if="error">An error has occurred, please try refreshing the page.</p>
|
||||
<v-table v-else>
|
||||
<thead class="text-center">
|
||||
<tr>
|
||||
<th>
|
||||
ID
|
||||
</th>
|
||||
<th>
|
||||
NAME
|
||||
</th>
|
||||
</tr>
|
||||
</thead>
|
||||
<tbody>
|
||||
<tr
|
||||
v-for="beer in beers"
|
||||
:key="beer.id">
|
||||
<td>{{ beer.id }}</td>
|
||||
<td>{{ beer.name }}</td>
|
||||
</tr>
|
||||
</tbody>
|
||||
</v-table>
|
||||
</template>
|
||||
|
||||
<style scoped>
|
||||
|
||||
</style>
|
||||
57
frontend/src/components/Tables/SearchTable.vue
Normal file
57
frontend/src/components/Tables/SearchTable.vue
Normal file
@@ -0,0 +1,57 @@
|
||||
<script setup lang="ts">
|
||||
import { Beer } from '@/models/Beer';
|
||||
import { addFavouriteBeer } from '../../connectors/punk.connector';
|
||||
import { ref } from 'vue';
|
||||
|
||||
type Props = {
|
||||
beers: Beer[],
|
||||
loading: boolean,
|
||||
error?: string,
|
||||
};
|
||||
|
||||
const snackbar = ref(false);
|
||||
const snackbarText = ref('');
|
||||
|
||||
const { beers } = defineProps<Props>();
|
||||
</script>
|
||||
|
||||
<template>
|
||||
<v-snackbar
|
||||
v-model="snackbar"
|
||||
>
|
||||
{{ snackbarText }}
|
||||
</v-snackbar>
|
||||
<p v-if="loading">Loading...</p>
|
||||
<p v-else-if="error">An error has occurred. {{ error }}</p>
|
||||
<div
|
||||
v-else
|
||||
@click="async () => {
|
||||
const result = await addFavouriteBeer(beer.id);
|
||||
|
||||
snackbar = true;
|
||||
|
||||
snackbarText = result ? `Added ${beer.name} to favourites!` : 'An error occurred whilst trying to add a favourite.';
|
||||
}"
|
||||
class="mt-2 mb-4 entry rounded-xl"
|
||||
v-for="beer in beers"
|
||||
:key="beer.id">
|
||||
<p>
|
||||
{{ beer.name }} -
|
||||
{{ beer.id }}
|
||||
</p>
|
||||
{{ beer.tagline }}
|
||||
{{ beer.first_brewed }}
|
||||
{{ beer.description }}
|
||||
{{ beer.image_url }}
|
||||
{{ beer.abv }}
|
||||
{{ beer.iby }}
|
||||
{{ beer.food_pairing?.join(',') }}
|
||||
</div>
|
||||
</template>
|
||||
|
||||
<style scoped>
|
||||
.entry {
|
||||
background-color:azure;
|
||||
cursor: pointer;
|
||||
}
|
||||
</style>
|
||||
Reference in New Issue
Block a user