Browse Source

Update Web UI to use the request API package. Implement all API requests as Vuex actions. Fix components

dev
David Ludwig 4 years ago
parent
commit
095a745f77
14 changed files with 246 additions and 303 deletions
  1. +1
    -1
      services/webui/src/app/components/CheckBox.vue
  2. +2
    -2
      services/webui/src/app/components/MovieList.vue
  3. +1
    -1
      services/webui/src/app/components/MoviePoster.vue
  4. +1
    -1
      services/webui/src/app/components/ProgressRing.vue
  5. +0
    -1
      services/webui/src/app/components/SideNav.vue
  6. +8
    -9
      services/webui/src/app/components/modals/MovieModal.vue
  7. +0
    -36
      services/webui/src/app/routes/index.ts
  8. +193
    -61
      services/webui/src/app/store/actions.ts
  9. +15
    -9
      services/webui/src/app/views/Dashboard.vue
  10. +2
    -2
      services/webui/src/app/views/Login.vue
  11. +16
    -24
      services/webui/src/app/views/Register.vue
  12. +7
    -6
      services/webui/src/app/views/Search.vue
  13. +0
    -60
      services/webui/src/common/api_schema.ts
  14. +0
    -90
      services/webui/src/common/validation.ts

+ 1
- 1
services/webui/src/app/components/CheckBox.vue View File

@ -38,7 +38,7 @@ export default defineComponent({
}); });
</script> </script>
<style>
<style lang="postcss">
.checkbox-label input[type="checkbox"]:focus-visible + span { .checkbox-label input[type="checkbox"]:focus-visible + span {
@apply ring ring-offset-2 ring-indigo-500; @apply ring ring-offset-2 ring-indigo-500;


+ 2
- 2
services/webui/src/app/components/MovieList.vue View File

@ -28,8 +28,8 @@
</template> </template>
<script lang="ts"> <script lang="ts">
import { IMovie } from "@autoplex-api/request";
import { defineComponent } from "vue"; import { defineComponent } from "vue";
import { IApiMovie } from "../../common/api_schema";
import MoviePoster from "./MoviePoster.vue"; import MoviePoster from "./MoviePoster.vue";
export default defineComponent({ export default defineComponent({
@ -44,7 +44,7 @@ export default defineComponent({
}, },
props: { props: {
movies: { movies: {
default: <IApiMovie[]>[]
default: <IMovie[]>[]
} }
} }
}); });


+ 1
- 1
services/webui/src/app/components/MoviePoster.vue View File

@ -39,7 +39,7 @@ export default defineComponent({
}); });
</script> </script>
<style lang="css">
<style lang="postcss">
.poster { .poster {
@apply relative overflow-hidden; @apply relative overflow-hidden;
} }


+ 1
- 1
services/webui/src/app/components/ProgressRing.vue View File

