Initial commit

This commit is contained in:
Stedoss
2023-12-07 00:20:59 +00:00
commit 284a36412d
66 changed files with 7591 additions and 0 deletions

4
frontend/.browserslistrc Normal file
View File

@@ -0,0 +1,4 @@
> 1%
last 2 versions
not dead
not ie 11

5
frontend/.editorconfig Normal file
View 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
View 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
View 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
View 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
View 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

File diff suppressed because it is too large Load Diff

36
frontend/package.json Normal file
View 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

Binary file not shown.

After

Width:  |  Height:  |  Size: 15 KiB

6
frontend/src/App.vue Normal file
View File

@@ -0,0 +1,6 @@
<template>
<router-view />
</template>
<script lang="ts" setup>
</script>

View 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>

View 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>

View 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>

View 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>

View 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>

View 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>

View 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();
}
}

View 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();
}

View 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>

View 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>

View 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
View 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');

View 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[];
}

View File

@@ -0,0 +1,3 @@
export type User = {
email: string;
};

View 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);
}

View 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',
},
},
},
},
});

View 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;

View 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
// );

View File

@@ -0,0 +1,3 @@
export const Configuration = {
APIBaseUrl: 'http://localhost:5279',
} as const;

View 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);
});
});

View 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);
}
}

View 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>

View File

@@ -0,0 +1,8 @@
<script lang="ts" setup>
import LoginForm from '../components/Forms/LoginForm.vue';
</script>
<template>
<LoginForm />
</template>

View File

@@ -0,0 +1,7 @@
<script lang="ts" setup>
import RegisterForm from '../components/Forms/RegisterForm.vue';
</script>
<template>
<RegisterForm />
</template>

View 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
View 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
View 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"]
}

View 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
View 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,
},
})