diff --git a/.amber.yml b/.amber.yml new file mode 100644 index 0000000..6eff810 --- /dev/null +++ b/.amber.yml @@ -0,0 +1,4 @@ +type: app +database: pg +language: slang +model: granite diff --git a/.gitignore b/.gitignore new file mode 100644 index 0000000..0113c5b --- /dev/null +++ b/.gitignore @@ -0,0 +1,15 @@ +/doc/ +/lib/ +/.crystal/ +/.shards/ +/.vscode/ +/tmp/ +.env +.encryption_key +production.yml +.DS_Store +/bin/ +/node_modules +/public/dist +shard.lock +package-lock.json diff --git a/Dockerfile b/Dockerfile new file mode 100644 index 0000000..b0ea544 --- /dev/null +++ b/Dockerfile @@ -0,0 +1,12 @@ +FROM amberframework/amber:v0.9.0 + +WORKDIR /app + +COPY shard.* /app/ +RUN shards install + +COPY . /app + +RUN rm -rf /app/node_modules + +CMD amber watch diff --git a/README.md b/README.md index 34d538d..2ba5858 100644 --- a/README.md +++ b/README.md @@ -1,4 +1,41 @@ # carnetdejeu -Gérer sa collection de jeux vidéos, consoles et figurines. -Et plus si affinités. \ No newline at end of file +Carnet de jeu (CDJ) est une application web permettant de gérer sa collection de jeux vidéos, consoles et figurines. + +Site officiel : [https://carnetdejeu.fr/](https://carnetdejeu.fr/) + +# Motivation + +L'idée principale de ce projet est d'avoir - enfin - un site web francophone concernant sa collection de jeux vidéos. + +Mais également d'avoir une alternative OpenSource au très connu Backloggery.com. + +# Pré-requis + + * Crystal 0.26.0 + * Amber 0.9.0 + * shards 0.8.1 + +# Installation des dépendances Crystal + +```bash +shards install +``` + +# Aperçu de l'application + +``` +amber w +``` + +# Fabriqué avec + + * [Amber](https://amberframework.org/) - framework pour créer des applications simples, rapides et agréables + +# Contributeurs + + * Olivier DOSSMANN - création initiale - [blankoworld](https://github.com/blankoworld/) + +# Licence + +Ce projet est délivré sous les termes de la licence EUPL 1.2. Pour plus d’informations, veuillez lire [la licence EUPL 1.2](https://joinup.ec.europa.eu/collection/eupl/eupl-text-11-12). diff --git a/config/application.cr b/config/application.cr new file mode 100644 index 0000000..429e84c --- /dev/null +++ b/config/application.cr @@ -0,0 +1,131 @@ +require "./initializers/**" + +require "amber" + +require "../src/controllers/application_controller" +require "../src/controllers/**" + +# About Application.cr File +# +# This is Amber application main entry point. This file is responsible for loading +# initializers, classes, and all application related code in order to have +# Amber::Server boot up. +# +# > We recommend to not modify the order of the require since the order will +# affect the behavior of the application. +# +# With `Amber::Server.configure` block you can redefine the Server configuration +# settings and use ENVIRONMENT variables and/or values evaluated at runtime. +# +# > Important! Yaml configurations are first class citizen and are loaded first before +# this file, we recommend to use yaml configurations before changing any settings here. +# Any uncommented setting here will override the YAML with the value set here. + +Amber::Server.configure do |settings| + # Use your environment variables settings here. + # + # Name: A name that identifies this application. This is not internally + # used by the framework. + # + # settings.name = "Cdj Carnetdejeu web application." + # + # + # Colorize Logging: specifies whether or not to use ANSI color codes + # when logging information, display the time and/or to display the severity level. + # Defaults to true. + # + # Log Level defines the verbosity of the Amber logger. This option defaults to + # debug for all environments. The available log levels are: debug, info, warn, + # error, fatal, and unknown. + # + # settings.logging.colorize = true + # settings.logging.severity = "debug" + # settings.logging.filter = %w(password confirm_password) + # settings.logging.skip = %w() + # settings.logging.context = %w(request headers cookies session params) + # + # + # Secret Key Base: is used for specifying a key which allows sessions + # for the application to be verified against a known secure key to + # prevent tampering. Applications get Amber.secret_key + # initialized to a random key present in `ENV["AMBER_SECRET_KEY"]` or + # `.amber_secret_key` in this order. + # + # settings.secret_key_base= FEHWm3Fpm7vrPejFPM9x-3PLkj7C_fho6N-nIaBa19g + # + # + # Host: is the application server host address or ip address. Useful for when + # deploying Amber to a PAAS and likely the assigned server IP is either + # known or unknown. Defaults to an environment variable HOST + # + # settings.host = ENV["HOST"] if ENV["HOST"]? + # + # + # Port Reuse: Amber supports clustering mode which allows to spin + # multiple app instances per core. This setting allows to bind the different + # instances to the same port. Default this setting to true if the number or process + # is greater than 1. + # + # > Read more about Linux PORT REUSE https://lwn.net/Articles/542629/ + # + # settings.port_reuse = true + # + # + # Process Count: This will enable Amber to be used in cluster mode, + # spinning an instance for each number of process specified here. + # Rule of thumb, always leave at least 1 core available for system processes/resources. + # + # settings.process_count = ENV["PROCESS_COUNT"].to_i if ENV["PROCESS_COUNT"]? + # + # + # PORT: This is the port that you're application will run on. Examples would be (80, 443, 3000, 8080) + # + settings.port = ENV["PORT"].to_i if ENV["PORT"]? + # + # + # Redis URL: Redis is an in memory key value storage. Amber utilizes redis as + # a storing option for session information. + # + # settings.redis_url = ENV["REDIS_URL"] if ENV["REDIS_URL"]? + # + # + # Database URL: This is the database connection string or data file url. + # The connection string contains the information to establish a connection to the + # database or the data file. Defaults to the database provider you chose at + # at app generation. + # + # settings.database_url = ENV["DATABASE_URL"] if ENV["DATABASE_URL"]? + # + # + # SSL Key File: The private key is a text file used initially to generate a + # Certificate Signing Request (CSR), and later to secure and verify connections + # using the certificate created per that request. The private key is used to create + # a digital signature as you might imagine from the name, the private key should be + # ``closely guarded. + # + # settings.ssl_key_file = ENV["SSL_KEY_FILE"] if ENV["SSL_KEY_FILE"]? + # + # + # SSL Cert File: This represents the signed certificate file. SSL Certificates are + # small data files that digitally bind a cryptographic key to an organization's + # details. When installed on a web server, it activates the padlock and the https + # protocol and allows secure connections from a web server to a browser. + # + # settings.ssl_cert_file = ENV["SSL_CERT_FILE"] if ENV["SSL_CERT_FILE"]? + # + # + # Session: A Hash that specifies the session storage mechanism, expiration and key to be used + # for the application. The `key` specifies the name of the cookie to be used defaults to + # "amber.session". The store can be `encrypted_cookie`, `signed_cookie` or `redis`. Expires + # when set to 0 means this is indefinitely and is expressed in seconds. + # + # settings.session = { "key" => "amber.session", "store" => "signed_cookie", "expires" => 0 } + # + # + # Logger: is the logger that Amber and other capable shards in the project will use + # instead of writing directly to STDOUT. Supply a custom logger to write to syslog, etc. + # + # settings.logger = Amber::Environment::Logger.new(File.open("cdj_carnetdejeu.log", "w")) + # + # +end diff --git a/config/environments/.production.enc b/config/environments/.production.enc new file mode 100644 index 0000000..14d3405 Binary files /dev/null and b/config/environments/.production.enc differ diff --git a/config/environments/development.yml b/config/environments/development.yml new file mode 100644 index 0000000..5a5edc1 --- /dev/null +++ b/config/environments/development.yml @@ -0,0 +1,36 @@ +secret_key_base: cie0hbJtXS-F51akAYdEeNof67tLG4q-L5bpaLqgPTE +port: 3000 +name: cdj_carnetdejeu + +logging: + severity: debug + colorize: true + filter: + - password + - confirm_password + context: + - request + - session + - headers + - cookies + - params + +host: 0.0.0.0 +port_reuse: true +process_count: 1 +# ssl_key_file: +# ssl_cert_file: +redis_url: "redis://localhost:6379" +database_url: postgres://postgres:@localhost:5432/cdj_carnetdejeu_development + +session: + key: amber.session + store: signed_cookie + expires: 0 + +smtp: + enabled: false + +secrets: + description: Store your development secrets credentials and settings here. + diff --git a/config/environments/test.yml b/config/environments/test.yml new file mode 100644 index 0000000..f90f93f --- /dev/null +++ b/config/environments/test.yml @@ -0,0 +1,36 @@ +secret_key_base: 2jEkAoKdioIl6U1W0T-Zu2U7V5HRQ3-GpeIxOeEVjms +port: 3000 +name: cdj_carnetdejeu + +logging: + severity: debug + colorize: true + filter: + - password + - confirm_password + context: + - request + - session + - headers + - cookies + - params + +host: 0.0.0.0 +port_reuse: false +process_count: 1 +# ssl_key_file: +# ssl_cert_file: +redis_url: "redis://localhost:6379" +database_url: postgres://postgres:@localhost:5432/cdj_carnetdejeu_test + +session: + key: amber.session + store: signed_cookie + expires: 0 + +smtp: + enabled: false + +secrets: + description: Store your development secrets credentials and settings here. + diff --git a/config/initializers/database.cr b/config/initializers/database.cr new file mode 100644 index 0000000..69378b7 --- /dev/null +++ b/config/initializers/database.cr @@ -0,0 +1,6 @@ +require "granite/adapter/pg" + +Granite::Adapters << Granite::Adapter::Pg.new({name: "pg", url: Amber.settings.database_url}) +Granite.settings.logger = Amber.settings.logger.dup +Granite.settings.logger.progname = "Granite" + diff --git a/config/initializers/i18n.cr b/config/initializers/i18n.cr new file mode 100644 index 0000000..1b005b2 --- /dev/null +++ b/config/initializers/i18n.cr @@ -0,0 +1,24 @@ +require "citrine-i18n" + +Citrine::I18n.configure do |settings| + # Backend storage (as supported by i18n.cr) + # settings.backend = I18n::Backend::Yaml.new + + # Default locale (defaults to "en" and "./src/locales/**/en.yml"). + # For a new default locale to be accepted, it must be found by the + # backend storage and reported in "settings.available_locales". + # settings.default_locale = "en" + + # Separator between sublevels of data (defaults to '.') + # e.g. I18n.translate("some/thing") instead of "some.thing" + # settings.default_separator = '.' + + # Returns the current exception handler. Defaults to an instance of + # I18n::ExceptionHandler. + # settings.exception_handler = ExceptionHandler.new + + # The path from where the translations should be loaded + settings.load_path += ["./src/locales"] +end + +I18n.init diff --git a/config/routes.cr b/config/routes.cr new file mode 100644 index 0000000..0c9e0e3 --- /dev/null +++ b/config/routes.cr @@ -0,0 +1,42 @@ +Amber::Server.configure do + pipeline :web do + # Plug is the method to use connect a pipe (middleware) + # A plug accepts an instance of HTTP::Handler + plug Amber::Pipe::PoweredByAmber.new + # plug Amber::Pipe::ClientIp.new(["X-Forwarded-For"]) + plug Citrine::I18n::Handler.new + plug Amber::Pipe::Error.new + plug Amber::Pipe::Logger.new + plug Amber::Pipe::Session.new + plug Amber::Pipe::Flash.new + plug Amber::Pipe::CSRF.new + end + + pipeline :api do + plug Amber::Pipe::PoweredByAmber.new + plug Amber::Pipe::Error.new + plug Amber::Pipe::Logger.new + plug Amber::Pipe::Session.new + plug Amber::Pipe::CORS.new + end + + # All static content will run these transformations + pipeline :static do + plug Amber::Pipe::PoweredByAmber.new + plug Amber::Pipe::Error.new + plug Amber::Pipe::Static.new("./public") + end + + routes :web do + get "/", HomeController, :index + end + + routes :api do + end + + routes :static do + # Each route is defined as follow + # verb resource : String, controller : Symbol, action : Symbol + get "/*", Amber::Controller::Static, :index + end +end diff --git a/config/webpack/common.js b/config/webpack/common.js new file mode 100644 index 0000000..336418e --- /dev/null +++ b/config/webpack/common.js @@ -0,0 +1,69 @@ +const webpack = require('webpack'); +const path = require('path'); +const ExtractTextPlugin = require('extract-text-webpack-plugin'); + +let config = { + entry: { + 'main.bundle.js': './src/assets/javascripts/main.js', + 'main.bundle.css': './src/assets/stylesheets/main.scss' + }, + output: { + filename: '[name]', + path: path.resolve(__dirname, '../../public/dist'), + publicPath: '/dist' + }, + resolve: { + alias: { + amber: path.resolve(__dirname, '../../lib/amber/assets/js/amber.js') + } + }, + module: { + rules: [ + { + test: /\.css$/, + exclude: /node_modules/, + use: ExtractTextPlugin.extract({ + fallback: 'style-loader', + use: 'css-loader' + }) + }, + { + test: /\.scss$/, + exclude: /node_modules/, + use: ExtractTextPlugin.extract({ + fallback: 'style-loader', + use: ['css-loader', 'sass-loader'] + }) + }, + { + test: /\.(png|svg|jpg|gif)$/, + exclude: /node_modules/, + use: [ + 'file-loader?name=/images/[name].[ext]' + ] + }, + { + test: /\.(woff|woff2|eot|ttf|otf)$/, + exclude: /node_modules/, + use: [ + 'file-loader?name=/[name].[ext]' + ] + }, + { + test: /\.js?$/, + exclude: /node_modules/, + loader: 'babel-loader', + query: { + presets: ['env'] + } + } + ] + }, + plugins: [ + new ExtractTextPlugin('main.bundle.css'), + ], + // For more info about webpack logs see: https://webpack.js.org/configuration/stats/ + stats: 'errors-only' +}; + +module.exports = config; diff --git a/config/webpack/development.js b/config/webpack/development.js new file mode 100644 index 0000000..3ec0d95 --- /dev/null +++ b/config/webpack/development.js @@ -0,0 +1,7 @@ +const webpack = require('webpack'); +const merge = require('webpack-merge'); +const common = require('./common.js'); + +module.exports = merge(common, { + devtool: 'inline-source-map' +}); diff --git a/config/webpack/production.js b/config/webpack/production.js new file mode 100644 index 0000000..4a44b47 --- /dev/null +++ b/config/webpack/production.js @@ -0,0 +1,11 @@ +const webpack = require('webpack'); +const merge = require('webpack-merge'); +const common = require('./common.js'); + +module.exports = merge(common, { + plugins: [ + new webpack.optimize.UglifyJsPlugin({ + compress: { warnings: false } + }) + ] +}); diff --git a/db/migrations/.gitkeep b/db/migrations/.gitkeep new file mode 100644 index 0000000..e69de29 diff --git a/docker-compose.yml b/docker-compose.yml new file mode 100644 index 0000000..13ff841 --- /dev/null +++ b/docker-compose.yml @@ -0,0 +1,42 @@ +version: '2' + +services: + app: + build: . + image: cdj_carnetdejeu + command: amber watch + environment: + DATABASE_URL: postgres://admin:password@db:5432/cdj_carnetdejeu_development + ports: + - 3000:3000 + links: + - db + volumes: + - .:/app + - nodes:/app/node_modules + - shards:/app/lib + + migrate: + build: . + image: cdj_carnetdejeu + command: bash -c 'while ! nc -q 1 db 5432 + + + + + + + + + + diff --git a/public/favicon.ico b/public/favicon.ico new file mode 100644 index 0000000..edb2d9c Binary files /dev/null and b/public/favicon.ico differ diff --git a/public/favicon.png b/public/favicon.png new file mode 100644 index 0000000..850d724 Binary files /dev/null and b/public/favicon.png differ diff --git a/public/robots.txt b/public/robots.txt new file mode 100644 index 0000000..f591645 --- /dev/null +++ b/public/robots.txt @@ -0,0 +1,3 @@ +# http://www.robotstxt.org +User-agent: * +Disallow: diff --git a/shard.yml b/shard.yml new file mode 100644 index 0000000..6c3eab6 --- /dev/null +++ b/shard.yml @@ -0,0 +1,47 @@ +name: cdj_carnetdejeu +version: 0.1.0 + +authors: + - Olivier DOSSMANN + +crystal: 0.26.0 + +license: UNLICENSED + +targets: + cdj_carnetdejeu: + main: src/cdj_carnetdejeu.cr + + amber: + main: lib/amber/src/amber/cli.cr + +dependencies: + amber: + github: amberframework/amber + version: 0.9.0 + #branch: master + + granite: + github: amberframework/granite + version: ~> 0.13.0 + + quartz_mailer: + github: amberframework/quartz-mailer + version: ~> 0.5.1 + + jasper_helpers: + github: amberframework/jasper-helpers + version: ~> 0.2.0 + + pg: + github: will/crystal-pg + version: ~> 0.15.0 + + citrine-i18n: + github: amberframework/citrine-i18n + version: 0.3.2 + +development_dependencies: + garnet_spec: + github: amberframework/garnet-spec + version: ~> 0.2.1 diff --git a/spec/spec_helper.cr b/spec/spec_helper.cr new file mode 100644 index 0000000..ecec995 --- /dev/null +++ b/spec/spec_helper.cr @@ -0,0 +1,16 @@ +ENV["AMBER_ENV"] ||= "test" + +require "spec" +require "micrate" +require "garnet_spec" + +require "../config/*" + +Micrate::DB.connection_url = Amber.settings.database_url + +# Automatically run migrations on the test database +Micrate::Cli.run_up + +# Disable Granite logs in tests +Granite.settings.logger = Logger.new nil + diff --git a/src/assets/fonts/.gitkeep b/src/assets/fonts/.gitkeep new file mode 100644 index 0000000..e69de29 diff --git a/src/assets/images/.gitkeep b/src/assets/images/.gitkeep new file mode 100644 index 0000000..e69de29 diff --git a/src/assets/images/logo.png b/src/assets/images/logo.png new file mode 100644 index 0000000..457789a Binary files /dev/null and b/src/assets/images/logo.png differ diff --git a/src/assets/javascripts/main.js b/src/assets/javascripts/main.js new file mode 100644 index 0000000..4b3eb94 --- /dev/null +++ b/src/assets/javascripts/main.js @@ -0,0 +1,23 @@ +import Amber from 'amber' + +if (!Date.prototype.toGranite) { + (function() { + + function pad(number) { + if (number < 10) { + return '0' + number; + } + return number; + } + + Date.prototype.toGranite = function() { + return this.getUTCFullYear() + + '-' + pad(this.getUTCMonth() + 1) + + '-' + pad(this.getUTCDate()) + + ' ' + pad(this.getUTCHours()) + + ':' + pad(this.getUTCMinutes()) + + ':' + pad(this.getUTCSeconds()) ; + }; + + }()); +} diff --git a/src/assets/stylesheets/main.scss b/src/assets/stylesheets/main.scss new file mode 100644 index 0000000..638d392 --- /dev/null +++ b/src/assets/stylesheets/main.scss @@ -0,0 +1,117 @@ +$font-stack: "Helvetica Neue", Helvetica, Arial, sans-serif; +$background-color: #f4994b; +$nav-item-color: #ffe4cd; + +/* + * Globals + */ +body { + font-family: $font-stack; + color: #555; +} + +h1, .h1, +h2, .h2, +h3, .h3, +h4, .h4, +h5, .h5, +h6, .h6 { + margin-top: 0; + font-family: $font-stack; + font-weight: normal; + color: #333; +} + +/* + * Override Bootstrap's default container. + */ +@media (min-width: 1200px) { + .container { + width: 970px; + } +} + +/* + * Masthead for nav + */ +.masthead { + background-color: $background-color; + -webkit-box-shadow: inset 0 -2px 5px rgba(0,0,0,.1); + box-shadow: inset 0 -2px 5px rgba(0,0,0,.1); +} + +/* Nav links */ +.nav-item { + position: relative; + display: inline-block; + padding: 10px; + font-weight: 500; + color: $nav-item-color; +} +.nav-item:hover, +.nav-item:focus { + color: #fff; + text-decoration: none; +} + +/* Active state gets a caret at the bottom */ +.nav .active { + color: #fff; +} +.nav .active:after { + position: absolute; + bottom: 0; + left: 50%; + width: 0; + height: 0; + margin-left: -5px; + vertical-align: middle; + content: " "; + border-right: 5px solid transparent; + border-bottom: 5px solid; + border-left: 5px solid transparent; +} + +.main { + padding-top: 20px; +} + +#logo { + background-color: $background-color; + background-image: url("../images/logo.png"); + background-size:contain; + width: 200px; + height:278px; +} + +/* Hide logo in small screens */ +@media (max-width: 767px) { + #logo { + display: none; + } +} + +.table td, .table th { + vertical-align: middle; +} + +.alert { + margin-top: 1em; + + p { + margin-top: 0; + margin-bottom: 0; + } +} + +// For Navigation pulling right +.nav-item-auth { + &.nav-item-auth-signout, &.nav-item-auth-signup { + order: 9999; + } + + &.nav-item-auth-profile, &.nav-item-auth-signin { + order: 9990; + margin-left: auto; + } +} \ No newline at end of file diff --git a/src/cdj_carnetdejeu.cr b/src/cdj_carnetdejeu.cr new file mode 100644 index 0000000..66d2b28 --- /dev/null +++ b/src/cdj_carnetdejeu.cr @@ -0,0 +1,3 @@ +require "../config/*" + +Amber::Server.start diff --git a/src/controllers/application_controller.cr b/src/controllers/application_controller.cr new file mode 100644 index 0000000..0967557 --- /dev/null +++ b/src/controllers/application_controller.cr @@ -0,0 +1,6 @@ +require "jasper_helpers" + +class ApplicationController < Amber::Controller::Base + include JasperHelpers + LAYOUT = "application.slang" +end diff --git a/src/controllers/home_controller.cr b/src/controllers/home_controller.cr new file mode 100644 index 0000000..2543e78 --- /dev/null +++ b/src/controllers/home_controller.cr @@ -0,0 +1,5 @@ +class HomeController < ApplicationController + def index + render("index.slang") + end +end diff --git a/src/locales/en.yml b/src/locales/en.yml new file mode 100644 index 0000000..28c4419 --- /dev/null +++ b/src/locales/en.yml @@ -0,0 +1,2 @@ +--- +welcome_to_amber: "Welcome to Amber Framework!" diff --git a/src/views/home/index.slang b/src/views/home/index.slang new file mode 100644 index 0000000..1dbac34 --- /dev/null +++ b/src/views/home/index.slang @@ -0,0 +1,9 @@ +.row + #logo + .col-sm-12.col-md-6 + h2 = t "welcome_to_amber" + p Thank you for trying out the Amber Framework. We are working hard to provide a super fast and reliable framework that provides all the productivity tools you are used to without sacrificing the speed. + .list-group + a.list-group-item.list-group-item-action target="_blank" href="https://docs.amberframework.org" Getting Started with Amber Framework + a.list-group-item.list-group-item-action target="_blank" href="https://github.com/veelenga/awesome-crystal" List of Awesome Crystal projects and shards + a.list-group-item.list-group-item-action target="_blank" href="https://crystalshards.xyz" What's hot in Crystal right now diff --git a/src/views/layouts/_nav.slang b/src/views/layouts/_nav.slang new file mode 100644 index 0000000..0888270 --- /dev/null +++ b/src/views/layouts/_nav.slang @@ -0,0 +1,2 @@ +- active = context.request.path == "/" ? "active" : "" +a class="nav-item #{active}" href="/" Home diff --git a/src/views/layouts/application.slang b/src/views/layouts/application.slang new file mode 100644 index 0000000..aa83d80 --- /dev/null +++ b/src/views/layouts/application.slang @@ -0,0 +1,32 @@ +doctype html +html + head + title Cdj Carnetdejeu using Amber + meta charset="utf-8" + meta http-equiv="X-UA-Compatible" content="IE=edge" + meta name="viewport" content="width=device-width, initial-scale=1" + link rel="stylesheet" href="https://stackpath.bootstrapcdn.com/bootstrap/4.1.0/css/bootstrap.min.css" + link rel="stylesheet" href="/dist/main.bundle.css" + link rel="apple-touch-icon" href="/favicon.png" + link rel="icon" href="/favicon.png" + link rel="icon" type="image/x-icon" href="/favicon.ico" + + body + .masthead + .container + nav.nav + == render(partial: "layouts/_nav.slang") + .container + .row + .col-sm + - flash.each do |key, value| + div class="alert alert-#{key}" + p = flash[key] + .row + .col-sm-12.main + == content + + script src="https://code.jquery.com/jquery-3.3.1.min.js" + script src="https://cdnjs.cloudflare.com/ajax/libs/popper.js/1.14.0/umd/popper.min.js" + script src="https://stackpath.bootstrapcdn.com/bootstrap/4.1.0/js/bootstrap.min.js" + script src="/dist/main.bundle.js"