diff --git a/gymboard-app/src/api/search/index.ts b/gymboard-app/src/api/search/index.ts index 800bcbc..9c8a730 100644 --- a/gymboard-app/src/api/search/index.ts +++ b/gymboard-app/src/api/search/index.ts @@ -6,23 +6,37 @@ const api = axios.create({ }); /** - * Searches for gyms using the given query, and eventually returns results. - * @param query The query to use. + * The search API class. */ -export async function searchGyms( - query: string -): Promise> { - const response = await api.get('/search/gyms?q=' + query); - return response.data; +class SearchApi { + /** + * Searches for gyms using the given query, and eventually returns results. + * @param query The query to use. + */ + public async searchGyms(query: string): Promise> { + const response = await api.get(`/search/gyms?q=${query}`); + return response.data; + } + + /** + * Searches for users using the given query, and eventually returns results. + * Note that only users whose accounts are not private will be included in + * search results. + * @param query The query to use. + */ + public async searchUsers(query: string): Promise> { + const response = await api.get(`/search/users?q=${query}`); + return response.data; + } + + public async getStatus(): Promise { + try { + const response = await api.get(`/status`); + return true; + } catch (error) { + return false; + } + } } -/** - * Searches for users using the given query, and eventually returns results. - * Note that only users whose accounts are not private will be included in - * search results. - * @param query The query to use. - */ -export async function searchUsers(query: string): Promise> { - const response = await api.get('/search/users?q=' + query); - return response.data; -} +export default new SearchApi(); diff --git a/gymboard-app/src/components/AccountMenuItem.vue b/gymboard-app/src/components/AccountMenuItem.vue index 3126d2e..a55f932 100644 --- a/gymboard-app/src/components/AccountMenuItem.vue +++ b/gymboard-app/src/components/AccountMenuItem.vue @@ -3,14 +3,19 @@ - + - {{ $t('accountMenuItem.myAccount') }} + {{ $t('accountMenuItem.profile') }} + + + + + {{ $t('accountMenuItem.settings') }} @@ -35,6 +40,7 @@ import { useAuthStore } from 'stores/auth-store'; import api from 'src/api/main'; import { useRoute, useRouter } from 'vue-router'; +import { getUserRoute } from 'src/router/user-routing'; const authStore = useAuthStore(); const route = useRoute(); diff --git a/gymboard-app/src/i18n/en-US/index.ts b/gymboard-app/src/i18n/en-US/index.ts index 6d39bac..9edaf94 100644 --- a/gymboard-app/src/i18n/en-US/index.ts +++ b/gymboard-app/src/i18n/en-US/index.ts @@ -17,6 +17,7 @@ export default { password: 'Password', logIn: 'Log in', createAccount: 'Create an account', + authFailed: 'Invalid credentials.' }, indexPage: { searchHint: 'Search for a Gym', @@ -45,9 +46,16 @@ export default { description: 'We couldn\'t find the user you\'re looking for.' } }, + userSettingsPage: { + title: 'Account Settings' + }, accountMenuItem: { logIn: 'Login', - myAccount: 'My Account', + profile: 'Profile', + settings: 'Settings', logOut: 'Log out', }, + generalErrors: { + apiError: 'An API error occurred. Please try again later.' + } }; diff --git a/gymboard-app/src/i18n/index.ts b/gymboard-app/src/i18n/index.ts index 1a4a52b..c590cf3 100644 --- a/gymboard-app/src/i18n/index.ts +++ b/gymboard-app/src/i18n/index.ts @@ -7,3 +7,23 @@ export default { 'nl-NL': nlNL, de: de, }; + +export const supportedLocales = [ + { value: 'en-US', label: 'English' }, + { value: 'nl-NL', label: 'Nederlands' }, + { value: 'de', label: 'Deutsch' }, +]; + +/** + * Tries to find a locale with the given code, or defaults to the base locale. + * @param code The locale code. + * @returns The locale that was resolved. + */ +export function resolveLocale(code?: string) { + for (const loc of supportedLocales) { + if (loc.value === code) { + return loc; + } + } + return supportedLocales[0]; +} diff --git a/gymboard-app/src/layouts/MainLayout.vue b/gymboard-app/src/layouts/MainLayout.vue index fbc5b66..c4f881e 100644 --- a/gymboard-app/src/layouts/MainLayout.vue +++ b/gymboard-app/src/layouts/MainLayout.vue @@ -17,7 +17,6 @@ > - @@ -41,7 +40,6 @@ diff --git a/gymboard-app/src/pages/auth/LoginPage.vue b/gymboard-app/src/pages/auth/LoginPage.vue index 493d450..11a3c95 100644 --- a/gymboard-app/src/pages/auth/LoginPage.vue +++ b/gymboard-app/src/pages/auth/LoginPage.vue @@ -59,10 +59,15 @@ import { ref } from 'vue'; import api from 'src/api/main'; import { useAuthStore } from 'stores/auth-store'; import { useRoute, useRouter } from 'vue-router'; +import { useI18n } from 'vue-i18n'; +import { resolveLocale } from 'src/i18n'; +import { useQuasar } from 'quasar'; const authStore = useAuthStore(); const router = useRouter(); const route = useRoute(); +const i18n = useI18n({ useScope: 'global' }); +const quasar = useQuasar(); const loginModel = ref({ email: '', @@ -70,15 +75,40 @@ const loginModel = ref({ }); const passwordVisible = ref(false); +/** + * The main login function. It attempts to log in the user, and gracefully + * handles failures. + * + * Upon successful login, we set the app's locale to the user's preferred + * locale, and then redirect to the pre-configured next URL (if there is one). + */ async function tryLogin() { try { await api.auth.login(authStore, loginModel.value); + + // Set the locale to the user's preferred locale. + i18n.locale.value = resolveLocale(authStore.user?.preferences?.locale); + + // Redirect back to whatever was set as the next URL. const dest = route.query.next ? decodeURIComponent(route.query.next as string) : '/'; await router.push(dest); - } catch (error) { - console.error(error); + } catch (error: any) { + if (error.response && error.response.status === 401) { + quasar.notify({ + message: i18n.t('loginPage.authFailed'), + type: 'warning', + position: 'top', + }); + } else { + quasar.notify({ + message: i18n.t('generalErrors.apiError'), + type: 'negative', + position: 'top', + }); + console.error(error); + } } } diff --git a/gymboard-app/src/pages/auth/UserSettingsPage.vue b/gymboard-app/src/pages/auth/UserSettingsPage.vue new file mode 100644 index 0000000..99f3690 --- /dev/null +++ b/gymboard-app/src/pages/auth/UserSettingsPage.vue @@ -0,0 +1,14 @@ + + + + + \ No newline at end of file diff --git a/gymboard-app/src/router/routes.ts b/gymboard-app/src/router/routes.ts index b6220b3..35f2e7e 100644 --- a/gymboard-app/src/router/routes.ts +++ b/gymboard-app/src/router/routes.ts @@ -13,6 +13,7 @@ import RegistrationSuccessPage from 'pages/auth/RegistrationSuccessPage.vue'; import ActivationPage from 'pages/auth/ActivationPage.vue'; import SubmissionPage from 'pages/SubmissionPage.vue'; import UserPage from 'pages/UserPage.vue'; +import UserSettingsPage from 'pages/auth/UserSettingsPage.vue'; const routes: RouteRecordRaw[] = [ // Auth-related pages, which live outside the main layout. @@ -28,7 +29,15 @@ const routes: RouteRecordRaw[] = [ children: [ { path: '', component: IndexPage }, { path: 'testing', component: TestingPage }, - { path: 'users/:userId', component: UserPage }, + { + path: 'users/:userId', + children: [ + { path: '', component: UserPage }, + { path: 'settings', component: UserSettingsPage } + ] + }, + // { path: 'users/:userId', component: UserPage }, + // { path: 'users/:userId/settings', component: UserSettingsPage }, { path: 'gyms/:countryCode/:cityShortName/:gymShortName', component: GymPage, diff --git a/gymboard-app/src/router/user-routing.ts b/gymboard-app/src/router/user-routing.ts new file mode 100644 index 0000000..3cc52b1 --- /dev/null +++ b/gymboard-app/src/router/user-routing.ts @@ -0,0 +1,5 @@ +import { User } from 'src/api/main/auth'; + +export function getUserRoute(user: User) { + return `/users/${user.id}`; +} \ No newline at end of file diff --git a/gymboard-cdn/src/main/java/nl/andrewlalis/gymboardcdn/api/StatusController.java b/gymboard-cdn/src/main/java/nl/andrewlalis/gymboardcdn/api/StatusController.java new file mode 100644 index 0000000..1925b6c --- /dev/null +++ b/gymboard-cdn/src/main/java/nl/andrewlalis/gymboardcdn/api/StatusController.java @@ -0,0 +1,17 @@ +package nl.andrewlalis.gymboardcdn.api; + +import org.springframework.http.ResponseEntity; +import org.springframework.web.bind.annotation.GetMapping; +import org.springframework.web.bind.annotation.RequestMapping; +import org.springframework.web.bind.annotation.RestController; + +import java.util.Map; + +@RestController +@RequestMapping +public class StatusController { + @GetMapping(path = "/status") + public ResponseEntity getStatus() { + return ResponseEntity.ok(Map.of("online", true)); + } +} diff --git a/gymboard-search/src/main/java/nl/andrewlalis/gymboardsearch/StatusController.java b/gymboard-search/src/main/java/nl/andrewlalis/gymboardsearch/StatusController.java new file mode 100644 index 0000000..03403f5 --- /dev/null +++ b/gymboard-search/src/main/java/nl/andrewlalis/gymboardsearch/StatusController.java @@ -0,0 +1,15 @@ +package nl.andrewlalis.gymboardsearch; + +import org.springframework.http.ResponseEntity; +import org.springframework.web.bind.annotation.GetMapping; +import org.springframework.web.bind.annotation.RestController; + +import java.util.Map; + +@RestController +public class StatusController { + @GetMapping(path = "/status") + public ResponseEntity getStatus() { + return ResponseEntity.ok(Map.of("online", true)); + } +}