├── static.json ├── .prettierignore ├── public └── favicon.ico ├── src ├── assets │ ├── logo.png │ └── scss │ │ ├── app.scss │ │ ├── _transitions.scss │ │ └── _variables.scss ├── auth │ ├── index.ts │ ├── useAuth.ts │ ├── interceptors.ts │ ├── navigationGuards.ts │ ├── devtools.ts │ ├── types.ts │ └── plugin.ts ├── api │ ├── axios.ts │ └── index.ts ├── layouts │ ├── default.vue │ └── main.vue ├── components │ ├── loading │ │ ├── Loading.vue │ │ ├── WithLoadingIcon.vue │ │ └── WithLoadingPromise.vue │ ├── HelloWorld.vue │ ├── RouterViewTransition.vue │ └── Toasts.vue ├── App.vue ├── router.ts ├── pages │ ├── profile.vue │ ├── login.vue │ ├── about.vue │ ├── [...error].vue │ ├── home.vue │ ├── index.vue │ └── design.vue ├── plugins.d.ts ├── env.d.ts ├── main.ts ├── plugins │ └── i18n.ts └── composables │ └── useToasts.ts ├── .prettierrc.js ├── .gitignore ├── .vscode ├── extensions.json └── settings.json ├── openapitools.json ├── locales ├── en.json ├── es.json └── de.json ├── index.html ├── tsconfig.json ├── vue-i18n-extract.config.js ├── vite.config.ts ├── .eslintrc.js ├── package.json ├── spec └── schema.yml └── README.md /static.json: -------------------------------------------------------------------------------- 1 | { 2 | "root": "dist/" 3 | } 4 | -------------------------------------------------------------------------------- /.prettierignore: -------------------------------------------------------------------------------- 1 | node_modules 2 | dist 3 | src/api-client 4 | -------------------------------------------------------------------------------- /public/favicon.ico: -------------------------------------------------------------------------------- https://raw.githubusercontent.com/helixsoftco/vuelix/HEAD/public/favicon.ico -------------------------------------------------------------------------------- /src/assets/logo.png: -------------------------------------------------------------------------------- https://raw.githubusercontent.com/helixsoftco/vuelix/HEAD/src/assets/logo.png -------------------------------------------------------------------------------- /src/auth/index.ts: -------------------------------------------------------------------------------- 1 | export { createAuth } from './plugin' 2 | export { useAuth } from './useAuth' 3 | -------------------------------------------------------------------------------- /.prettierrc.js: -------------------------------------------------------------------------------- 1 | module.exports = { 2 | printWidth: 120, 3 | semi: false, 4 | singleQuote: true, 5 | } 6 | -------------------------------------------------------------------------------- /.gitignore: -------------------------------------------------------------------------------- 1 | node_modules 2 | .DS_Store 3 | dist 4 | dist-ssr 5 | *.local 6 | # Generated API Client 7 | /src/api-client -------------------------------------------------------------------------------- /src/api/axios.ts: -------------------------------------------------------------------------------- 1 | import axios from 'axios' 2 | 3 | const axiosInstance = axios.create() 4 | export default axiosInstance 5 | -------------------------------------------------------------------------------- /.vscode/extensions.json: -------------------------------------------------------------------------------- 1 | { 2 | "recommendations": ["johnsoncodehk.volar", "esbenp.prettier-vscode", "dbaeumer.vscode-eslint"] 3 | } 4 | -------------------------------------------------------------------------------- /openapitools.json: -------------------------------------------------------------------------------- 1 | { 2 | "$schema": "node_modules/@openapitools/openapi-generator-cli/config.schema.json", 3 | "spaces": 2, 4 | "generator-cli": { 5 | "version": "5.3.0" 6 | } 7 | } 8 | -------------------------------------------------------------------------------- /locales/en.json: -------------------------------------------------------------------------------- 1 | { 2 | "Not Authenticated": "", 3 | "Change Language": "", 4 | "Authenticated as {user}": "", 5 | "This template has no likes | This template has one like | This template has {likes} likes": "" 6 | } -------------------------------------------------------------------------------- /src/layouts/default.vue: -------------------------------------------------------------------------------- 1 | 4 | 5 | 8 | -------------------------------------------------------------------------------- /src/api/index.ts: -------------------------------------------------------------------------------- 1 | import { PetApi } from '@/api-client' 2 | import axiosInstance from './axios' 3 | 4 | /** 5 | * Example API by Swagger 6 | * https://editor.swagger.io/ 7 | */ 8 | export const petApi = new PetApi(undefined, undefined, axiosInstance) 9 | -------------------------------------------------------------------------------- /src/components/loading/Loading.vue: -------------------------------------------------------------------------------- 1 | 8 | -------------------------------------------------------------------------------- /src/App.vue: -------------------------------------------------------------------------------- 1 | 5 | 6 | 10 | -------------------------------------------------------------------------------- /src/auth/useAuth.ts: -------------------------------------------------------------------------------- 1 | import { authInstance } from './plugin' 2 | import { AuthPlugin } from './types' 3 | 4 | /** 5 | * Returns the auth instance. Equivalent to using `$auth` inside 6 | * templates. 7 | */ 8 | export function useAuth(): AuthPlugin { 9 | // eslint-disable-next-line 10 | return authInstance! 11 | } 12 | -------------------------------------------------------------------------------- /src/router.ts: -------------------------------------------------------------------------------- 1 | import { createRouter, createWebHistory } from 'vue-router' 2 | import generatedRoutes from '~pages' 3 | import { setupLayouts } from 'virtual:generated-layouts' 4 | 5 | const router = createRouter({ 6 | history: createWebHistory(), 7 | routes: setupLayouts(generatedRoutes), 8 | }) 9 | 10 | export default router 11 | -------------------------------------------------------------------------------- /locales/es.json: -------------------------------------------------------------------------------- 1 | { 2 | "Not Authenticated": "No Autenticado", 3 | "Change Language": "Cambiar Lenguaje", 4 | "Authenticated as {user}": "Autenticado como {user}", 5 | "This template has no likes | This template has one like | This template has {likes} likes": "Este template no tiene likes | Este template tiene un like | Este template tiene {likes} likes" 6 | } 7 | -------------------------------------------------------------------------------- /locales/de.json: -------------------------------------------------------------------------------- 1 | { 2 | "Not Authenticated": "Nicht berechtigt", 3 | "Change Language": "Sprache ändern", 4 | "Authenticated as {user}": "Authentifiziert als {user}", 5 | "This template has no likes | This template has one like | This template has {likes} likes": "Diese Vorlage hat keine Likes | Diese Vorlage hat ein Like | Diese Vorlage hat {likes} Likes" 6 | } 7 | -------------------------------------------------------------------------------- /src/pages/profile.vue: -------------------------------------------------------------------------------- 1 | 2 | meta: 3 | layout: main 4 | transition: slide-fade 5 | 6 | 7 | 15 | -------------------------------------------------------------------------------- /src/plugins.d.ts: -------------------------------------------------------------------------------- 1 | import { AuthPlugin } from './auth/types' 2 | import 'vue-router' 3 | 4 | declare module '@vue/runtime-core' { 5 | export interface ComponentCustomProperties { 6 | $auth: AuthPlugin 7 | } 8 | } 9 | declare module 'vue-router' { 10 | interface RouteMeta { 11 | layout?: string 12 | transition?: string 13 | public?: boolean 14 | } 15 | } 16 | -------------------------------------------------------------------------------- /index.html: -------------------------------------------------------------------------------- 1 | 2 | 3 | 4 | 5 | 6 | 7 | Vite App 8 | 9 | 10 |
11 | 12 | 13 | 14 | -------------------------------------------------------------------------------- /src/env.d.ts: -------------------------------------------------------------------------------- 1 | /// 2 | /// 3 | /// 4 | 5 | declare module '*.vue' { 6 | import { DefineComponent } from 'vue' 7 | // eslint-disable-next-line @typescript-eslint/no-explicit-any, @typescript-eslint/ban-types 8 | const component: DefineComponent<{}, {}, any> 9 | export default component 10 | } 11 | -------------------------------------------------------------------------------- /src/pages/login.vue: -------------------------------------------------------------------------------- 1 | 2 | 3 | 11 | -------------------------------------------------------------------------------- /src/components/loading/WithLoadingIcon.vue: -------------------------------------------------------------------------------- 1 | 6 | 7 | 14 | 15 | 21 | -------------------------------------------------------------------------------- /src/auth/interceptors.ts: -------------------------------------------------------------------------------- 1 | import { AxiosInstance } from 'axios' 2 | import { useAuth } from './useAuth' 3 | 4 | export function configureAuthorizationHeaderInterceptor(axiosInstance: AxiosInstance, prefix = 'Bearer') { 5 | axiosInstance.interceptors.request.use((config) => { 6 | const auth = useAuth() 7 | 8 | config.headers = config.headers ?? {} 9 | if (auth.isAuthenticated) { 10 | config.headers.Authorization = `${prefix} ${auth.accessToken}` 11 | } 12 | return config 13 | }) 14 | } 15 | -------------------------------------------------------------------------------- /src/assets/scss/app.scss: -------------------------------------------------------------------------------- 1 | @import './variables'; 2 | @import 'bootstrap/scss/bootstrap'; 3 | 4 | @import './transitions'; 5 | 6 | html { 7 | font-size: 16px; // 1rem 8 | } 9 | 10 | html, 11 | body, 12 | #app, 13 | .router-view-root-transition-wrapper, 14 | .router-view-transition-wrapper { 15 | height: 100%; 16 | } 17 | 18 | #app { 19 | font-family: Avenir, Helvetica, Arial, sans-serif; 20 | -webkit-font-smoothing: antialiased; 21 | -moz-osx-font-smoothing: grayscale; 22 | text-align: center; // TODO: Remove this 23 | } 24 | -------------------------------------------------------------------------------- /tsconfig.json: -------------------------------------------------------------------------------- 1 | { 2 | "compilerOptions": { 3 | "baseUrl": ".", 4 | "target": "esnext", 5 | "useDefineForClassFields": true, 6 | "module": "esnext", 7 | "moduleResolution": "node", 8 | "strict": true, 9 | "jsx": "preserve", 10 | "sourceMap": true, 11 | "resolveJsonModule": true, 12 | "esModuleInterop": true, 13 | "lib": ["esnext", "dom"], 14 | "paths": { 15 | "@/*": ["src/*"] 16 | } 17 | }, 18 | "include": ["src/**/*.ts", "src/**/*.d.ts", "src/**/*.tsx", "src/**/*.vue"] 19 | } 20 | -------------------------------------------------------------------------------- /src/assets/scss/_transitions.scss: -------------------------------------------------------------------------------- 1 | /* fade */ 2 | .fade-enter-active, 3 | .fade-leave-active { 4 | transition: opacity 0.4s ease; 5 | } 6 | 7 | .fade-enter-to, 8 | .fade-leave-from { 9 | opacity: 1; 10 | } 11 | 12 | .fade-enter-from, 13 | .fade-leave-to { 14 | opacity: 0; 15 | } 16 | 17 | /* slide-fade */ 18 | .slide-fade-enter-active { 19 | transition: all 0.3s ease-out; 20 | } 21 | 22 | .slide-fade-leave-active { 23 | transition: all 0.5s cubic-bezier(1, 0.5, 0.8, 1); 24 | } 25 | 26 | .slide-fade-enter-from, 27 | .slide-fade-leave-to { 28 | transform: translateY(-30px); 29 | opacity: 0; 30 | } 31 | -------------------------------------------------------------------------------- /src/main.ts: -------------------------------------------------------------------------------- 1 | import '@/assets/scss/app.scss' 2 | import { createApp } from 'vue' 3 | import { createAuth } from './auth' 4 | import App from './App.vue' 5 | import router from './router' 6 | import axiosInstance from './api/axios' 7 | import i18n from './plugins/i18n' 8 | 9 | const auth = createAuth({ 10 | router, 11 | loginRedirectRoute: { name: 'home' }, 12 | logoutRedirectRoute: { name: 'index' }, 13 | autoConfigureNavigationGuards: true, 14 | axios: { 15 | instance: axiosInstance, 16 | autoAddAuthorizationHeader: true, 17 | }, 18 | }) 19 | 20 | const app = createApp(App) 21 | app.use(router) 22 | app.use(auth) 23 | app.use(i18n) 24 | app.provide('enable-route-transitions', true) 25 | app.mount('#app') 26 | -------------------------------------------------------------------------------- /src/auth/navigationGuards.ts: -------------------------------------------------------------------------------- 1 | import { RouteLocationRaw, Router } from 'vue-router' 2 | import { RequiredAuthOptions } from './types' 3 | import { useAuth } from './useAuth' 4 | 5 | export function configureNavigationGuards(router: Router, options: RequiredAuthOptions) { 6 | router.beforeEach(async (to): Promise => { 7 | const auth = useAuth() 8 | 9 | if (to.name === options.loginRouteName) { 10 | if (auth.isAuthenticated) { 11 | return options.loginRedirectRoute 12 | } 13 | return true 14 | } 15 | 16 | if (!to.meta.public) { 17 | if (!auth.isAuthenticated) { 18 | return { name: options.loginRouteName, query: { redirectTo: to.fullPath } } 19 | } 20 | } 21 | 22 | return true 23 | }) 24 | } 25 | -------------------------------------------------------------------------------- /src/auth/devtools.ts: -------------------------------------------------------------------------------- 1 | import { App, setupDevtoolsPlugin } from '@vue/devtools-api' 2 | import { watch } from 'vue' 3 | import { AuthPlugin } from './types' 4 | 5 | const stateType = 'Auth Plugin' 6 | 7 | export function setupDevtools(app: App, plugin: AuthPlugin) { 8 | setupDevtoolsPlugin( 9 | { 10 | id: 'basic-auth-plugin', 11 | label: 'Basic Auth Plugin', 12 | componentStateTypes: [stateType], 13 | app, 14 | }, 15 | (api) => { 16 | api.on.inspectComponent((payload) => { 17 | payload.instanceData.state.push({ 18 | type: stateType, 19 | key: '$auth', 20 | value: plugin, 21 | }) 22 | }) 23 | 24 | watch(plugin, () => { 25 | api.notifyComponentUpdate() 26 | }) 27 | } 28 | ) 29 | } 30 | -------------------------------------------------------------------------------- /src/layouts/main.vue: -------------------------------------------------------------------------------- 1 | 4 | 5 | 19 | 20 | 37 | -------------------------------------------------------------------------------- /src/assets/scss/_variables.scss: -------------------------------------------------------------------------------- 1 | // COLORS 2 | $primary: #0d6efd; 3 | $secondary: #6c757d; 4 | $success: #198754; 5 | $info: #0dcaf0; 6 | $warning: #ffc107; 7 | $danger: #dc3545; 8 | $light: #f8f9fa; 9 | $dark: #212529; 10 | $body-bg: #f5f7fa; 11 | 12 | $theme-colors: ( 13 | 'primary': $primary, 14 | 'secondary': $secondary, 15 | 'success': $success, 16 | 'info': $info, 17 | 'warning': $warning, 18 | 'danger': $danger, 19 | 'light': $light, 20 | 'dark': $dark, 21 | ); 22 | 23 | // UTILITIES 24 | $spacer: 1rem; 25 | $spacers: ( 26 | 0: 0, 27 | 1: $spacer * 0.25, 28 | 2: $spacer * 0.5, 29 | 3: $spacer, 30 | 4: $spacer * 1.5, 31 | 5: $spacer * 3, 32 | 6: $spacer * 6, 33 | 100: 100px, 34 | ); 35 | $enable-negative-margins: false; 36 | 37 | // GRID SYSTEM 38 | $container-max-widths: ( 39 | sm: 540px, 40 | md: 720px, 41 | lg: 960px, 42 | xl: 1140px, 43 | xxl: 1320px, 44 | ); 45 | -------------------------------------------------------------------------------- /.vscode/settings.json: -------------------------------------------------------------------------------- 1 | { 2 | "[json]": { 3 | "editor.defaultFormatter": "esbenp.prettier-vscode" 4 | }, 5 | "[vue]": { 6 | "editor.defaultFormatter": "esbenp.prettier-vscode" 7 | }, 8 | "[html]": { 9 | "editor.defaultFormatter": "esbenp.prettier-vscode" 10 | }, 11 | "[javascript]": { 12 | "editor.defaultFormatter": "esbenp.prettier-vscode" 13 | }, 14 | "[typescript]": { 15 | "editor.defaultFormatter": "esbenp.prettier-vscode" 16 | }, 17 | "[scss]": { 18 | "editor.defaultFormatter": "esbenp.prettier-vscode" 19 | }, 20 | "editor.tabSize": 2, 21 | "editor.rulers": [120], 22 | "editor.formatOnPaste": true, 23 | "editor.formatOnSave": true, 24 | "prettier.requireConfig": true, 25 | "editor.codeActionsOnSave": { 26 | "source.fixAll.eslint": true 27 | }, 28 | "i18n-ally.localesPaths": [ 29 | "locales" 30 | ], 31 | "i18n-ally.keystyle": "nested" 32 | } 33 | -------------------------------------------------------------------------------- /src/pages/about.vue: -------------------------------------------------------------------------------- 1 | 2 | meta: 3 | public: true 4 | 5 | 6 | 21 | 22 | 38 | -------------------------------------------------------------------------------- /vue-i18n-extract.config.js: -------------------------------------------------------------------------------- 1 | module.exports = { 2 | /* 3 | Required (Glob files pattern) 4 | Do not include the entire src directory because vue-i18n-extract doesn't have a way to exclude directories 5 | */ 6 | vueFiles: './src/{pages,layouts,components}/**/*.vue', 7 | languageFiles: './locales/*.json', 8 | output: false, 9 | /* With this option activated, the new found translations are added to the lang files, otherwise just a report is displayed in the console */ 10 | add: true, 11 | /* If this is true, the unused translations will be removed, however, we found that this feature activated doesn't work well when the add feature is also activated, therefore, they haave opposite values */ 12 | remove: false, 13 | ci: false, 14 | /* 15 | The separator is set to "_", because we are using the original language as the translations keys, 16 | therefore a code like $t("Hello. World!") would be translated into: 17 | { 18 | "Hello": { 19 | " World!": "" 20 | } 21 | } 22 | 23 | // Instead we want: 24 | { 25 | "Hello. World!": "" 26 | } 27 | */ 28 | separator: '_', 29 | } 30 | -------------------------------------------------------------------------------- /src/pages/[...error].vue: -------------------------------------------------------------------------------- 1 | 2 | meta: 3 | public: true 4 | 5 | 6 | 25 | 26 | 41 | -------------------------------------------------------------------------------- /src/pages/home.vue: -------------------------------------------------------------------------------- 1 | 2 | meta: 3 | layout: main 4 | transition: slide-fade 5 | 6 | 7 | 24 | 25 | 44 | -------------------------------------------------------------------------------- /src/components/HelloWorld.vue: -------------------------------------------------------------------------------- 1 | 12 | 13 | 36 | 37 | 54 | -------------------------------------------------------------------------------- /vite.config.ts: -------------------------------------------------------------------------------- 1 | import { defineConfig } from 'vite' 2 | import vue from '@vitejs/plugin-vue' 3 | import path from 'path' 4 | import Pages from 'vite-plugin-pages' 5 | import Layouts from 'vite-plugin-vue-layouts' 6 | import Icons from 'unplugin-icons/vite' 7 | import IconsResolver from 'unplugin-icons/resolver' 8 | import Components from 'unplugin-vue-components/vite' 9 | 10 | // https://vitejs.dev/config/ 11 | export default defineConfig({ 12 | plugins: [ 13 | vue(), 14 | Pages(), 15 | Layouts(), 16 | // This plugin allows to autoimport vue components 17 | Components({ 18 | /** 19 | * The icons resolver finds icons components from 'unplugin-icons' using this convenction: 20 | * {prefix}-{collection}-{icon} e.g. 21 | */ 22 | resolvers: [IconsResolver()], 23 | }), 24 | /** 25 | * This plugin allows to use all icons from Iconify as vue components 26 | * See: https://github.com/antfu/unplugin-icons 27 | */ 28 | Icons({ 29 | // This setting will autoinstall the iconify iconset when it's used in the code, e.g, @iconify-json/mdi or @iconify-json/fe 30 | autoInstall: true, 31 | }), 32 | ], 33 | resolve: { 34 | alias: { 35 | '@/': `${path.resolve(__dirname, './src')}/`, 36 | }, 37 | }, 38 | }) 39 | -------------------------------------------------------------------------------- /src/components/loading/WithLoadingPromise.vue: -------------------------------------------------------------------------------- 1 | 37 | 38 | 48 | -------------------------------------------------------------------------------- /src/auth/types.ts: -------------------------------------------------------------------------------- 1 | import { AxiosInstance } from 'axios' 2 | import { RouteLocationRaw, Router, RouteRecordName } from 'vue-router' 3 | 4 | export interface User { 5 | id?: string 6 | firstName: string 7 | lastName: string 8 | email?: string 9 | } 10 | 11 | export const ANONYMOUS_USER: Readonly = Object.freeze({ 12 | id: undefined, 13 | firstName: 'Anonymous', 14 | lastName: '', 15 | }) 16 | 17 | export interface AuthPlugin { 18 | readonly user: User 19 | readonly isAuthenticated: boolean 20 | readonly userFullName: string 21 | readonly accessToken?: string 22 | readonly login: () => Promise 23 | readonly logout: () => Promise 24 | } 25 | 26 | export interface AuthAxiosConfig { 27 | instance: AxiosInstance 28 | autoAddAuthorizationHeader: boolean 29 | authorizationHeaderPrefix?: string 30 | } 31 | 32 | export interface RequiredAuthOptions { 33 | router: Router 34 | loginRouteName: RouteRecordName 35 | loginRedirectRoute: RouteLocationRaw 36 | logoutRedirectRoute: RouteLocationRaw 37 | autoConfigureNavigationGuards: boolean 38 | axios?: AuthAxiosConfig 39 | } 40 | 41 | /* 42 | * Make all optional but router 43 | * See: https://www.typescriptlang.org/docs/handbook/utility-types.html#partialtype 44 | * See: https://stackoverflow.com/a/51507473/4873750 45 | */ 46 | export interface AuthOptions extends Omit, 'router'> { 47 | router: Router 48 | } 49 | -------------------------------------------------------------------------------- /.eslintrc.js: -------------------------------------------------------------------------------- 1 | module.exports = { 2 | env: { 3 | browser: true, 4 | es2021: true, 5 | node: true, 6 | }, 7 | // Avoids the eslint: no-undef in " 28 | 29 | 44 | -------------------------------------------------------------------------------- /src/plugins/i18n.ts: -------------------------------------------------------------------------------- 1 | import { createI18n } from 'vue-i18n' 2 | 3 | export const DEFAULT_LANGUAGE = 'en' 4 | export const BROWSER_LANGUAGE = navigator?.language?.split('-')[0] 5 | 6 | function getMessages() { 7 | // eslint-disable-next-line 8 | const messages: any = {} 9 | // See: https://vitejs.dev/guide/features.html#glob-import 10 | const localeFiles = import.meta.globEager('../../locales/*.json') 11 | for (const path in localeFiles) { 12 | // E.g: ../../locales/de.json 13 | const pathParts = path.split('/') 14 | // E.g: de.json -> de 15 | const locale = pathParts[pathParts.length - 1].slice(0, -5) 16 | if (locale === DEFAULT_LANGUAGE) { 17 | // For the default language the keys are the same as the value 18 | // eslint-disable-next-line 19 | const defaultLangMessages: any = {} 20 | for (const key in localeFiles[path].default) { 21 | defaultLangMessages[key] = key 22 | } 23 | messages[locale] = defaultLangMessages 24 | } else { 25 | // E.g: "de" => { "Hello": "Hallo" } 26 | messages[locale] = localeFiles[path].default 27 | } 28 | } 29 | console.log(messages) 30 | return messages 31 | } 32 | 33 | const messages = getMessages() 34 | const i18n = createI18n({ 35 | locale: Object.keys(messages).includes(BROWSER_LANGUAGE) ? BROWSER_LANGUAGE : DEFAULT_LANGUAGE, 36 | fallbackLocale: DEFAULT_LANGUAGE, 37 | legacy: true, // Enables $t(), $tc(), etc in templates 38 | messages, 39 | }) 40 | 41 | export default i18n 42 | -------------------------------------------------------------------------------- /src/pages/index.vue: -------------------------------------------------------------------------------- 1 | 2 | meta: 3 | public: true 4 | 5 | 6 | 17 | 18 | 48 | -------------------------------------------------------------------------------- /src/components/Toasts.vue: -------------------------------------------------------------------------------- 1 | 25 | 26 | 53 | -------------------------------------------------------------------------------- /src/composables/useToasts.ts: -------------------------------------------------------------------------------- 1 | import { ref, VNode } from 'vue' 2 | import { useI18n } from 'vue-i18n' 3 | 4 | const DEFAULT_TIMEOUT = 5000 5 | 6 | type BSVariant = 'primary' | 'secondary' | 'danger' | 'warning' | 'success' | 'info' 7 | type DefaultVariants = 'danger' | 'success' 8 | export interface ToastOptions { 9 | id?: string 10 | text?: string 11 | variant?: BSVariant 12 | timeout?: number 13 | customHTML?: string 14 | customVNode?: VNode 15 | } 16 | export interface Toast extends ToastOptions { 17 | // These are optional in the options because we can provide default values from them, but when adding the Toast these values should be set 18 | id: string 19 | variant: BSVariant 20 | timeout: number 21 | } 22 | 23 | const toasts = ref([]) 24 | 25 | export function addToast({ 26 | text, 27 | variant = 'primary', 28 | timeout = DEFAULT_TIMEOUT, 29 | customHTML, 30 | customVNode, 31 | }: ToastOptions) { 32 | pushToast({ id: `toast-${Date.now()}`, text, variant, timeout, customHTML, customVNode }) 33 | } 34 | 35 | function pushToast(toast: Toast) { 36 | toasts.value = [...toasts.value, toast] 37 | } 38 | 39 | export default function useToasts() { 40 | const { t } = useI18n() 41 | 42 | function addDefaultToast(variant: DefaultVariants) { 43 | let text = '' 44 | switch (variant) { 45 | case 'success': 46 | text = t('Action successful') 47 | break 48 | case 'danger': 49 | text = t('An unexpected error happened') 50 | break 51 | } 52 | pushToast({ 53 | id: `toast-${Date.now()}`, 54 | text, 55 | variant, 56 | timeout: DEFAULT_TIMEOUT, 57 | }) 58 | } 59 | 60 | function clearToast(id: string) { 61 | toasts.value = toasts.value.filter((t) => t.id !== id) 62 | } 63 | 64 | return { toasts, addToast, clearToast, addDefaultToast } 65 | } 66 | -------------------------------------------------------------------------------- /package.json: -------------------------------------------------------------------------------- 1 | { 2 | "name": "vuelix", 3 | "version": "1.0.0", 4 | "scripts": { 5 | "dev": "vite", 6 | "build": "npm run gen-api && vue-tsc --noEmit && vite build", 7 | "serve": "vite preview", 8 | "type-check": "vue-tsc --noEmit", 9 | "eslint": "npx eslint --ignore-path .prettierignore --ext .ts,.js,.vue .", 10 | "eslint-fix": "npx eslint --ignore-path .prettierignore --ext .ts,.js,.vue --fix .", 11 | "prettier": "npx prettier --write .", 12 | "prettier-check": "npx prettier --check .", 13 | "vue-i18n-extract": "npx vue-i18n-extract", 14 | "vue-i18n-extract-remove": "npx vue-i18n-extract --remove", 15 | "gen-api": "rm -rf src/api-client && npx @openapitools/openapi-generator-cli generate -i ./spec/schema.yml -g typescript-axios -o src/api-client --additional-properties withSeparateModelsAndApi=true,supportsES6=true,useSingleRequestParameter=true,apiPackage=api,modelPackage=models" 16 | }, 17 | "dependencies": { 18 | "axios": "^0.24.0", 19 | "bootstrap": "^5.1.3", 20 | "vue": "^3.2.16", 21 | "vue-i18n": "^9.2.0-beta.22", 22 | "vue-router": "^4.0.12" 23 | }, 24 | "devDependencies": { 25 | "@iconify-json/mdi": "^1.0.10", 26 | "@openapitools/openapi-generator-cli": "^2.4.18", 27 | "@types/node": "^16.11.9", 28 | "@typescript-eslint/eslint-plugin": "^5.4.0", 29 | "@typescript-eslint/parser": "^5.4.0", 30 | "@vitejs/plugin-vue": "^1.9.3", 31 | "eslint": "^8.3.0", 32 | "eslint-config-prettier": "^8.3.0", 33 | "eslint-plugin-prettier": "^4.0.0", 34 | "eslint-plugin-vue": "^8.1.1", 35 | "prettier": "^2.4.1", 36 | "sass": "^1.43.4", 37 | "typescript": "^4.4.3", 38 | "unplugin-icons": "^0.12.18", 39 | "unplugin-vue-components": "^0.17.2", 40 | "vite": "^2.6.4", 41 | "vite-plugin-pages": "^0.19.0-beta.3", 42 | "vite-plugin-vue-layouts": "^0.5.0", 43 | "vue-eslint-parser": "^8.0.1", 44 | "vue-i18n-extract": "^2.0.4", 45 | "vue-tsc": "^0.3.0" 46 | } 47 | } 48 | -------------------------------------------------------------------------------- /src/auth/plugin.ts: -------------------------------------------------------------------------------- 1 | import { App, computed, reactive, readonly, ref } from 'vue' 2 | import { setupDevtools } from './devtools' 3 | import { configureAuthorizationHeaderInterceptor } from './interceptors' 4 | import { configureNavigationGuards } from './navigationGuards' 5 | import { ANONYMOUS_USER, AuthOptions, AuthPlugin, RequiredAuthOptions, User } from './types' 6 | 7 | export let authInstance: AuthPlugin | undefined = undefined 8 | 9 | function setupAuthPlugin(options: RequiredAuthOptions): AuthPlugin { 10 | const router = options.router 11 | const isAuthenticated = ref(false) 12 | const accessToken = ref() 13 | const user = ref({ ...ANONYMOUS_USER }) 14 | const userFullName = computed(() => { 15 | let fullname = user.value.firstName 16 | if (user.value.lastName) { 17 | fullname += ` ${user.value.lastName}` 18 | } 19 | return fullname 20 | }) 21 | 22 | async function login() { 23 | // TODO: Implement login logic using your Auth Provider, E.g. Auth0 24 | const authenticatedUser = { 25 | id: '0000', 26 | firstName: 'John', 27 | lastName: 'Doe', 28 | email: 'johndoe@email.com', 29 | } 30 | user.value = authenticatedUser 31 | isAuthenticated.value = true 32 | accessToken.value = '12345' 33 | router.push(router.currentRoute.value.query.redirectTo?.toString() || options.loginRedirectRoute) 34 | } 35 | 36 | async function logout() { 37 | user.value = { ...ANONYMOUS_USER } 38 | isAuthenticated.value = false 39 | accessToken.value = undefined 40 | router.push(options.logoutRedirectRoute) 41 | } 42 | 43 | /* 44 | * "reactive" unwraps 'ref's, therefore using the .value is not required. 45 | * E.g: from "auth.isAuthenticated.value" to "auth.isAuthenticated" 46 | * but when using destructuring like: { isAuthenticated } = useAuth() the reactivity over isAuthenticated would be lost 47 | * this is not recommended but in such case use toRefs: { isAuthenticated } = toRefs(useAuth()) 48 | * See: https://v3.vuejs.org/guide/reactivity-fundamentals.html#ref-unwrapping 49 | * And: https://v3.vuejs.org/guide/reactivity-fundamentals.html#destructuring-reactive-state 50 | */ 51 | const unWrappedRefs = reactive({ 52 | isAuthenticated, 53 | accessToken, 54 | user, 55 | userFullName, 56 | login, 57 | logout, 58 | }) 59 | 60 | return readonly(unWrappedRefs) 61 | } 62 | 63 | const defaultOptions = { 64 | loginRedirectRoute: '/', 65 | logoutRedirectRoute: '/', 66 | loginRouteName: 'login', 67 | autoConfigureNavigationGuards: true, 68 | } 69 | export function createAuth(appOptions: AuthOptions) { 70 | // Fill default values to options that were not received 71 | const options: RequiredAuthOptions = { ...defaultOptions, ...appOptions } 72 | 73 | return { 74 | install: (app: App): void => { 75 | authInstance = setupAuthPlugin(options) 76 | app.config.globalProperties.$auth = authInstance 77 | 78 | if (options.autoConfigureNavigationGuards) { 79 | configureNavigationGuards(options.router, options) 80 | } 81 | 82 | if (options.axios?.autoAddAuthorizationHeader) { 83 | configureAuthorizationHeaderInterceptor(options.axios.instance, options.axios.authorizationHeaderPrefix) 84 | } 85 | 86 | if (import.meta.env.DEV) { 87 | // @ts-expect-error: until it gets fixed in devtools 88 | setupDevtools(app, authInstance) 89 | } 90 | }, 91 | } 92 | } 93 | -------------------------------------------------------------------------------- /spec/schema.yml: -------------------------------------------------------------------------------- 1 | openapi: 3.0.1 2 | info: 3 | title: Swagger Petstore 4 | description: 'This is a sample server Petstore server. You can find out more about Swagger 5 | at [http://swagger.io](http://swagger.io) or on [irc.freenode.net, #swagger](http://swagger.io/irc/). For 6 | this sample, you can use the api key `special-key` to test the authorization filters.' 7 | termsOfService: http://swagger.io/terms/ 8 | contact: 9 | email: apiteam@swagger.io 10 | license: 11 | name: Apache 2.0 12 | url: http://www.apache.org/licenses/LICENSE-2.0.html 13 | version: 1.0.0 14 | externalDocs: 15 | description: Find out more about Swagger 16 | url: http://swagger.io 17 | servers: 18 | - url: https://petstore.swagger.io/v2 19 | - url: http://petstore.swagger.io/v2 20 | tags: 21 | - name: pet 22 | description: Everything about your Pets 23 | externalDocs: 24 | description: Find out more 25 | url: http://swagger.io 26 | paths: 27 | /pet/findByStatus: 28 | get: 29 | tags: 30 | - pet 31 | summary: Finds Pets by status 32 | description: Multiple status values can be provided with comma separated strings 33 | operationId: findPetsByStatus 34 | parameters: 35 | - name: status 36 | in: query 37 | description: Status values that need to be considered for filter 38 | required: true 39 | style: form 40 | explode: true 41 | schema: 42 | type: array 43 | items: 44 | type: string 45 | default: available 46 | enum: 47 | - available 48 | - pending 49 | - sold 50 | responses: 51 | 200: 52 | description: successful operation 53 | content: 54 | application/xml: 55 | schema: 56 | type: array 57 | items: 58 | $ref: '#/components/schemas/Pet' 59 | application/json: 60 | schema: 61 | type: array 62 | items: 63 | $ref: '#/components/schemas/Pet' 64 | 400: 65 | description: Invalid status value 66 | content: {} 67 | security: 68 | - petstore_auth: 69 | - write:pets 70 | - read:pets 71 | /pet/{petId}: 72 | get: 73 | tags: 74 | - pet 75 | summary: Find pet by ID 76 | description: Returns a single pet 77 | operationId: getPetById 78 | parameters: 79 | - name: petId 80 | in: path 81 | description: ID of pet to return 82 | required: true 83 | schema: 84 | type: integer 85 | format: int64 86 | responses: 87 | 200: 88 | description: successful operation 89 | content: 90 | application/xml: 91 | schema: 92 | $ref: '#/components/schemas/Pet' 93 | application/json: 94 | schema: 95 | $ref: '#/components/schemas/Pet' 96 | 400: 97 | description: Invalid ID supplied 98 | content: {} 99 | 404: 100 | description: Pet not found 101 | content: {} 102 | security: 103 | - api_key: [] 104 | post: 105 | tags: 106 | - pet 107 | summary: Updates a pet in the store with form data 108 | operationId: updatePetWithForm 109 | parameters: 110 | - name: petId 111 | in: path 112 | description: ID of pet that needs to be updated 113 | required: true 114 | schema: 115 | type: integer 116 | format: int64 117 | requestBody: 118 | content: 119 | application/x-www-form-urlencoded: 120 | schema: 121 | properties: 122 | name: 123 | type: string 124 | description: Updated name of the pet 125 | status: 126 | type: string 127 | description: Updated status of the pet 128 | responses: 129 | 405: 130 | description: Invalid input 131 | content: {} 132 | security: 133 | - petstore_auth: 134 | - write:pets 135 | - read:pets 136 | delete: 137 | tags: 138 | - pet 139 | summary: Deletes a pet 140 | operationId: deletePet 141 | parameters: 142 | - name: api_key 143 | in: header 144 | schema: 145 | type: string 146 | - name: petId 147 | in: path 148 | description: Pet id to delete 149 | required: true 150 | schema: 151 | type: integer 152 | format: int64 153 | responses: 154 | 400: 155 | description: Invalid ID supplied 156 | content: {} 157 | 404: 158 | description: Pet not found 159 | content: {} 160 | security: 161 | - petstore_auth: 162 | - write:pets 163 | - read:pets 164 | components: 165 | schemas: 166 | Category: 167 | type: object 168 | properties: 169 | id: 170 | type: integer 171 | format: int64 172 | name: 173 | type: string 174 | xml: 175 | name: Category 176 | Tag: 177 | type: object 178 | properties: 179 | id: 180 | type: integer 181 | format: int64 182 | name: 183 | type: string 184 | xml: 185 | name: Tag 186 | Pet: 187 | required: 188 | - name 189 | - photoUrls 190 | type: object 191 | properties: 192 | id: 193 | type: integer 194 | format: int64 195 | category: 196 | $ref: '#/components/schemas/Category' 197 | name: 198 | type: string 199 | example: doggie 200 | photoUrls: 201 | type: array 202 | xml: 203 | name: photoUrl 204 | wrapped: true 205 | items: 206 | type: string 207 | tags: 208 | type: array 209 | xml: 210 | name: tag 211 | wrapped: true 212 | items: 213 | $ref: '#/components/schemas/Tag' 214 | status: 215 | type: string 216 | description: pet status in the store 217 | enum: 218 | - available 219 | - pending 220 | - sold 221 | xml: 222 | name: Pet 223 | securitySchemes: 224 | petstore_auth: 225 | type: oauth2 226 | flows: 227 | implicit: 228 | authorizationUrl: http://petstore.swagger.io/oauth/dialog 229 | scopes: 230 | write:pets: modify pets in your account 231 | read:pets: read your pets 232 | api_key: 233 | type: apiKey 234 | name: api_key 235 | in: header 236 | -------------------------------------------------------------------------------- /src/pages/design.vue: -------------------------------------------------------------------------------- 1 | 2 | meta: 3 | layout: main 4 | transition: slide-fade 5 | public: true 6 | 7 | 8 | 29 | 30 | 251 | -------------------------------------------------------------------------------- /README.md: -------------------------------------------------------------------------------- 1 | # Vuelix 2 | 3 | Vuelix is a Vue 3 + Vite starter template to scaffold new projects really fast, with OpenAPI Client Generation and with a great developer experience. 4 | 5 | ## Table of contents 6 | 7 | - [Setup](#setup) 8 | - [Build](#build) 9 | - [Features](#features) 10 | - [🚀 Vue 3 + Vite 2](#-vue-3--vite-2) 11 | - [🦾 TypeScript and SCSS](#-typescript-and-scss) 12 | - [🗂 File system routing](#-file-system-routing) 13 | - [📑 Layouts system](#-layouts-system) 14 | - [🔗 Path Aliasing](#-path-aliasing) 15 | - [😃 Universal Icons Framework](#-universal-icons-framework) 16 | - [✨ Routes Transitions](#-routes-transitions) 17 | - [🪄 ESLint + Prettier](#-eslint--prettier) 18 | - [🔧 OpenAPI Client Generator](#-openapi-client-generator) 19 | - [👤 Authentication System](#-authentication-system) 20 | - [The Auth Plugin](#the-auth-plugin) 21 | - [The Navigation Guards](#the-navigation-guards) 22 | - [The Axios Interceptors](#the-axios-interceptors) 23 | - [🌐 Internationalization: vue-i18n and vue-i18n-extract](#-internationalization-vue-i18n-and-vue-i18n-extract) 24 | - [Recommended IDE Setup](#recommended-ide-setup) 25 | - [Deployment](#deployment) 26 | - [Heroku](#heroku) 27 | 28 | ## Setup 29 | 30 | Install Dependencies 31 | 32 | ``` 33 | npm install 34 | ``` 35 | 36 | Generate API client 37 | 38 | ``` 39 | npm run gen-api 40 | ``` 41 | 42 | > **NOTE:** This command requires a java `jvm` to be installed, if you want to avoid asking all developers to install it 43 | > check [OpenAPI Client Generator](#-openapi-client-generator) for more info. 44 | 45 | Start the development server 46 | 47 | ``` 48 | npm run dev 49 | ``` 50 | 51 | ## Build 52 | 53 | To build the app, run 54 | 55 | ``` 56 | npm run build 57 | ``` 58 | 59 | And to preview it, after building the app run 60 | 61 | ``` 62 | npm run serve 63 | ``` 64 | 65 | ## Features 66 | 67 | ### 🚀 Vue 3 + Vite 2 68 | 69 | The version 3 of Vue with its powerful **Composition API** is available in this project. 70 | 71 | The new ` 271 | ``` 272 | 273 | See: 274 | 275 | - [OpenAPI Specification](https://swagger.io/docs/specification/about/) 276 | - [OpenAPI Generator](https://openapi-generator.tech/) 277 | - [OpenAPI Generator CLI](https://github.com/openapitools/openapi-generator-cli) 278 | - [OpenAPI typescript-axios generator](https://openapi-generator.tech/docs/generators/typescript-axios) 279 | 280 | ### 👤 Authentication System 281 | 282 | The auth system consist on three main parts: 283 | 284 | - The Plugin 285 | - The Navigation Guards 286 | - The Axios Interceptors 287 | 288 | #### The Auth Plugin 289 | 290 | The plugin is installed in Vue's `globalProperties` with the name `$auth`, it includes an `isAuthenticated` property, 291 | an `user` object, an `accessToken` plus the `login` and `logout` functions. It can be used in templates like this: 292 | 293 | ```html 294 | 295 | Authenticated as {{ $auth.user.email }} 296 | 297 | 298 | Not Authenticated 299 | ``` 300 | 301 | The `auth` instance is created using the composition API, therefore we can alternatively retrieve it outside of 302 | components with the `useAuth` function: 303 | 304 | ```ts 305 | import { useAuth } from './useAuth' 306 | 307 | const auth = useAuth() 308 | if (auth.isAuthenticated) { 309 | console.log(auth.userFullName) 310 | } 311 | ``` 312 | 313 | ```html 314 | 323 | ``` 324 | 325 | Aditionally, the auth plugin can be inspected in the **Vue's Devtools panel** when having the extension in the browser. 326 | The plugin's values are displayed when inspecting any component. 327 | 328 | #### The Navigation Guards 329 | 330 | The navigation guards protects pages from non-authenticated users and redirect them to the login page, 331 | by default **all** pages but the `login` page are protected. 332 | 333 | In order to make a page available for non-authenticated users, a route meta boolean called `public` needs to be 334 | configured in the page. E.g: 335 | 336 | ```vue 337 | 338 | 339 | meta: 340 | public: true 341 | 342 | ``` 343 | 344 | The navigation guards can be disabled by changing the `autoConfigureNavigationGuards` when configuring the auth system: 345 | 346 | ```ts 347 | // main.ts 348 | import { createApp } from 'vue' 349 | import { createAuth } from './auth' 350 | import App from './App.vue' 351 | import router from './router' 352 | 353 | const auth = createAuth({ 354 | router, 355 | loginRouteName: 'login', 356 | autoConfigureNavigationGuards: false, 357 | }) 358 | 359 | const app = createApp(App) 360 | app.use(router) 361 | app.use(auth) 362 | ``` 363 | 364 | #### The Axios Interceptors 365 | 366 | The axios interceptors helps appending auth information to requests and responses of APIs. 367 | 368 | The main interceptor adds the `Authorization` header with a value of `Bearer the-token-value` to all authenticated requests. 369 | 370 | This can be configured and disabled in the `createAuth` options: 371 | 372 | ```ts 373 | // api/axios.ts 374 | import axios from 'axios' 375 | 376 | const axiosInstance = axios.create() 377 | export default axiosInstance 378 | ``` 379 | 380 | ```ts 381 | // main.ts 382 | import { createApp } from 'vue' 383 | import { createAuth } from './auth' 384 | import App from './App.vue' 385 | import router from './router' 386 | import axiosInstance from './api/axios' 387 | 388 | const auth = createAuth({ 389 | router, 390 | axios: { 391 | instance: axiosInstance, 392 | autoAddAuthorizationHeader: true, // default: false 393 | authorizationHeaderPrefix: 'Token', // default: 'Bearer' 394 | }, 395 | }) 396 | 397 | const app = createApp(App) 398 | app.use(router) 399 | app.use(auth) 400 | ``` 401 | 402 | See: 403 | 404 | - [Auth System](./src/auth) 405 | - [Vue Router - Navigation Guards](https://next.router.vuejs.org/guide/advanced/navigation-guards.html) 406 | - [Axios - Interceptors](https://github.com/axios/axios#interceptors) 407 | - [Vue Devtools - Plugin Registration](https://devtools.vuejs.org/plugin/plugins-guide.html#registering-your-plugin) 408 | 409 | ### 🌐 Internationalization: vue-i18n and vue-i18n-extract 410 | 411 | The `vue-i18n` package is used as the internationalization system. 412 | 413 | All translation files located in the `locales` dir are loaded automatically with the corresponding language code obtained from the file name, e.g. `locales/es.json` -> lang code: `es`. 414 | 415 | **How to use it?** 416 | 417 | Put the texts in the original language inside the function of vue-i18n, for example: 418 | 419 | ```html 420 | 421 |