@ -71,7 +71,7 @@ export default defineComponent({
</script> </script>
<style lang="css">
<style lang="postcss">
.radial-progress text { .radial-progress text {
fill: white; fill: white;
} }


+ 0
- 1
services/webui/src/app/components/SideNav.vue View File

@ -85,7 +85,6 @@ export default defineComponent({
.nav-toggle-button { .nav-toggle-button {
@apply w-14 h-14 flex items-center justify-center text-2xl focus:ring-0 focus:outline-none ring-indigo-500; @apply w-14 h-14 flex items-center justify-center text-2xl focus:ring-0 focus:outline-none ring-indigo-500;
transition: margin 0.15s cubic-bezier(0.4, 0.0, 1, 1); transition: margin 0.15s cubic-bezier(0.4, 0.0, 1, 1);
} }
.nav-toggle-button.active { .nav-toggle-button.active {
@apply ml-72; @apply ml-72;


+ 8
- 9
services/webui/src/app/components/modals/MovieModal.vue View File

@ -56,9 +56,9 @@
</template> </template>
<script lang="ts"> <script lang="ts">
import type { IMovieDetails } from "@autoplex-api/request";
import { Status } from "@autoplex/restful";
import { defineComponent } from "vue"; import { defineComponent } from "vue";
import { IApiDataResponse, IApiMovieDetails } from "@common/api_schema";
import { authFetch } from "../../routes";
import { getAverageRgb } from "../../util"; import { getAverageRgb } from "../../util";
import { useStore, Mutation, Action } from "../../store"; import { useStore, Mutation, Action } from "../../store";
import MoviePoster from "../MoviePoster.vue"; import MoviePoster from "../MoviePoster.vue";
@ -109,7 +109,7 @@ export default defineComponent({
data() { data() {
return { return {
rgb: {r: 0, g: 0, b: 0}, rgb: {r: 0, g: 0, b: 0},
movie: <IApiMovieDetails|undefined>undefined,
movie: <IMovieDetails|undefined>undefined,
isPosterLoaded: false, isPosterLoaded: false,
isRequesting: false isRequesting: false
} }
@ -159,22 +159,21 @@ export default defineComponent({
if (this.tmdbId === undefined) { if (this.tmdbId === undefined) {
return; return;
} }
let response = await (authFetch(`/api/movie/details/${this.tmdbId}`));
if (response.status != 200) {
let [status, response] = await this.$store.dispatch(Action.MovieDetails, this.tmdbId);
if (status != Status.Ok) {
this.close(); this.close();
return; return;
} }
let movie = <IApiDataResponse<IApiMovieDetails>> await response.json();
this.movie = movie.data;
this.movie = response.data;
}, },
async request() { async request() {
if (this.isRequesting || this.movie == null || this.movie.tmdbId == null) { if (this.isRequesting || this.movie == null || this.movie.tmdbId == null) {
return; return;
} }
this.isRequesting = true; this.isRequesting = true;
let response = await this.$store.dispatch(Action.RequestMovie, this.movie.tmdbId);
let [status, response] = await this.$store.dispatch(Action.RequestMovie, this.movie.tmdbId);
this.isRequesting = false; this.isRequesting = false;
if (response.status == "Forbidden") {
if (status == Status.Forbidden) {
console.log("Failed to add movie: quota has been met"); console.log("Failed to add movie: quota has been met");
return; return;
} }


+ 0
- 36
services/webui/src/app/routes/index.ts View File

@ -1,4 +1,3 @@
import { IApiDataResponse } from "@common/api_schema";
import { createRouter, createWebHistory, NavigationGuardNext, RouteLocationNormalized, RouteRecordRaw } from "vue-router"; import { createRouter, createWebHistory, NavigationGuardNext, RouteLocationNormalized, RouteRecordRaw } from "vue-router";
import store, { Action } from "../store"; import store, { Action } from "../store";
@ -6,7 +5,6 @@ import store, { Action } from "../store";
* Check if the user is a guest; redirect otherwise * Check if the user is a guest; redirect otherwise
*/ */
function requiresGuest(to: RouteLocationNormalized, from: RouteLocationNormalized, next: NavigationGuardNext) { function requiresGuest(to: RouteLocationNormalized, from: RouteLocationNormalized, next: NavigationGuardNext) {
if (store.getters.isAuthenticated) { if (store.getters.isAuthenticated) {
console.log("Checking guest..."); console.log("Checking guest...");
next({ name: "Home" }); next({ name: "Home" });
@ -37,40 +35,6 @@ function requiresAdmin(to: RouteLocationNormalized, from: RouteLocationNormalize
next(); next();
} }
/**
* Fetch request providing authentication and logout upon unauthorized requests
*/
export async function authFetch(path: string, options: RequestInit = {}): Promise<Response> {
// Ensure the user is logged in
let token = store.getters.token;
if (token == null) {
router.push({ name: "Login" });
throw Error("Unauthorized");
}
// Perform the request
options.credentials = "include";
options.headers = Object.assign(options.headers ?? {}, {
"Authorization": `Bearer ${token}`
});
let response = await fetch(path, options);
// If an unauthorized gets thrown, forget the user and redirect to login
if (response.status === 401) {
store.dispatch(Action.AuthForget, undefined);
router.push({ name: "Login" });
throw Error("Unauthorized");
}
return response;
}
/**
* Perform an authorized API request returning a JSON object
*/
export async function authFetchApi<T>(path: string, options: RequestInit = {}) {
let response = await authFetch(path, options);
return <Promise<IApiDataResponse<T>>>response.json();
}
const routes: RouteRecordRaw[] = [ const routes: RouteRecordRaw[] = [
{ {
path: "/", path: "/",


+ 193
- 61
services/webui/src/app/store/actions.ts View File

@ -1,42 +1,66 @@
import type { IApiResponse, IApiDataResponse, IMovie, IMovieDetails, IPaginatedResponse } from "@autoplex-api/request";
import { ActionTree } from "vuex"; import { ActionTree } from "vuex";
import { Actions } from "./generics"; import { Actions } from "./generics";
import { IState } from "./state"; import { IState } from "./state";
import { Mutation, MutationsTypes } from "./mutations"; import { Mutation, MutationsTypes } from "./mutations";
import router, { authFetch, authFetchApi } from "../routes";
import router from "../routes";
import { GettersTypes } from "./getters"; import { GettersTypes } from "./getters";
import { IApiDataResponse } from "@common/api_schema";
// Payload types // Payload types
type IAuthenticatePayload = { email: string, password: string, remember: boolean };
type IAuthenticateResult = { email?: string[], password?: string[] } | null;
type IAuthFetchPayload = { path: string, options?: RequestInit };
type IRegisterPayload = { token: string, name: string, email: string, password: string, retypePassword: string };
type ILoginPayload = { email: string, password: string, remember: boolean };
type IGetPayload = { path: string, useAuth?: boolean, options?: RequestInit };
type IPostPayload = { path: string, body?: any, useAuth?: boolean, options?: RequestInit };
// Result types
type IFormErrors = { [field: string]: string[] };
/** /**
* The available actions te perform * The available actions te perform
*/ */
export enum Action { export enum Action {
// RESTful Generics
Fetch = "FETCH",
Get = "GET",
Post = "POST",
Put = "PUT",
Delete = "DELETE",
// Authentication // Authentication
AuthAuthenticate = "AUTH_AUTHENTICATE",
AuthForget = "AUHT_FORGET",
AuthLoad = "AUTH_LOAD",
AuthRegister = "AUTH_REGISTER",
AuthLogin = "AUTH_LOGIN",
AuthForget = "AUHT_FORGET",
AuthLoad = "AUTH_LOAD",
// RESTful
AuthFetch = "AUTH_FETCH",
RequestMovie = "REQUEST_MOVIE"
// Movies Methods
ActiveMovieRequests = "ACTIVE_MOVIE_REQUESTS",
MovieDetails = "MOVIE_DETAILS",
RequestMovie = "REQUEST_MOVIE",
SearchMovies = "SEARCH_MOVIES"
} }
/** /**
* The action function signatures * The action function signatures
*/ */
export type ActionsTypes = { export type ActionsTypes = {
// RESTful Generics
[Action.Fetch] : (payload: IGetPayload) => Promise<any>,
[Action.Get] : (payload: IGetPayload) => Promise<any>,
[Action.Post] : (payload: IPostPayload) => Promise<any>,
[Action.Put] : (payload: IPostPayload) => Promise<any>,
[Action.Delete]: (payload: IPostPayload) => Promise<any>,
// Authentication // Authentication
[Action.AuthAuthenticate]: (payload: IAuthenticatePayload) => Promise<IAuthenticateResult>,
[Action.AuthForget] : () => void,
[Action.AuthLoad] : () => boolean,
[Action.AuthRegister]: (payload: IRegisterPayload) => Promise<IFormErrors|null>,
[Action.AuthLogin] : (payload: ILoginPayload) => Promise<IFormErrors|null>,
[Action.AuthForget] : () => void,
[Action.AuthLoad] : () => boolean,
// RESTful
[Action.AuthFetch] : (payload: IAuthFetchPayload) => Promise<Response>,
[Action.RequestMovie]: (tmdbId: number | string) => Promise<IApiDataResponse<{ ticketId: number }>>
// Movie Methods
[Action.ActiveMovieRequests]: () => Promise<[number, IApiDataResponse<IMovie[]>]>,
[Action.MovieDetails] : (tmdbId: number | string) => Promise<[number, IApiDataResponse<IMovieDetails>]>,
[Action.SearchMovies] : (query: string) => Promise<[number, IPaginatedResponse<IMovie>]>,
[Action.RequestMovie] : (tmdbId: number | string) => Promise<[number, IApiDataResponse<{ ticketId: number }>]>
} }
/** /**
@ -44,38 +68,138 @@ export type ActionsTypes = {
*/ */
export const actions: Actions<IState, GettersTypes, MutationsTypes, ActionsTypes> & ActionTree<IState, IState> = { export const actions: Actions<IState, GettersTypes, MutationsTypes, ActionsTypes> & ActionTree<IState, IState> = {
// Authentication ------------------------------------------------------------------------------
// Generic RESTful API -------------------------------------------------------------------------
/** /**
* Authenticate the credentials of a user and log them in
* Fetch request providing authentication and logout upon unauthorized requests
*/ */
[Action.AuthAuthenticate]({commit, dispatch}, {email, password, remember = false}) {
return new Promise((resolve, reject) => {
fetch(`/auth/login?use_cookies=${navigator.cookieEnabled}`, {
method: "post",
headers: { "Content-Type": "application/json" },
body: JSON.stringify({email, password})
})
.then(async response => {
let body = await response.json();
if (response.status !== 200) {
if (response.status === 401) {
body.errors = { "email": [ "Email or password is incorrect" ] };
}
resolve(body.errors || {});
return;
}
commit(Mutation.UserLoad, body.token);
commit(Mutation.UserStore, remember);
resolve(null);
})
.catch(e => {
console.error("Error occurred during submission:", e);
reject(e);
async [Action.Fetch]({commit, state}, {path, useAuth = true, options = {}}) {
if (useAuth) {
if (state.user === null) {
router.push({ name: "Login" });
throw Error("Unauthorized");
}
options.credentials = "include";
options.headers = Object.assign(options.headers ?? {}, {
"Authorization": `Bearer ${state.user.token}`
}); });
}
let response = await fetch(path, options);
if (useAuth && response.status === 401) {
commit(Mutation.UserForget, undefined);
router.push({ name: "Login" });
throw Error("Unauthorized");
}
return [response.status, <IApiResponse>(await response.json())];
},
/**
* Perform a generic GET request to the API
*/
async [Action.Get]({dispatch}, {path, useAuth, options = {}}) {
return await dispatch(Action.Fetch, {
path,
useAuth,
options: Object.assign(options, { method: "get" })
}); });
}, },
/**
* Perform a generic POST request to the API
*/
async [Action.Post]({dispatch}, {path, body, useAuth, options = {}}) {
return await dispatch(Action.Fetch, {
path,
useAuth,
options: Object.assign(options, {
method: "post",
headers: Object.assign(
options.headers ?? {},
body !== undefined ? { "Content-Type": "application/json" } : {}),
body: JSON.stringify(body)
}),
});
},
/**
* Perform a generic PUT request to the API
*/
async [Action.Put]({dispatch}, {path, body, useAuth, options = {}}) {
return await dispatch(Action.Fetch, {
path,
useAuth,
options: Object.assign(options, {
method: "put",
headers: Object.assign(
options.headers ?? {},
body !== undefined ? { "Content-Type": "application/json" } : {}),
body: JSON.stringify(body)
}),
});
},
/**
* Perform a generic DELETE request to the API
*/
async [Action.Delete]({dispatch}, {path, body, useAuth, options = {}}) {
return await dispatch(Action.Fetch, {
path,
useAuth,
options: Object.assign(options, {
method: "delete",
headers: Object.assign(
options.headers ?? {},
body !== undefined ? { "Content-Type": "application/json" } : {}),
body: JSON.stringify(body)
}),
});
},
// Authentication ------------------------------------------------------------------------------
/**
* Register a user. Returns any errors
*/
async [Action.AuthRegister]({dispatch}, payload: IRegisterPayload) {
try {
let [status, body] = await dispatch(Action.Post, {
path: `/auth/register`,
useAuth: false,
body: payload});
if (status !== 200) {
return body.errors ?? {};
}
} catch(e) {
console.error("Error occurred during registration", e);
throw new Error(e);
}
return null;
},
/**
* Authenticate the credentials of a user and log them in. Returns any errors
*/
async [Action.AuthLogin]({commit, dispatch}, {email, password, remember = false}) {
try {
let [status, body] = await dispatch(Action.Post, {
path: `/auth/login?use_cookies=${navigator.cookieEnabled}`,
useAuth: false,
body: {email, password}});
if (status !== 200) {
if (status === 401) {
body.errors = { "email": ["Email or password is incorrect"] };
}
return body.errors || {};
}
commit(Mutation.UserLoad, body.token);
commit(Mutation.UserStore, remember);
} catch(e) {
console.error("Error occurred during login:", e);
throw new Error(e);
}
return null;
},
/** /**
* Forget the login token and log the user out * Forget the login token and log the user out
*/ */
@ -100,33 +224,41 @@ export const actions: Actions<IState, GettersTypes, MutationsTypes, ActionsTypes
return true; return true;
}, },
// RESTful -------------------------------------------------------------------------------------
// Movies --------------------------------------------------------------------------------------
/** /**
* Fetch request providing authentication and logout upon unauthorized requests
* Get the user's active movie requests
*/ */
async [Action.AuthFetch]({commit, state}, {path, options = {}}) {
if (state.user == null) {
router.push({ name: "Login" });
throw Error("Unauthorized");
}
options.credentials = "include";
options.headers = Object.assign(options.headers ?? {}, {
"Authorization": `Bearer ${state.user.token}`
async [Action.ActiveMovieRequests]({dispatch}) {
return await dispatch(Action.Get, {
path: `/api/movie/request/tickets/active`
});
},
/**
* Get the details of a specific movie by its TMDb ID
*/
async [Action.MovieDetails]({dispatch}, tmdbId: string|number) {
return await dispatch(Action.Get, {
path: `/api/movie/details/${tmdbId}`
});
},
/**
* Search for movies
*/
async [Action.SearchMovies]({dispatch}, query: string) {
return await dispatch(Action.Get, {
path: `api/movie/search?query=${encodeURI(query)}`
}); });
let response = await fetch(path, options);
if (response.status === 401) {
commit(Mutation.UserForget, undefined);
router.push({ name: "Login" });
throw Error("Unauthorized");
}
return response;
}, },
/** /**
* Request a movie * Request a movie
*/ */
async [Action.RequestMovie](_, tmdbId) {
return await authFetchApi(`/api/movie/request/create/tmdb/${tmdbId}`);
async [Action.RequestMovie]({dispatch}, tmdbId: string|number) {
return await dispatch(Action.Post, {
path: `/api/movie/request/create/tmdb/${tmdbId}`
});
} }
}; };

+ 15
- 9
services/webui/src/app/views/Dashboard.vue View File

@ -7,11 +7,12 @@
</template> </template>
<script lang="ts"> <script lang="ts">
import type { IMovie } from "@autoplex-api/request";
import { Status } from "@autoplex/restful";
import { Action } from "../store";
import { defineComponent } from "vue"; import { defineComponent } from "vue";
import { authFetchApi } from "../routes";
import MovieList from "../components/MovieList.vue"; import MovieList from "../components/MovieList.vue";
import MovieModal from "../components/modals/MovieModal.vue"; import MovieModal from "../components/modals/MovieModal.vue";
import { IApiMovie } from "../../common/api_schema";
export default defineComponent({ export default defineComponent({
components: { components: {
@ -20,20 +21,25 @@ export default defineComponent({
}, },
data() { data() {
return { return {
activeRequests: <IApiMovie[]>[],
activeRequests: <IMovie[]>[],
activeTmdb: <number|undefined> undefined activeTmdb: <number|undefined> undefined
} }
}, },
methods: { methods: {
displayMovie(movie: IApiMovie, index: number) {
displayMovie(movie: IMovie, index: number) {
this.activeTmdb = movie.tmdbId; this.activeTmdb = movie.tmdbId;
}, },
fetchRequests() {
return authFetchApi<IApiMovie[]>("/api/movie/request/tickets/active").then((response) => {
async fetchRequests() {
try {
let [status, response] = await this.$store.dispatch(Action.ActiveMovieRequests, undefined);
if (status !== Status.Ok) {
throw new Error("Non-OK status returned: " + status);
}
console.log(response.data);
this.activeRequests = response.data; this.activeRequests = response.data;
}).catch(() => {
console.error("Failed to fetch active movie tickets");
});
} catch(e) {
console.error("Failed to fetch active movie tickets", e);
};
} }
}, },
mounted() { mounted() {


+ 2
- 2
services/webui/src/app/views/Login.vue View File

@ -41,7 +41,7 @@ export default defineComponent({
email: "", email: "",
password: "", password: "",
remember: false remember: false
},
}
} }
}, },
methods: { methods: {
@ -51,7 +51,7 @@ export default defineComponent({
} }
this.isSubmitting = true; this.isSubmitting = true;
try { try {
let errors = await this.$store.dispatch(Action.AuthAuthenticate, this.fields);
let errors = await this.$store.dispatch(Action.AuthLogin, this.fields);
if (errors) { if (errors) {
for (let fieldName of ["email", "password"]) { for (let fieldName of ["email", "password"]) {
let field = <any>this.$refs[fieldName]; let field = <any>this.$refs[fieldName];


+ 16
- 24
services/webui/src/app/views/Register.vue View File

@ -34,11 +34,12 @@
</template> </template>
<script lang="ts"> <script lang="ts">
import { ValidationConstraints as constraints } from "@autoplex-api/request";
import { defineComponent, reactive } from "vue"; import { defineComponent, reactive } from "vue";
import CheckBox from "../components/CheckBox.vue"; import CheckBox from "../components/CheckBox.vue";
import TextBox from "../components/TextBox.vue"; import TextBox from "../components/TextBox.vue";
import { constraints } from "../../common/validation";
import { validateValue } from "../util"; import { validateValue } from "../util";
import { Action } from "../store";
export default defineComponent({ export default defineComponent({
components: { components: {
@ -58,7 +59,8 @@ export default defineComponent({
constraints: { constraints: {
name: constraints.register.name, name: constraints.register.name,
email: constraints.register.email, email: constraints.register.email,
password: constraints.register.password
password: constraints.register.password,
retypePassword: constraints.register.retypePassword
} }
} }
}, },
@ -71,32 +73,22 @@ export default defineComponent({
return; return;
} }
this.isSubmitting = true; this.isSubmitting = true;
fetch(`/auth/register`, {
method: "post",
headers: {
"Content-Type": "application/json"
},
body: JSON.stringify(this.fields)
})
.then(async response => {
this.isSubmitting = false;
if (response.status !== 200) {
let body = await response.json();
if (body.errors) {
for (let fieldName in this.fields) {
let field = <any>this.$refs[fieldName];
let message = <string>(body.errors[fieldName] ?? [""])[0];
field.setErrorMessage(message);
}
try {
let errors = await this.$store.dispatch(Action.AuthRegister, this.fields);
if (errors !== null) {
for (let fieldName in this.fields) {
let field = <any>this.$refs[fieldName];
let message = (fieldName in errors) ? (errors)[fieldName][0] : "";
field.setErrorMessage(message);
} }
this.isSubmitting = false;
return; return;
} }
this.$router.push({ name: "Login" }); this.$router.push({ name: "Login" });
})
.catch(e => {
console.error("Error occurred during submission:", e);
this.isSubmitting = false;
});
} catch(e) {
console.error("Error occurred during registration:", e);
}
this.isSubmitting = false;
}, },
/** /**


+ 7
- 6
services/webui/src/app/views/Search.vue View File

@ -14,10 +14,10 @@
</template> </template>
<script lang="ts"> <script lang="ts">
import type { IMovie } from "@autoplex-api/request";
import { defineComponent } from "vue"; import { defineComponent } from "vue";
import { IApiMovie, IApiPaginatedResponse } from "@common/api_schema";
import { authFetchApi } from "../routes";
import MovieList from "../components/MovieList.vue"; import MovieList from "../components/MovieList.vue";
import { Action } from "../store";
export default defineComponent({ export default defineComponent({
components: { components: {
@ -27,7 +27,7 @@ export default defineComponent({
return { return {
exampleTitles: ["John Wick", "The Incredibles 2", "Stand by Me", "Shawshank Redemption", "The Dark Knight", "Pulp Fiction", "Forrest Gump"], exampleTitles: ["John Wick", "The Incredibles 2", "Stand by Me", "Shawshank Redemption", "The Dark Knight", "Pulp Fiction", "Forrest Gump"],
isSubmitting: false, isSubmitting: false,
movies: <IApiMovie[]>[],
movies: <IMovie[]>[],
searchValue: <string>this.$route.query["query"] || "", searchValue: <string>this.$route.query["query"] || "",
page: -1, page: -1,
totalPages: 0, totalPages: 0,
@ -35,7 +35,7 @@ export default defineComponent({
} }
}, },
methods: { methods: {
displayMovie(movie: IApiMovie) {
displayMovie(movie: IMovie) {
this.$router.push({ this.$router.push({
name : "Lookup", name : "Lookup",
params: { tmdbId: movie.tmdbId }, params: { tmdbId: movie.tmdbId },
@ -49,15 +49,16 @@ export default defineComponent({
if (this.isSubmitting || this.searchValue.trim().length == 0) { if (this.isSubmitting || this.searchValue.trim().length == 0) {
return; return;
} }
this.isSubmitting = true;
if (document.activeElement && (<any>document.activeElement).blur) { if (document.activeElement && (<any>document.activeElement).blur) {
(<any>document.activeElement).blur(); (<any>document.activeElement).blur();
} }
if (pushRoute) { if (pushRoute) {
this.$router.push({ name: "Search", query: { query: this.searchValue } }); this.$router.push({ name: "Search", query: { query: this.searchValue } });
} }
this.isSubmitting = true;
try { try {
let response = await authFetchApi<IApiPaginatedResponse<IApiMovie>>(`/api/movie/search?query=${encodeURI(this.searchValue)}`);
let [status, response] = await this.$store.dispatch(Action.SearchMovies, this.searchValue);
console.log(status, response);
this.movies = response.data.results; this.movies = response.data.results;
this.page = response.data.page; this.page = response.data.page;
this.totalPages = response.data.totalPages; this.totalPages = response.data.totalPages;


+ 0
- 60
services/webui/src/common/api_schema.ts View File

@ -1,60 +0,0 @@
/**
* Basic user information schema
*/
export interface IUser {
id : number,
name : string,
isAdmin: boolean
}
/**
* The JWT auth token structure
*/
export interface ITokenSchema extends IUser {
iat : number,
exp : number
}
/**
* The general API response structure
*/
export interface IApiResponse {
status: string
}
/**
* A generic data response from the API
*/
export interface IApiDataResponse<T> extends IApiResponse {
data: T
}
export interface IApiPaginatedResponse<T> {
page : number,
results : T[],
totalPages : number,
totalResults: number
};
/**
* A movie listing returned from the API
*/
export interface IApiMovie {
plexLink : string | null,
posterPath : string | null,
releaseDate: string | null,
ticketId : number | null,
title : string,
tmdbId : number
}
/**
* Movie details returned from the API
*/
export interface IApiMovieDetails extends IApiMovie {
backdropPath: string | null,
imdbId : string | null,
overview : string | null,
runtime : number | null,
requestedBy : IUser | null
}

+ 0
- 90
services/webui/src/common/validation.ts View File

@ -1,90 +0,0 @@
export const constraints = {
api: {
movie: {
search: {
query: {
presence: {
allowEmpty: false,
message: "The query cannot be blank"
}
},
year: {
numericality: {
onlyInteger: true,
greaterThan: 0,
notGreaterThan: "Invalid year",
notValid: "Invalid year",
notInteger: "Invalid year"
}
}
}
}
},
login: {
email: {
presence: {
allowEmpty: false,
message: "An email address is required"
}
},
password: {
presence: {
allowEmpty: false,
message: "A password is required"
}
}
},
register: {
token: {
presence: {
message: "A valid token is required to register"
},
token: {
message: "A valid token is required to register"
}
},
name: {
presence: {
allowEmpty: false,
message: "Your name is required"
},
length: {
maximum: 50,
tooLong: "Your name cannot exceed 50 characters"
}
},
email: {
presence: {
allowEmpty: false,
message: "Your email is required"
},
length: {
maximum: 255,
tooLong: "An email address cannot exceed 255 characters"
},
email: {
message: "A valid email address is required"
}
},
password: {
presence: {
allowEmpty: false,
message: "A password is required"
},
length: {
minimum: 8,
tooShort: "Password should be at least 8 characters"
}
},
retypePassword: {
presence: {
allowEmpty: false,
message: "Re-type your password to confirm it"
},
equality: {
attribute: "password",
message: "Passwords must match"
}
}
}
};

Loading…
Cancel
Save