Initial commit
This commit is contained in:
4
frontend/.browserslistrc
Normal file
4
frontend/.browserslistrc
Normal file
@@ -0,0 +1,4 @@
|
||||
> 1%
|
||||
last 2 versions
|
||||
not dead
|
||||
not ie 11
|
||||
5
frontend/.editorconfig
Normal file
5
frontend/.editorconfig
Normal file
@@ -0,0 +1,5 @@
|
||||
[*.{js,jsx,ts,tsx,vue}]
|
||||
indent_style = space
|
||||
indent_size = 2
|
||||
trim_trailing_whitespace = true
|
||||
insert_final_newline = true
|
||||
14
frontend/.eslintrc.js
Normal file
14
frontend/.eslintrc.js
Normal file
@@ -0,0 +1,14 @@
|
||||
module.exports = {
|
||||
root: true,
|
||||
env: {
|
||||
node: true,
|
||||
},
|
||||
extends: [
|
||||
'plugin:vue/vue3-essential',
|
||||
'eslint:recommended',
|
||||
'@vue/eslint-config-typescript',
|
||||
],
|
||||
rules: {
|
||||
'vue/multi-word-component-names': 'off',
|
||||
},
|
||||
}
|
||||
22
frontend/.gitignore
vendored
Normal file
22
frontend/.gitignore
vendored
Normal file
@@ -0,0 +1,22 @@
|
||||
.DS_Store
|
||||
node_modules
|
||||
/dist
|
||||
|
||||
# local env files
|
||||
.env.local
|
||||
.env.*.local
|
||||
|
||||
# Log files
|
||||
npm-debug.log*
|
||||
yarn-debug.log*
|
||||
yarn-error.log*
|
||||
pnpm-debug.log*
|
||||
|
||||
# Editor directories and files
|
||||
.idea
|
||||
.vscode
|
||||
*.suo
|
||||
*.ntvs*
|
||||
*.njsproj
|
||||
*.sln
|
||||
*.sw?
|
||||
48
frontend/README.md
Normal file
48
frontend/README.md
Normal file
@@ -0,0 +1,48 @@
|
||||
# YPM Beer (frontend)
|
||||
|
||||
A super-simple frontend for viewing and searching Beers from the [PunkAPI](https://punkapi.com/).
|
||||
|
||||
## Setup
|
||||
|
||||
### Requirements
|
||||
|
||||
To run in development mode, you will need:
|
||||
* [node](https://nodejs.org/en) (v20>=)
|
||||
|
||||
### Installing dependencies
|
||||
To install the node dependencies required, run:
|
||||
```sh
|
||||
npm i
|
||||
```
|
||||
|
||||
### Running locally
|
||||
To run in development mode, run:
|
||||
```sh
|
||||
npm run dev
|
||||
```
|
||||
|
||||
The application should be accessable at:
|
||||
```sh
|
||||
http://localhost:3000
|
||||
```
|
||||
|
||||
### Configuring
|
||||
The app should already be configured to connect to the backend once it is running, but the URL can be modified at `src/utils/configuration.ts`.
|
||||
|
||||
## Technology
|
||||
To create this project, the following was used:
|
||||
* Node 20
|
||||
* Vite & Vitest
|
||||
* Vue3 (with composition API & TypeScript & VueRouter)
|
||||
* Vuetify for the UI framework
|
||||
* Vue-Query (Tanstack Query) for communicating with the backend.
|
||||
|
||||
## To improve:
|
||||
### UI
|
||||
The UI isn't the best at all, it's somewhat responsive but data display should be a lot better.
|
||||
|
||||
## Testing
|
||||
If I had more time, I would have loved to use `Testing-Library` to add some tests for the frontend.
|
||||
|
||||
## Auth Handling
|
||||
Right now, it uses very simple cookie auth - which can be a little hard to manage.
|
||||
16
frontend/index.html
Normal file
16
frontend/index.html
Normal file
@@ -0,0 +1,16 @@
|
||||
<!DOCTYPE html>
|
||||
<html lang="en">
|
||||
|
||||
<head>
|
||||
<meta charset="UTF-8" />
|
||||
<link rel="icon" href="/favicon.ico" />
|
||||
<meta name="viewport" content="width=device-width, initial-scale=1.0" />
|
||||
<title>YPS Beer</title>
|
||||
</head>
|
||||
|
||||
<body>
|
||||
<div id="app"></div>
|
||||
<script type="module" src="/src/main.ts"></script>
|
||||
</body>
|
||||
|
||||
</html>
|
||||
4608
frontend/package-lock.json
generated
Normal file
4608
frontend/package-lock.json
generated
Normal file
File diff suppressed because it is too large
Load Diff
36
frontend/package.json
Normal file
36
frontend/package.json
Normal file
@@ -0,0 +1,36 @@
|
||||
{
|
||||
"name": "yps-beer",
|
||||
"version": "1.0.0",
|
||||
"private": true,
|
||||
"scripts": {
|
||||
"dev": "vite",
|
||||
"build": "vue-tsc --noEmit && vite build",
|
||||
"preview": "vite preview",
|
||||
"lint": "eslint . --fix --ignore-path .gitignore",
|
||||
"test": "vitest"
|
||||
},
|
||||
"dependencies": {
|
||||
"@mdi/font": "7.0.96",
|
||||
"@tanstack/vue-query": "^5.12.2",
|
||||
"core-js": "^3.29.0",
|
||||
"roboto-fontface": "*",
|
||||
"vue": "^3.2.0",
|
||||
"vue-router": "^4.0.0",
|
||||
"vuetify": "^3.0.0"
|
||||
},
|
||||
"devDependencies": {
|
||||
"@babel/types": "^7.21.4",
|
||||
"@testing-library/vue": "^8.0.1",
|
||||
"@types/node": "^18.15.0",
|
||||
"@vitejs/plugin-vue": "^4.0.0",
|
||||
"@vue/eslint-config-typescript": "^11.0.0",
|
||||
"eslint": "^8.22.0",
|
||||
"eslint-plugin-vue": "^9.3.0",
|
||||
"sass": "^1.60.0",
|
||||
"typescript": "^5.0.0",
|
||||
"unplugin-fonts": "^1.0.3",
|
||||
"vite": "^4.2.0",
|
||||
"vite-plugin-vuetify": "^1.0.0",
|
||||
"vue-tsc": "^1.2.0"
|
||||
}
|
||||
}
|
||||
BIN
frontend/public/favicon.ico
Normal file
BIN
frontend/public/favicon.ico
Normal file
Binary file not shown.
|
After Width: | Height: | Size: 15 KiB |
6
frontend/src/App.vue
Normal file
6
frontend/src/App.vue
Normal file
@@ -0,0 +1,6 @@
|
||||
<template>
|
||||
<router-view />
|
||||
</template>
|
||||
|
||||
<script lang="ts" setup>
|
||||
</script>
|
||||
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>
|
||||
69
frontend/src/connectors/auth.connector.ts
Normal file
69
frontend/src/connectors/auth.connector.ts
Normal file
@@ -0,0 +1,69 @@
|
||||
import { User } from "../models/User";
|
||||
import { Configuration } from '@/utils/configuration';
|
||||
|
||||
type RegisterResponse = {
|
||||
success: boolean,
|
||||
errors?: Record<string, string[]>;
|
||||
};
|
||||
|
||||
export namespace Auth {
|
||||
export async function Login(email: string, password: string): Promise<boolean> {
|
||||
const response = await fetch(`${Configuration.APIBaseUrl}/login?useCookies=true`, {
|
||||
method: 'POST',
|
||||
credentials: 'include',
|
||||
body: JSON.stringify({
|
||||
email,
|
||||
password
|
||||
}),
|
||||
headers: {
|
||||
'Content-Type': 'application/json',
|
||||
},
|
||||
});
|
||||
|
||||
return response.ok;
|
||||
}
|
||||
|
||||
export async function Register(email: string, password: string): Promise<RegisterResponse> {
|
||||
const response = await fetch(`${Configuration.APIBaseUrl}/register`, {
|
||||
method: 'POST',
|
||||
credentials: 'include',
|
||||
body: JSON.stringify({
|
||||
email,
|
||||
password
|
||||
}),
|
||||
headers: {
|
||||
'Content-Type': 'application/json',
|
||||
},
|
||||
});
|
||||
|
||||
if (response.status === 400) {
|
||||
return {
|
||||
success: false,
|
||||
errors: (await response.json()).errors,
|
||||
};
|
||||
}
|
||||
|
||||
return {
|
||||
success: response.ok,
|
||||
};
|
||||
}
|
||||
|
||||
export async function Logout() {
|
||||
await fetch(`${Configuration.APIBaseUrl}/logout`, {
|
||||
method: 'GET',
|
||||
credentials: 'include',
|
||||
});
|
||||
}
|
||||
|
||||
export async function Me(): Promise<User | null> {
|
||||
const response = await fetch(`${Configuration.APIBaseUrl}/manage/info`, {
|
||||
credentials: 'include',
|
||||
});
|
||||
|
||||
if (!response.ok) {
|
||||
return null;
|
||||
}
|
||||
|
||||
return await response.json();
|
||||
}
|
||||
}
|
||||
34
frontend/src/connectors/punk.connector.ts
Normal file
34
frontend/src/connectors/punk.connector.ts
Normal file
@@ -0,0 +1,34 @@
|
||||
import { Beer } from '@/models/Beer';
|
||||
import { Configuration } from '@/utils/configuration';
|
||||
|
||||
export async function searchBeer(search: string): Promise<Beer[]> {
|
||||
const response = await fetch(`${Configuration.APIBaseUrl}/Beer?search=${search}`, {
|
||||
credentials: 'include',
|
||||
|
||||
});
|
||||
|
||||
return await response.json();
|
||||
}
|
||||
|
||||
export async function addFavouriteBeer(beerId: number): Promise<boolean> {
|
||||
const response = await fetch(`${Configuration.APIBaseUrl}/Favourites`, {
|
||||
method: 'POST',
|
||||
body: JSON.stringify({
|
||||
beerId,
|
||||
}),
|
||||
credentials: 'include',
|
||||
headers: {
|
||||
'Content-Type': 'application/json',
|
||||
},
|
||||
});
|
||||
|
||||
return response.ok;
|
||||
}
|
||||
|
||||
export async function getFavouriteBeers(): Promise<Beer[]> {
|
||||
const response = await fetch(`${Configuration.APIBaseUrl}/Favourites`, {
|
||||
credentials: 'include',
|
||||
});
|
||||
|
||||
return await response.json();
|
||||
}
|
||||
33
frontend/src/layouts/default/AppBar.vue
Normal file
33
frontend/src/layouts/default/AppBar.vue
Normal file
@@ -0,0 +1,33 @@
|
||||
<script lang="ts" setup>
|
||||
import { Auth } from '../../connectors/auth.connector';
|
||||
import router from '../../router';
|
||||
import { useQuery } from '@tanstack/vue-query';
|
||||
|
||||
const logout = async () => {
|
||||
await Auth.Logout();
|
||||
await router.push('/login');
|
||||
};
|
||||
|
||||
const { isLoading, data: user } = useQuery({
|
||||
queryKey: ['user'],
|
||||
queryFn: () => Auth.Me(),
|
||||
});
|
||||
</script>
|
||||
|
||||
<template>
|
||||
<v-app-bar>
|
||||
<v-app-bar-title>
|
||||
<v-icon icon="mdi-glass-mug" />
|
||||
Beer Pair
|
||||
</v-app-bar-title>
|
||||
<router-link to="/search" class="pr-2">
|
||||
Search
|
||||
</router-link>
|
||||
<router-link to="/favourites" class="pr-2">
|
||||
Favourites
|
||||
</router-link>
|
||||
<p v-if="isLoading">...</p>
|
||||
<p v-else class="pr-2">{{ user?.email }}</p>
|
||||
<button @click="async () => await logout()">Logout</button>
|
||||
</v-app-bar>
|
||||
</template>
|
||||
12
frontend/src/layouts/default/Default.vue
Normal file
12
frontend/src/layouts/default/Default.vue
Normal file
@@ -0,0 +1,12 @@
|
||||
<template>
|
||||
<v-app>
|
||||
<default-bar />
|
||||
|
||||
<default-view />
|
||||
</v-app>
|
||||
</template>
|
||||
|
||||
<script lang="ts" setup>
|
||||
import DefaultBar from './AppBar.vue';
|
||||
import DefaultView from './View.vue';
|
||||
</script>
|
||||
8
frontend/src/layouts/default/View.vue
Normal file
8
frontend/src/layouts/default/View.vue
Normal file
@@ -0,0 +1,8 @@
|
||||
<template>
|
||||
<v-main>
|
||||
<router-view />
|
||||
</v-main>
|
||||
</template>
|
||||
|
||||
<script lang="ts" setup>
|
||||
</script>
|
||||
11
frontend/src/main.ts
Normal file
11
frontend/src/main.ts
Normal file
@@ -0,0 +1,11 @@
|
||||
import { registerPlugins } from '@/plugins';
|
||||
|
||||
import App from './App.vue';
|
||||
|
||||
import { createApp } from 'vue';
|
||||
|
||||
const app = createApp(App);
|
||||
|
||||
registerPlugins(app);
|
||||
|
||||
app.mount('#app');
|
||||
11
frontend/src/models/Beer.ts
Normal file
11
frontend/src/models/Beer.ts
Normal file
@@ -0,0 +1,11 @@
|
||||
export type Beer = {
|
||||
id: number;
|
||||
name: string;
|
||||
tagline: string;
|
||||
first_brewed: string;
|
||||
description: string;
|
||||
image_url: string;
|
||||
abv: number;
|
||||
iby: number;
|
||||
food_pairing: string[];
|
||||
}
|
||||
3
frontend/src/models/User.ts
Normal file
3
frontend/src/models/User.ts
Normal file
@@ -0,0 +1,3 @@
|
||||
export type User = {
|
||||
email: string;
|
||||
};
|
||||
20
frontend/src/plugins/index.ts
Normal file
20
frontend/src/plugins/index.ts
Normal file
@@ -0,0 +1,20 @@
|
||||
/**
|
||||
* plugins/index.ts
|
||||
*
|
||||
* Automatically included in `./src/main.ts`
|
||||
*/
|
||||
|
||||
// Plugins
|
||||
import vuetify from './vuetify'
|
||||
import router from '../router'
|
||||
|
||||
// Types
|
||||
import type { App } from 'vue';
|
||||
import { VueQueryPlugin } from '@tanstack/vue-query';
|
||||
|
||||
export function registerPlugins (app: App) {
|
||||
app
|
||||
.use(vuetify)
|
||||
.use(router)
|
||||
.use(VueQueryPlugin);
|
||||
}
|
||||
18
frontend/src/plugins/vuetify.ts
Normal file
18
frontend/src/plugins/vuetify.ts
Normal file
@@ -0,0 +1,18 @@
|
||||
import '@mdi/font/css/materialdesignicons.css';
|
||||
import 'vuetify/styles';
|
||||
|
||||
import { createVuetify } from 'vuetify';
|
||||
|
||||
// https://vuetifyjs.com/en/introduction/why-vuetify/#feature-guides
|
||||
export default createVuetify({
|
||||
theme: {
|
||||
themes: {
|
||||
light: {
|
||||
colors: {
|
||||
primary: '#1867C0',
|
||||
secondary: '#5CBBF6',
|
||||
},
|
||||
},
|
||||
},
|
||||
},
|
||||
});
|
||||
90
frontend/src/router/index.ts
Normal file
90
frontend/src/router/index.ts
Normal file
@@ -0,0 +1,90 @@
|
||||
import { createRouter, createWebHistory } from 'vue-router';
|
||||
import { Auth } from '../connectors/auth.connector';
|
||||
|
||||
const routes = [
|
||||
{
|
||||
path: '/',
|
||||
compoent: () => import('@/layouts/default/Default.vue'),
|
||||
children: [
|
||||
{
|
||||
path: '/',
|
||||
name: '',
|
||||
component: () => import('@/views/Login.vue'),
|
||||
}
|
||||
],
|
||||
},
|
||||
{
|
||||
path: '/login',
|
||||
compoent: () => import('@/layouts/default/Default.vue'),
|
||||
children: [
|
||||
{
|
||||
path: '/login',
|
||||
name: 'Login',
|
||||
component: () => import('@/views/Login.vue'),
|
||||
}
|
||||
],
|
||||
},
|
||||
{
|
||||
path: '/register',
|
||||
compoent: () => import('@/layouts/default/Default.vue'),
|
||||
children: [
|
||||
{
|
||||
path: '/register',
|
||||
name: 'Register',
|
||||
component: () => import('@/views/Register.vue'),
|
||||
}
|
||||
],
|
||||
},
|
||||
{
|
||||
path: '/search',
|
||||
component: () => import('@/layouts/default/Default.vue'),
|
||||
children: [
|
||||
{
|
||||
path: '/search',
|
||||
name: 'Search',
|
||||
// route level code-splitting
|
||||
// this generates a separate chunk (Home-[hash].js) for this route
|
||||
// which is lazy-loaded when the route is visited.
|
||||
component: () => import('@/views/Search.vue'),
|
||||
},
|
||||
],
|
||||
},
|
||||
{
|
||||
path: '/favourites',
|
||||
component: () => import('@/layouts/default/Default.vue'),
|
||||
children: [
|
||||
{
|
||||
path: '/favourites',
|
||||
name: 'Favourites',
|
||||
// route level code-splitting
|
||||
// this generates a separate chunk (Home-[hash].js) for this route
|
||||
// which is lazy-loaded when the route is visited.
|
||||
component: () => import('@/views/Favourites.vue'),
|
||||
},
|
||||
],
|
||||
},
|
||||
];
|
||||
|
||||
const router = createRouter({
|
||||
history: createWebHistory(process.env.BASE_URL),
|
||||
routes,
|
||||
});
|
||||
|
||||
router.beforeEach(async (to) => {
|
||||
const me = await Auth.Me();
|
||||
|
||||
if (!me && to.name !== 'Login' && to.name !== 'Register') {
|
||||
return { name: 'Login' };
|
||||
}
|
||||
|
||||
if (me && to.name === 'Login') {
|
||||
console.log('should return to search');
|
||||
return { name: 'Search' };
|
||||
}
|
||||
|
||||
if (me && to.name === 'Register') {
|
||||
return { name: 'Search' };
|
||||
}
|
||||
});
|
||||
|
||||
export default router;
|
||||
10
frontend/src/styles/settings.scss
Normal file
10
frontend/src/styles/settings.scss
Normal file
@@ -0,0 +1,10 @@
|
||||
/**
|
||||
* src/styles/settings.scss
|
||||
*
|
||||
* Configures SASS variables and Vuetify overwrites
|
||||
*/
|
||||
|
||||
// https://vuetifyjs.com/features/sass-variables/`
|
||||
// @use 'vuetify/settings' with (
|
||||
// $color-pack: false
|
||||
// );
|
||||
3
frontend/src/utils/configuration.ts
Normal file
3
frontend/src/utils/configuration.ts
Normal file
@@ -0,0 +1,3 @@
|
||||
export const Configuration = {
|
||||
APIBaseUrl: 'http://localhost:5279',
|
||||
} as const;
|
||||
21
frontend/src/utils/debounce.test.ts
Normal file
21
frontend/src/utils/debounce.test.ts
Normal file
@@ -0,0 +1,21 @@
|
||||
import { describe, expect, it, vi } from 'vitest';
|
||||
import { Debounce } from './debounce';
|
||||
|
||||
describe('debounce', () => {
|
||||
it('cancels existing callback if a new one is created', () => {
|
||||
vi.useFakeTimers();
|
||||
const mockFn = vi.fn();
|
||||
|
||||
const debounce = new Debounce();
|
||||
|
||||
debounce.debounce(() => mockFn(), 500);
|
||||
debounce.debounce(() => mockFn(), 500);
|
||||
debounce.debounce(() => mockFn(), 500);
|
||||
debounce.debounce(() => mockFn(), 500);
|
||||
debounce.debounce(() => mockFn(), 500);
|
||||
|
||||
vi.advanceTimersByTime(1000);
|
||||
|
||||
expect(mockFn).toBeCalledTimes(1);
|
||||
});
|
||||
});
|
||||
8
frontend/src/utils/debounce.ts
Normal file
8
frontend/src/utils/debounce.ts
Normal file
@@ -0,0 +1,8 @@
|
||||
export class Debounce {
|
||||
private timeoutRef?: NodeJS.Timeout;
|
||||
|
||||
public debounce(cb: () => void, timeout: number) {
|
||||
clearTimeout(this.timeoutRef);
|
||||
this.timeoutRef = setTimeout(cb, timeout);
|
||||
}
|
||||
}
|
||||
19
frontend/src/views/Favourites.vue
Normal file
19
frontend/src/views/Favourites.vue
Normal file
@@ -0,0 +1,19 @@
|
||||
<script lang="ts" setup>
|
||||
import FavouritesTable from '../components/Tables/FavouritesTable.vue';
|
||||
import { getFavouriteBeers } from '../connectors/punk.connector';
|
||||
import { useQuery } from '@tanstack/vue-query';
|
||||
|
||||
const { isFetching, data, error } = useQuery({
|
||||
queryKey: ['favourites'],
|
||||
queryFn: getFavouriteBeers,
|
||||
});
|
||||
|
||||
</script>
|
||||
|
||||
<template>
|
||||
<FavouritesTable
|
||||
:beers="data ?? []"
|
||||
:error="error?.message"
|
||||
:loading="isFetching"
|
||||
/>
|
||||
</template>
|
||||
8
frontend/src/views/Login.vue
Normal file
8
frontend/src/views/Login.vue
Normal file
@@ -0,0 +1,8 @@
|
||||
<script lang="ts" setup>
|
||||
import LoginForm from '../components/Forms/LoginForm.vue';
|
||||
|
||||
|
||||
</script>
|
||||
<template>
|
||||
<LoginForm />
|
||||
</template>
|
||||
7
frontend/src/views/Register.vue
Normal file
7
frontend/src/views/Register.vue
Normal file
@@ -0,0 +1,7 @@
|
||||
<script lang="ts" setup>
|
||||
import RegisterForm from '../components/Forms/RegisterForm.vue';
|
||||
</script>
|
||||
|
||||
<template>
|
||||
<RegisterForm />
|
||||
</template>
|
||||
7
frontend/src/views/Search.vue
Normal file
7
frontend/src/views/Search.vue
Normal file
@@ -0,0 +1,7 @@
|
||||
<template>
|
||||
<Search />
|
||||
</template>
|
||||
|
||||
<script lang="ts" setup>
|
||||
import Search from '@/components/Search.vue';
|
||||
</script>
|
||||
7
frontend/src/vite-env.d.ts
vendored
Normal file
7
frontend/src/vite-env.d.ts
vendored
Normal file
@@ -0,0 +1,7 @@
|
||||
/// <reference types="vite/client" />
|
||||
|
||||
declare module '*.vue' {
|
||||
import type { DefineComponent } from 'vue'
|
||||
const component: DefineComponent<{}, {}, any>
|
||||
export default component
|
||||
}
|
||||
25
frontend/tsconfig.json
Normal file
25
frontend/tsconfig.json
Normal file
@@ -0,0 +1,25 @@
|
||||
{
|
||||
"compilerOptions": {
|
||||
"baseUrl": ".",
|
||||
"target": "ESNext",
|
||||
"useDefineForClassFields": true,
|
||||
"module": "ESNext",
|
||||
"moduleResolution": "Node",
|
||||
"strict": true,
|
||||
"jsx": "preserve",
|
||||
"resolveJsonModule": true,
|
||||
"isolatedModules": true,
|
||||
"esModuleInterop": true,
|
||||
"lib": ["ESNext", "DOM"],
|
||||
"skipLibCheck": true,
|
||||
"noEmit": true,
|
||||
"paths": {
|
||||
"@/*": [
|
||||
"src/*"
|
||||
]
|
||||
}
|
||||
},
|
||||
"include": ["src/**/*.ts", "src/**/*.d.ts", "src/**/*.tsx", "src/**/*.vue"],
|
||||
"references": [{ "path": "./tsconfig.node.json" }],
|
||||
"exclude": ["node_modules"]
|
||||
}
|
||||
9
frontend/tsconfig.node.json
Normal file
9
frontend/tsconfig.node.json
Normal file
@@ -0,0 +1,9 @@
|
||||
{
|
||||
"compilerOptions": {
|
||||
"composite": true,
|
||||
"module": "ESNext",
|
||||
"moduleResolution": "Node",
|
||||
"allowSyntheticDefaultImports": true
|
||||
},
|
||||
"include": ["vite.config.ts"]
|
||||
}
|
||||
44
frontend/vite.config.ts
Normal file
44
frontend/vite.config.ts
Normal file
@@ -0,0 +1,44 @@
|
||||
// Plugins
|
||||
import vue from '@vitejs/plugin-vue'
|
||||
import vuetify, { transformAssetUrls } from 'vite-plugin-vuetify'
|
||||
import ViteFonts from 'unplugin-fonts/vite'
|
||||
|
||||
// Utilities
|
||||
import { defineConfig } from 'vite'
|
||||
import { fileURLToPath, URL } from 'node:url'
|
||||
|
||||
// https://vitejs.dev/config/
|
||||
export default defineConfig({
|
||||
plugins: [
|
||||
vue({
|
||||
template: { transformAssetUrls },
|
||||
}),
|
||||
// https://github.com/vuetifyjs/vuetify-loader/tree/master/packages/vite-plugin#readme
|
||||
vuetify({
|
||||
autoImport: true,
|
||||
styles: {
|
||||
configFile: 'src/styles/settings.scss',
|
||||
},
|
||||
}),
|
||||
ViteFonts({
|
||||
google: {
|
||||
families: [
|
||||
{
|
||||
name: 'Roboto',
|
||||
styles: 'wght@100;300;400;500;700;900',
|
||||
},
|
||||
],
|
||||
},
|
||||
}),
|
||||
],
|
||||
define: { 'process.env': {} },
|
||||
resolve: {
|
||||
alias: {
|
||||
'@': fileURLToPath(new URL('./src', import.meta.url)),
|
||||
},
|
||||
extensions: ['.js', '.json', '.jsx', '.mjs', '.ts', '.tsx', '.vue'],
|
||||
},
|
||||
server: {
|
||||
port: 3000,
|
||||
},
|
||||
})
|
||||
Reference in New Issue
Block a user