{{ $t('Hello World') }} {{ $t("Hello, how are you?") }} {{ $t(`Hey. I'm watching you!`) }}

422 | 423 | 425 | 426 | 427 | 428 | 429 | 430 | // In TS: 431 | 437 | ``` 438 | 439 | You may have noticed that we don't use translations keys like: `greetings.hello`, the reason is that defining keys is a troublesome task, and keys doesn't always show what we want to display, take this translation file for example: 440 | 441 | ```js 442 | // es.json 443 | 444 | { 445 | "greetings": { 446 | "hello": "Hola, ¿cómo estás?." 447 | } 448 | } 449 | ``` 450 | 451 | And the corresponding translation usage: 452 | 453 | ```js 454 | // Component.vue 455 | 456 | t('greetings.hello') 457 | ``` 458 | 459 | By just looking at the translation key, we won't know what the original text was, now look a this example: 460 | 461 | ```js 462 | // es.json 463 | 464 | { 465 | "Hello, how are you?": "Hola, ¿cómo estás?." 466 | } 467 | ``` 468 | 469 | ```js 470 | // Component.vue 471 | 472 | $t('Hello, how are you?') 473 | ``` 474 | 475 | Better right?, we can directly see the original text, and it's much simpler to translate, we also won't need to define keys because **the original text is the key!**. 476 | 477 | **Browser language detection** 478 | 479 | The default language would match the language of the browser, 480 | in case the language is not supported by the application, the fallback language `en` would be activated. 481 | 482 | **Vue i18n extract** 483 | 484 | Manually extracting the texts from vue or js,ts files is a complex task, we are often lazy to do so or we forget to add them, therefore we lose the sync between the translations files and the source code, that's why we use `vue-i18n-extract`, a handy tool that runs static analysis of the source code files and extracts the translation texts from the source code and add them to the translations files like `es.json`, `en.json`, `de.json`, etc. It no only adds the missing keys but also with a command we can remove the no longer used translations. 485 | 486 | To extract the keys/original text into the translations files, run: 487 | 488 | ``` 489 | npm run vue-i18n-extract 490 | ``` 491 | 492 | This executes the command located in `package.json`, which will search for the keys in the vue files given, compare it with the files inside the lang folder and if it finds new words, it will add them. 493 | 494 | This script uses the [vue-i18n-extract.config.js](./vue-i18n-extract.config.js) file for its configuration. This file is located in the root of the project. 495 | 496 | **Adding a new language:** 497 | 498 | To add a new language, for instance the German language, just create its file inside the `locales` folder using its language code, example: `./locales/de.json`. Then run `npm run vue-i18n-extract` to populate the translation keys into that file. 499 | 500 | > _IMPORTANT_: When creating the file, make it a valid JSON file, then at least it must has `{}`, otherwise the extraction would fail. 501 | 502 | Example: 503 | 504 | ```js 505 | // locales/es.json 506 | 507 | { 508 | } 509 | ``` 510 | 511 | The file would be loaded automatically by `vite`, a vite restart may be needed. 512 | 513 | **Removing unused translations** 514 | 515 | In case you want to remove the keys that are in the translation files but are not being used in the vue files, you can run: 516 | 517 | ``` 518 | npm run vue-i18n-extract-remove 519 | ``` 520 | 521 | See: 522 | 523 | - [Vue i18n](https://vue-i18n.intlify.dev/) 524 | - [Vue i18n extract](https://github.com/pixari/vue-i18n-extract) 525 | - [i18n plugin](./src/plugins/i18n.ts) 526 | 527 | ## Recommended IDE Setup 528 | 529 | - [VSCode](https://code.visualstudio.com/) + [Volar](https://marketplace.visualstudio.com/items?itemName=johnsoncodehk.volar) + [Prettier](https://marketplace.visualstudio.com/items?itemName=esbenp.prettier-vscode) + [Eslint](https://marketplace.visualstudio.com/items?itemName=dbaeumer.vscode-eslint) 530 | 531 | ## Deployment 532 | 533 | ### Heroku 534 | 535 | In Heroku create the app, then configure the following buildpacks in the same order: 536 | 537 | - heroku/jvm 538 | - heroku/nodejs 539 | - heroku-community/static 540 | 541 | Config the Heroku remote: 542 | 543 | ``` 544 | heroku login 545 | heroku git:remote -a 546 | ``` 547 | 548 | Finally, push the changes: 549 | 550 | ``` 551 | git push heroku main 552 | ``` 553 | --------------------------------------------------------------------------------