diff --git a/.eslintrc.js b/.eslintrc.js new file mode 100644 index 00000000..b8509ec3 --- /dev/null +++ b/.eslintrc.js @@ -0,0 +1,36 @@ +module.exports = { + env: { + browser: true, + es2021: true, + 'cypress/globals': true + }, + extends: [ + 'plugin:cypress/recommended' + ], + overrides: [ + ], + parserOptions: { + ecmaVersion: 'latest' + }, + plugins: [ + 'cypress' + ], + rules: { + semi: ['error', 'always'], + 'space-before-function-paren': 'off', + 'cypress/no-assigning-return-values': 'error', + 'cypress/no-unnecessary-waiting': 'error', + 'cypress/assertion-before-screenshot': 'warn', + 'cypress/no-force': 'warn', + 'cypress/no-async-tests': 'error', + 'cypress/no-pause': 'error', + 'max-len': ['error', 80, { + ignoreTemplateLiterals: true, + ignoreRegExpLiterals: true, + ignoreComments: true + }], + 'arrow-parens': ['error', 'always'], + quotes: ['error', 'single', { allowTemplateLiterals: true }], + 'no-console': ['error'] + } +}; diff --git a/.eslintrc.json b/.eslintrc.json deleted file mode 100644 index 0f78109e..00000000 --- a/.eslintrc.json +++ /dev/null @@ -1,10 +0,0 @@ -{ - "parserOptions": { - "ecmaVersion": 6 - }, - "ignorePatterns": ["react-redux-realworld-example-app"], - "plugins": ["prettier"], - "rules": { - "prettier/prettier": "error" - } -} diff --git a/.github/workflows/test.yml b/.github/workflows/test.yml new file mode 100644 index 00000000..7914be04 --- /dev/null +++ b/.github/workflows/test.yml @@ -0,0 +1,25 @@ +name: Test + +on: + pull_request: + branches: [ next ] + +jobs: + build: + + runs-on: ubuntu-latest + + strategy: + matrix: + node-version: [20.x] + + steps: + - uses: actions/checkout@v2 + - name: Use Node.js ${{ matrix.node-version }} + uses: actions/setup-node@v1 + with: + node-version: ${{ matrix.node-version }} + - run: npm install --legacy-peer-deps + - run: | + npm run dev & + npm run test-e2e diff --git a/.gitignore b/.gitignore index 27e3b0af..9fd6c662 100644 --- a/.gitignore +++ b/.gitignore @@ -39,10 +39,21 @@ node_modules .tern-port db.sqlite3 +db.sqlite3-journal /.env package-lock.json # Temporary files *.tmp +tmp.* *.bak + +# next.js +/.next/ +/out/ +/.now/ +.now + +# typescript +*.tsbuildinfo diff --git a/.gitmodules b/.gitmodules index f30bd358..9d81ce2b 100644 --- a/.gitmodules +++ b/.gitmodules @@ -1,6 +1,3 @@ -[submodule "react-redux-realworld-example-app"] - path = react-redux-realworld-example-app - url = https://github.com/cirosantilli/react-redux-realworld-example-app [submodule "realworld"] path = realworld url = https://github.com/cirosantilli/realworld diff --git a/.husky/pre-commit b/.husky/pre-commit new file mode 100755 index 00000000..c1de5752 --- /dev/null +++ b/.husky/pre-commit @@ -0,0 +1,4 @@ +#!/bin/sh + . "$(dirname "$0")/_/husky.sh" + + npx lint-staged \ No newline at end of file diff --git a/.nowignore b/.nowignore new file mode 100644 index 00000000..cf79a4a9 --- /dev/null +++ b/.nowignore @@ -0,0 +1,2 @@ +README.md +.next \ No newline at end of file diff --git a/.prettierignore b/.prettierignore new file mode 100644 index 00000000..7d961a0d --- /dev/null +++ b/.prettierignore @@ -0,0 +1,64 @@ +## Prettier specific + +/realworld + +## Copy of .gitinore +# Needed until they implement: +# https://github.com/prettier/prettier/issues/8048 + +# Logs +logs +*.log +.DS_Store + +npm-debug.log* + +# Runtime data +pids +*.pid +*.seed + +# Directory for instrumented libs generated by jscoverage/JSCover +lib-cov + +# Coverage directory used by tools like istanbul +coverage + +# Grunt intermediate storage (http://gruntjs.com/creating-plugins#storing-task-files) +.grunt + +# node-waf configuration +.lock-wscript + +# Compiled binary addons (http://nodejs.org/api/addons.html) +build/Release + +# Dependency directory +node_modules + +# Optional npm cache directory +.npm + +# Optional REPL history +.node_repl_history + +.idea + +.tern-port + +db.sqlite3 +db.sqlite3-journal +/.env + +package-lock.json + +# Temporary files +*.tmp +tmp.* +*.bak + +# next.js +/.next/ +/out/ +/.now/ +.now diff --git a/.sequelizerc b/.sequelizerc new file mode 100644 index 00000000..d31beda0 --- /dev/null +++ b/.sequelizerc @@ -0,0 +1,5 @@ +var path = require('path') + +module.exports = { + 'config': path.resolve(path.join('front', 'config.js')), +} diff --git a/.travis.yml b/.travis.yml index 3146a8e2..26b275b0 100644 --- a/.travis.yml +++ b/.travis.yml @@ -1,5 +1,5 @@ language: node_js -node_js: "8" +node_js: '8' sudo: false before_script: npm start & sleep 5 diff --git a/DEV.adoc b/DEV.adoc new file mode 100644 index 00000000..c6000f59 --- /dev/null +++ b/DEV.adoc @@ -0,0 +1,320 @@ += Node.js + Express.js + Sequelize + SQLite/PostgreSQL + Next.js fullstack static/SSR/SSG/ISG Example Realworld App + +This is an implementation of https://github.com/gothinkster/realworld + +== Local development with SQLite + +Run the app: + +.... +npm install +npm run dev +.... + +You can now visit http://localhost:3000[] to view the website. Both API and pages are served from that single server. + +You might also want to generate some test data as mentioned at: <>. + +The SQLite database is located at `db.sqlite3`. + +The Node.js and NPM versions this was tested with are specified at link:package.json[] `engines:` entry, which is what the <> uses https://devcenter.heroku.com/articles/nodejs-support#specifying-a-node-js-version[as mentioned here]. You should of course try to use those exact versions for reproducibility. The best way to install a custom node and NPM version is to use NVM as mentioned here: https://stackoverflow.com/questions/16898001/how-to-install-a-specific-version-of-node-on-ubuntu/47376491#47376491[]. + +== Local optimized frontend + +.... +npm install +npm run build-dev +npm run start-dev +.... + +If you make any changes to the code, at least code under `/pages` for sure, you have to rebuild before they take effect in this mode, as Next.js appears to also run server-only code such as `getStaticPaths` from one of the webpack bundles. + +Running `next build` is one very important test of the code, as it builds many of the most important pages of the website, and runs checks such as TypeScript type checking. You should basically run it after every single commit that touches the frontend. + +If you look into what `npm run start-dev` does in the `package.json`, you will see the following environment variables, which are custom to this project and not defined by Next.js itself: + +* `NEXT_PUBLIC_NODE_ENV=development`: sets everything to be development by default. ++ +If this variable not given, `NODE_ENV` is used instead. ++ +Just like `NODE_ENV`, this variable affects the following aspects of the application: ++ +** if the Next.js server will run in development or production mode. From the Next.js CLI, this determination is done with `next dev` vs `next start`. But we use a custom server where both dev and prod are run from `./app`, and so we determine that from environment variables. +** if the database will be SQLite (default development DB) or PostgreSQL (default production DB) +** in browser effects, e.g. turns off Google Analytics ++ +We cannot use `NODE_ENV` here directly as we would like because and Next.js forces `process.env.NODE_ENV` to match the server's dev vs production mode. But we want a production mode server, and no Google analytics in this case. +* `NODE_ENV_NEXT_SERVER_ONLY=production`: determines is the Next.js server will run in development or production mode. ++ +This variable only affects the Next.js server dev vs prod aspect of the application, and not any other aspects such as the database used and in browser effects such as having Google Analytics or not. ++ +If given, this variable overrides all others in making that determination, including `NEXT_PUBLIC_NODE_ENV`. If not given, `NODE_ENV` is used as usual. ++ +If this variable is not given, `NEXT_PUBLIC_NODE_ENV` is given instead. + +=== Local run as identical to deployment as possible + +Here we use PostgreSQL instead of SQLite with the prebuilt static frontend. Note that optimized frontend is also used on the SQLite setup described at <>). + +For when you really need to debug some deployment stuff locally + +Setup: + +.... +sudo apt install postgresql + +# Become able to run psql command without sudo. +sudo -u postgres createuser -s "$(whoami)" +createdb "$(whoami)" + +createdb realworld_next +psql -c "CREATE ROLE realworld_next_user with login password 'a'" +psql -c 'GRANT ALL PRIVILEGES ON DATABASE realworld_next TO realworld_next_user' +echo "SECRET=$(tr -dc A-Za-z0-9 > .env +.... + +Run: + +.... +npm run build-prod +npm run start-prod +.... + +then visit the running website at: http://localhost:3000/ + +To <> for this instance run: + +.... +npm run seed-prod +.... + +=== Development run in PostgreSQL + +If you want to debug a PostgreSQL specific issue interactively on the browser, you can run a development Next.js server on PostgreSQL. + +This is similar to <>, but running the development server is more convenient for development as you won't have to `npmr run build-prod` on every frontend change. + +First setup the PostgreSQL database as in <>. + +Then start the server with: + +.... +npm run dev-pg +.... + +To run other database related commands on PostgreSQL you can export the `REALWORLD_PG=true` environment variable manually as in: + +.... +REALWORLD_PG=true ./bin/sync-db.js +REALWORLD_PG=true ./bin/generate-demo-data.js +.... + +If you need to inspect the database manually you can use: + +.... +psql realworld_next +.... + +=== `devDependencies` vs `dependencies` + +Note that any dependencies required only for the build e.g. typescript are put under `devDependencies`. + +Our current <> setup installs both `dependencies` and `devDependencies`, builds, and then removes `devDependencies` from the deployed code to make it smaller. + +=== Demo mode + +Activated with `NEXT_PUBLID_DEMO=true` or: + +.... +npm run dev-demo +.... + +This has the following effects: + +* block posts with tags given at `blacklistTags` of `config.js` The initial motivation for this was to block automated "Cypress Automation" spam that is likely setup by some bastard on all published implementations via the backend, example: https://archive.ph/wip/n4Jlx[], and might be taking up a good part of our Heroku dynos, to be confirmed. ++ +We've logged their IP as 31.183.168.37, let's see if it changes with time. That IP is from Poland, which is consistent with Google Analytics results, which are overwhelmingly from Poland, suggesting a bot within that country, which also does GET on the web UI. +* whenever a new object is created, such as article, comment or user, if we already have 1000 objects of that type, delete the oldest object of that type, so as to keep the database size limited. TODO implement for Tags, Follows and Likes. +* "Source code for this website" banner on top with link to this repository +* clearer tags input message "Press Enter, Tab or Comma to add a tag" + +== Keyboard shortcuts + +Ctrl + Enter submits articles. + +=== Generate demo data + +Note that this will first erase any data present in the database: + +.... +./bin/generate-demo-data.js +.... + +You can then login with users such as: + +* `user0@mail.com` +* `user1@mail.com` + +and password `asdf`. + +Test data size can be configured with CLI parameters, e.g.: + +.... +./bin/generate-demo-data.js --n-users 5 --n-articles-per-user 8 --n-follows-per-user 3 +.... + +If you just want to truncate the database with empty data use: + +.... +./bin/generate-demo-data.js --empty +.... + +== Linting + +The following lint checks are run automatically as part of: + +.... +npm run build-dev +.... + +from <>, but it can be good to isolate the command to speed up the development loop. + +Run typescript type checks: + +.... +npm run tsc +.... + +Run eslint checks: + +.... +npm run lint +.... + +These lint checks include both: + +* https://github.com/prettier/prettier[prettier] checks, which do style checking. Since it is just style checks, any problems with those can be fixed automatically by prettier's auto-refactoring functionality with: ++ +.... +npm run format +.... +* more functional checks, including important checks such as those provided by eslint-config-react-hooks as opposed to more functional thing + +Rationale for some rules we've disabled: + +* `@next/next/no-img-element`: Next insist that you whitelist servers, which is only possible if we implement profile picture upload: https://github.com/cirosantilli/node-express-sequelize-nextjs-realworld-example-app/issues/16 We will actually do this at some point. +* `import/no-anonymous-default-export`: what's the point??? It just duplicates module names as pointed in comments. https://stackoverflow.com/questions/64729264/how-can-i-get-rid-of-the-warning-import-no-anonymous-default-export-in-react + +== Testing + +When running: + +.... +NODE_ENV=test npm run dev +.... + +the server runs on a temporary in-memory database when using the default SQLite database. + +It has no effect on <>, as we don't know of any reasonable alternatives unfortunately. We could grant a create database privilege to our PostgreSQL test user... but Sequelize does not seem to support database creation there: https://stackoverflow.com/questions/31294562/sequelize-create-database/32212001[]. + +One implication of this is that it is not currently possible to run <> in parallel mode for PostgreSQL. + +=== test.js + +Our tests are all located inside link:test.js[]. + +They can be run with: + +.... +npm test +.... + +Run just a single test: + +.... +npm test -- -g 'substring of test title' +.... + +Show all queries done in the tests: + +.... +DEBUG='sequelize:sql:*' npm run test +.... + +To run those tests on PostgreSQL intead, first setup as in <>, and then: + +.... +npm run test-pg +.... + +Note that this will erase all data present in the database used. In order to point to a custom database use: + +.... +DATABASE_URL_TEST=postgres://realworld_next_user:a@localhost:5432/realworld_next_test npm run test-pg +.... + +We don't use `DATABASE_URL` when running tests as a safegard to reduce the likelyhood of accidentaly nuking the production database. + +The tests include two broad classes of tests: + +* API tests: launch the server on a random port, and run API commands, thus testing the entire backend. These are similar to the <>, but don't require postman JSON insanity, and start and close a clean server for every single test +* smaller unit tests that only call certain functions directly +* TODO: frontend tests: https://github.com/cirosantilli/node-express-sequelize-nextjs-realworld-example-app/issues/11 + +==== Next.js tests + +By default <> does not run any tests on Next.js, only on the API routes, because Next.js would make tests too slow: + +* Next.js startup is slow +* we must run in production mode because development mode is too lenient, e.g. it does not raise 500 errors. Therefore we have to build before every run. + +To also run tests that hit Next.js run: + +.... +npm run test-next +.... + +or for Postgres: + +.... +npm run test-pg-next +.... + +=== Realworld API tests + +These tests are part of https://github.com/gothinkster/realworld which we track here as a submodule. + +Test test method uses Postman, but we feel that it is not a very good way to do the testing, as it uses JSON formats everywhere with embedded JavaScript, presumably to be edited in some dedicated editor like Jupyter does. It would be much better to just have a pure JavaScript setup instead. + +They test the JSON REST API without the frontend. + +First start the backend server in a terminal: + +.... +npm run back-test +.... + +`npm run back-test` will make our server use a clean one-off in-memory database instead of using the default in-disk development `./db.sqlite3` as done for `npm run back`. + +Then on another terminal: + +.... +npm run test-api +.... + +Run a single test called `Register` instead: + +.... +npm run test-api -- --folder Register +.... + +TODO: many tests depend on previous steps, notably register. But we weren't able to make it run just given specific tests e.g. with: + +.... +npmr test-api -- --folder 'Register' --folder 'Login and Remember Token' --folder 'Create Article' +.... + +only the last `--folder` is used. Some threads say that multiple ones can be used in newer Newman, but even after updating it to latest v5 we couldn't get it to work: + +* https://stackoverflow.com/questions/60057009/how-to-run-single-request-from-the-collection-in-newman +* https://stackoverflow.com/questions/52519415/how-to-read-two-folder-with-newman diff --git a/README.adoc b/README.adoc deleted file mode 100644 index 6598ec66..00000000 --- a/README.adoc +++ /dev/null @@ -1,324 +0,0 @@ -= Node.js + Express.js + Sequelize + SQLite/PostgreSQL + React + Redux + Heroku Example Realworld App -:china-dictatorship-media-base: https://raw.githubusercontent.com/cirosantilli/china-dictatorship-media/master -:china-dictatorship-media-base-ignore: {china-dictatorship-media-base} -:idprefix: -:idseparator: - -:sectanchors: -:sectlinks: -:sectnumlevels: 6 -:sectnums: -:toc: macro -:toclevels: 6 -:toc-title: - -Got it running on <> as of April 2021 at https://cirosantilli-realworld-express.herokuapp.com/ - -Includes a working single-process fullstack <> by building the https://github.com/gothinkster/react-redux-realworld-example-app frontend statically and serving it from the public folder of the backend. - -CSS bundling to use https://demo.productionready.io/main.css from the SCSS source is also setup, but blocked on: https://github.com/gothinkster/conduit-bootstrap-template/issues/5#issuecomment-829104220 - -This started as a fork of: https://github.com/sigoden/node-express-realworld-example-app (deleted on 2021-06-21 probably because of fear of backslash from the Chinese communist party after Ciro created some issues there) which was likely a port of https://github.com/gothinkster/node-express-realworld-example-app both of which are backend implementations of the awesome https://github.com/gothinkster/realworld sigoden was a good starting point, but it notably did not implement any of the many-to-many relations properly in SQL, rather hacking it with strings. We have now implemented those properly with relations (and it was not a breeze partly because sequelize is so quirky). - -The `react-redux-realworld-example-app` is stored at link:react-redux-realworld-example-app[] as a submodule, and some small modifications have been made to it from the upstream, they are tracked at: https://github.com/cirosantilli/react-redux-realworld-example-app - -Website behaviour is intended to match the front and backend upstreams as closely as possible, minus except possible obvious bugs. - -Other versions of this repository include: - -* Next.js instead of React + Redux: https://github.com/cirosantilli/node-express-sequelize-nextjs-realworld-example-app - -Tested on an Ubuntu 21.04 development host. - -toc::[] - -== Local development with SQLite - -..... -npm install -npm run dev -..... - -The browser automatically pops the development front-end server at http://localhost:4101[] which makes requests to the backend server that runs at http://localhost:3000[]. - -You might also want to generate some test data as mentioned at: <>. - -The SQLite database is located at `db.sqlite3`. - -== Local optimized frontend - -..... -npm install -npm run build -npm start -..... - -The website can now be seen at: http://localhost:3000 - -This setup does not start the front-end development server at http://localhost:4101[], but rather compiles the front-end files statically, and serves them on the public folder of the backend server, giving performance representative performance characteristics of the frontend. - -This setup is much closer to the final type of setup that will run in production, and it runs a single server. - -Running it locally might help debug some front-end deployment issues. - -But otherwise you will just normally use the <> setup instead for development, because this setup lacks important debug features such as: - -* hot code reloading - -This setup still runs on `NODE_ENV=development`, which implies that sqlite is used, so no further database setup is needed for PostgreSQL. This also means however that you might experience different performance characteristics or behaviour not ironed out by Sequelize between SQLite vs PotsgreSQL. - -=== Local run as identical to deployment as possible - -Here we use PostgreSQL instead of SQLite with the prebuilt static frontend. Note that optimized frontend is also used on the SQLite setup described at <>). - -For when you really need to debug some deployment stuff locally - -Setup: - -.... -sudo apt install postgresql - -# Become able to run psql command without sudo. -sudo -u postgres createuser -s "$(whoami)" -createdb "$(whoami)" - -createdb node_express_sequelize_realworld -psql -c "CREATE ROLE node_express_sequelize_realworld_user with login password 'a'" -psql -c 'GRANT ALL PRIVILEGES ON DATABASE node_express_sequelize_realworld TO node_express_sequelize_realworld_user' -echo "SECRET=$(tr -dc A-Za-z0-9 > .env -.... - -Run: - -.... -npm run build -npm run start-prod -.... - -then visit the running website at: http://localhost:3000/ - -To <> for this instance run: - -.... -NODE_ENV=production DATABASE_URL='postgres://node_express_sequelize_realworld_user:a@localhost:5432/node_express_sequelize_realworld' ./bin/generate-demo-data.js --force-production -.... - -== Heroku deployment - -First time setup: - -.... -heroku git:remote -a cirosantilli-realworld-express -# Automatically sets DATABASE_URL. -heroku addons:create heroku-postgresql:hobby-dev -# Otherwise the react build picks up the .eslint from this directory, -# which specifies a plugin that is not installed because it is in the -# devDependencies of this package.json... For the love of God, this is -# a deployment, not a CI. -# https://stackoverflow.com/questions/55821078/disable-eslint-that-create-react-app-provides -heroku config:set DISABLE_ESLINT_PLUGIN=true -# Notably to skip ultra-slow sqlite native build. -heroku config:set NPM_CONFIG_PRODUCTION=true YARN_PRODUCTION=true -heroku config:set SECRET="$(tr -dc A-Za-z0-9 > inside Heroku: - -.... -heroku run bash -./bin/generate-demo-data.js --force-production -.... - -We have to run `heroku run bash` instead of `heroku ps:exec` because the second command does not set `DATABASE_URL`: - -* https://stackoverflow.com/questions/62502951/heroku-env-variables-database-url-and-port-not-showing-in-dyno-heroku-psexec/68050303#68050303 -* https://stackoverflow.com/questions/48119289/how-to-get-environment-variables-in-live-heroku-dyno/64951959#64951959 -* https://www.reddit.com/r/rails/comments/ejljxj/how_to_seed_a_postgres_production_database_on/ - -Edit a file in Heroku to debug that you are trying to run manually, e.g. by adding print commands, uses https://github.com/hakash/termit[] minimal https://en.wikipedia.org/wiki/GNU_nano[nano]-like text editor: - -.... -heroku ps:exec -termit app.js -.... - -== Debugging - -=== Step debugging - -For the backend, add `debugger;` to the point of interest, and run as: - -.... -npm run back-inspect -.... - -On the debugger, do a `c` to continue so that the server will start running (impossible to skip automatically: https://stackoverflow.com/questions/16420374/how-to-disable-in-the-node-debugger-break-on-first-line[]), and then trigger your event of interest from the browser: - -.... -npm run front -.... - -=== VERBOSE environment variable - -If you run as: - -.... -VERBOSE=1 npm run dev -.... - -this enables the following extra logs: - -* a log line for every request done - -=== Log database queries done - -.... -DEBUG='sequelize:sql:*' npm run start-prod -.... - -=== Generate demo data - -Note that this will first erase any data present in the database: - -.... -./bin/generate-demo-data.js -.... - -You can then login with users such as: - -* `user0@mail.com` -* `user1@mail.com` - -and password `asdf`. - -Test data size can be configured with CLI parameters, e.g.: - -.... -./bin/generate-demo-data.js --n-users 5 --n-articles-per-user 8 --n-follows-per-user 3 -.... - -=== Prevent the browser from opening automatically - -In case you've broken things so bad that the very first GET blows up the website and further requests don't respond https://stackoverflow.com/questions/61927814/how-to-disable-open-browser-in-cra - -.... -BROWSER=none npm run dev -.... - -This gives you time to setup e.g. Network recording in Chrome Developer Tools to be able to understand what is going on. - -=== Sequelize sometimes does not show the full stack trace - -This is a big problem during development, not sure how to solve it: https://github.com/sequelize/sequelize/issues/8199#issuecomment-863943835 - -== Testing - -=== API tests - -These tests are part of https://github.com/gothinkster/realworld which we track here as a submodule. - -Test test method uses Postman, but we feel that it is not a very good way to do the testing, as it uses JSON formats everywhere with embedded JavaScript, presumably to be edited in some dedicated editor like Jupyter does. It would be much better to just have a pure JavaScript setup instead. - -They test the JSON REST API without the frontend. - -First start the backend server in a terminal: - -.... -npm run back-test -.... - -`npm run back-test` will make our server use a clean one-off in-memory database instead of using the default in-disk development `./db.sqlite3` as done for `npm run back`. - -Then on another terminal: - -.... -npm run test-api -.... - -Run a single test called `Register` instead: - -.... -npm run test-api -- --folder Register -.... - -TODO: many tests depend on previous steps, notably register. But we weren't able to make it run just given specific tests e.g. with: - -.... -npmr test-api -- --folder 'Register' --folder 'Login and Remember Token' --folder 'Create Article' -.... - -only the last `--folder` is used. Some threads say that multiple ones can be used in newer Newman, but even after updating it to latest v5 we couldn't get it to work: - -* https://stackoverflow.com/questions/60057009/how-to-run-single-request-from-the-collection-in-newman -* https://stackoverflow.com/questions/52519415/how-to-read-two-folder-with-newman - -=== Unit tests - -Ideally, all tests should be API test, so that they will work across any backend implementation more easily, and test the system more fully. - -However, setting up full API tests can be annoying, especially the user creation part, as especially since Postman is so clunky. - -Furthermore, the API tests can have a slower setup time, since by going directly to the backend API we can call `bulkCreate` which can be much faster than creating objects one by one. - -So sometimes, especially for things like model relations, we will just revert to a some quick API test: - -.... -npm test -.... - -To run those tests on PostgreSQL intead, first setup as in <>, and then - -.... -NODE_ENV=production DATABASE_URL='postgres://node_express_sequelize_realworld_user:a@localhost:5432/node_express_sequelize_realworld' npm test -.... - -== Benchmarks - -Methodology: - -* time after click event https://stackoverflow.com/questions/67750849/how-to-filter-by-event-type-in-chrome-devtools-profile-tab-e-g-to-see-mouse-cli/67750850#67750850 up until new page renders, not considering any images on the new page, just text -* caches warmed by clicking all pages involved just before the experiment -* hardware: Lenovo ThinkPad P51 -* browser: Chromium 91 - -Results: - -* click from global feed to article -** this repo at 98e628a76b4253bb51ff4a8659305fabfda1b1f8, `npm run dev`: 0.2s -** this repo at 98e628a76b4253bb51ff4a8659305fabfda1b1f8, `npm run start`: 0.2s -** this repo at 98e628a76b4253bb51ff4a8659305fabfda1b1f8 + frontend https://github.com/cirosantilli/next-realworld-example-app/tree/d510e33745966618ee95243ad8f7d3d974adcf14 `npm run dev`: 0.2s -** this repo at 98e628a76b4253bb51ff4a8659305fabfda1b1f8 + frontend https://github.com/cirosantilli/next-realworld-example-app/tree/d510e33745966618ee95243ad8f7d3d974adcf14 `npm run`: 0.2s - -== Bugs - -Notable React Redux upstream bugs: - -* https://github.com/gothinkster/react-redux-realworld-example-app/issues/197 Your Feed pagination is just completely broken. This is not an API bug in this repo. - -== Database conventions - -The naming conventions are meant to be similar to the JavaScript naming conventions: - -* camel case on tables and columns -* tables start with a capital letter, because they are class-like -* columns start with a lowercase letter, because they are field-like -* tables use singular form - -Achieving this requires fighting a bit with sequelize, which by default produces inconsistent names on foreign keys. - -== Related projects - -https://github.com/Varun-Hegde/Conduit_NodeJS/tree/99cc32f19a42d74ff9729765772b4676c537a755 some of the <> were failing, and some parts of the code didin't feel as clean as I'd like, so I ended up using https://github.com/sigoden/node-express-realworld-example-app[] as a basis. However I later learnt they did do/attempt to do the many to many relatioships properly unlike sigoden which just hacked with strings. The critical "hard" querry however https://github.com/Varun-Hegde/Conduit_NodeJS/blob/99cc32f19a42d74ff9729765772b4676c537a755/controllers/articles.js#L271[], which finds "posts by users I follow" and which best exercises the ORM's is not done nicel in a single SQL command as achieved in this repository after a lot of suffering. diff --git a/README.md b/README.md new file mode 100644 index 00000000..391c3d75 --- /dev/null +++ b/README.md @@ -0,0 +1,44 @@ +# Cypress: Settings + +## Workflow + +1. Fork the repo. +1. Clone **your** forked repository. +1. Clone [this](https://github.com/iBrianWarner/realworld) repo into you repo. +1. Create a new branch `git checkout -b e2e_testing`. +1. Run the [app](./DEV.adoc) (Local development with SQLite). +1. Resolve tasks. +1. Record a video of your running your tests (you can use Loom). +1. Check yourself before submitting the task with a [Cypress checklist](https://mate-academy.github.io/qa-program/checklists/cypress.html). +1. Create a pull request. + - note, you have to make a PR to this branch: + ![branch](./public/branch.png) +1. Attach a link to your video to the PR description. +1. Do not forget to click on `Re-request review` if you submit the homework after previous review. + +## Task + +Go to `e2e` folder and cover listed functionality with e2e tests: + +- updating bio; +- updating username; +- updating email; +- updating password. + +### Requirements + +1. Clear all data from the database before the test. +1. Add `data-cy` attributes for all elements you are working with in tests. +1. Use faker and custom methods to generate a fake data in tests. +1. Use PageObject pattern for your tests: + - create a files with POM classes for your pages in `cypress`/`support`/`pages`. + - use `PageObject.js` file for the common for the whole app elements. + +Observe an example in `cypress`/`e2e`/`signIn.cy.js`. +Find and additinoanl about Page Object in the [Cypress](https://mate.academy/learn/javascript-testing/cypress#/theory) topic. + +
+Hint +
+:bulb: Be mindful of data validation when generating test data for API requests. Some randomly generated values may not meet the API’s validation rules, causing tests to fail. To avoid flaky test behaviour, ensure that your data generation method produces valid values. You can adjust the data generation by using different `faker` methods or by passing different configuration options (if the method supports them). +
diff --git a/api/articles.js b/api/articles.js new file mode 100644 index 00000000..24bd836b --- /dev/null +++ b/api/articles.js @@ -0,0 +1,479 @@ +const router = require('express').Router() +const auth = require('../auth') +const { Transaction } = require('sequelize') + +const config = require('../front/config.js') +const lib = require('../lib.js') + +async function setArticleTags(req, article, tagList, transaction) { + await req.app.get('sequelize').models.Tag.bulkCreate( + tagList.map((tag) => { + return { name: tag } + }), + { + ignoreDuplicates: true, + transaction, + } + ) + // IDs may be missing from the above, so we have to do a find. + // https://github.com/sequelize/sequelize/issues/11223#issuecomment-864185973 + const tags2 = await req.app.get('sequelize').models.Tag.findAll({ + where: { name: tagList }, + transaction, + }) + return article.setTags(tags2, { transaction }) +} + +function validateArticle(req, res, article, tagList) { + let ret + if (typeof tagList !== 'undefined') { + if (config.isDemo) { + if (tagList.length > 10) { + ret = `too many tags: ${tagList.length}` + } + for (let tag of tagList) { + if (config.blacklistTags.has(tag.toLowerCase())) { + ret = `blacklisted tag: ${tag}` + } + } + } + } + return ret +} + +// Preload article objects on routes with ':article' +router.param('article', function (req, res, next, slug) { + req.app + .get('sequelize') + .models.Article.findOne({ + where: { slug: slug }, + include: [{ model: req.app.get('sequelize').models.User, as: 'author' }], + }) + .then(function (article) { + if (!article) { + return res.sendStatus(404) + } + req.article = article + return next() + }) + .catch(next) +}) + +router.param('comment', function (req, res, next, id) { + req.app + .get('sequelize') + .models.Comment.findOne({ + where: { id: id }, + include: [{ model: req.app.get('sequelize').models.User, as: 'author' }], + }) + .then(function (comment) { + if (!comment) { + return res.sendStatus(404) + } + req.comment = comment + return next() + }) + .catch(next) +}) + +router.get('/', auth.optional, async function (req, res, next) { + try { + let query = {} + const limit = lib.validateParam( + req.query, + 'limit', + lib.validatePositiveInteger, + 20 + ) + const offset = lib.validateParam( + req.query, + 'offset', + lib.validatePositiveInteger, + 0 + ) + const include = [] + + let loggedUserId = req.payload ? req.payload.id : undefined + + // Author include. + const authorInclude = { + model: req.app.get('sequelize').models.User, + as: 'author', + } + // TODO get if user follows author on JOIN here. + if (loggedUserId) { + //authorInclude.include = [{ + // model: req.app.get('sequelize').models.UserFollowUser, + // as: 'follows', + // required: false, + // where: { userId: loggedUserId }, + // include: [{ + // model: req.app.get('sequelize').models.User, + // as: 'UserFollowsUser', + // }] + //}] + } + if (req.query.author) { + authorInclude.where = { username: req.query.author } + } + include.push(authorInclude) + + let favoritedPrecalc = false + if (req.query.favorited) { + // Select only posts that have been favorited by this given. + include.push({ + model: req.app.get('sequelize').models.User, + as: 'favoritedBy', + where: { username: req.query.favorited }, + }) + } else if (loggedUserId) { + // Add "did the logged in user favorite this post" to the JOIN to not have to + // do it individually article by article. + // https://github.com/cirosantilli/node-express-sequelize-nextjs-realworld-example-app/issues/5 + // + // TODO we don't know how to do both "did logged in user favorite this article + // And "select articles liked by" at the same time as it would require including + // the same model multiple times under a single alias: + // * https://github.com/sequelize/sequelize/issues/8013 + // * https://github.com/sequelize/sequelize/issues/7754 + include.push({ + model: req.app.get('sequelize').models.UserFavoriteArticle, + where: { userId: req.payload.id }, + required: false, + include: [ + { + model: req.app.get('sequelize').models.User, + }, + ], + }) + favoritedPrecalc = true + } + + // Tag include. + if (req.query.tag) { + const tagInclude = { + model: req.app.get('sequelize').models.Tag, + as: 'tags', + } + tagInclude.where = { name: req.query.tag } + include.push(tagInclude) + } + + const [{ count: articlesCount, rows: articles }, user] = await Promise.all([ + req.app.get('sequelize').models.Article.findAndCountAll({ + where: query, + order: [['createdAt', 'DESC']], + limit, + offset, + include, + }), + req.payload + ? req.app.get('sequelize').models.User.findByPk(loggedUserId) + : null, + ]) + return res.json({ + articles: await Promise.all( + articles.map((article) => { + const tags = req.query.tag ? undefined : article.tags + const favorited = favoritedPrecalc + ? !!article.UserFavoriteArticles.length + : undefined + // TODO get if user follows author on JOIN here. + //console.error(article.author.followed.map(u => u.id)); + //const authorFollowed = loggedUserId ? undefined : undefined + return article.toJson(user, { tags, favorited }) + }) + ), + articlesCount: articlesCount, + }) + } catch (error) { + next(error) + } +}) + +router.get('/feed', auth.required, async function (req, res, next) { + try { + const limit = lib.validateParam( + req.query, + 'limit', + lib.validatePositiveInteger, + 20 + ) + const offset = lib.validateParam( + req.query, + 'offset', + lib.validatePositiveInteger, + 0 + ) + const user = await req.app + .get('sequelize') + .models.User.findByPk(req.payload.id) + if (!user) { + return res.sendStatus(401) + } + return res.json( + await user.findAndCountArticlesByFollowedToJson(offset, limit) + ) + } catch (error) { + next(error) + } +}) + +// Create article +router.post('/', auth.required, async function (req, res, next) { + try { + const user = await req.app + .get('sequelize') + .models.User.findByPk(req.payload.id) + if (!user) { + return res.sendStatus(401) + } + if (!req.body.article) { + return res.status(422).json({ errors: { article: "can't be blank" } }) + } + let article = new (req.app.get('sequelize').models.Article)( + req.body.article + ) + article.authorId = user.id + const tagList = req.body.article.tagList + if (validateArticle(req, res, article, tagList)) return + //await article.save() + //await setArticleTags(req, article, tagList) + await req.app + .get('sequelize') + .transaction( + Transaction.ISOLATION_LEVELS.SERIALIZABLE, + async (transaction) => { + await Promise.all([ + article.save({ transaction }), + setArticleTags(req, article, tagList, transaction), + ]) + } + ) + await Promise.all([ + lib.deleteOldestForDemo(req.app.get('sequelize').models.Article), + lib.deleteOldestForDemo(req.app.get('sequelize').models.Tag), + // TODO does not have a ID, and I can't find how to do IN check with + // (tagId, articleId) tuples in sequelize, and lazy to add ID. + //lib.deleteOldestForDemo(req.app.get('sequelize').models.ArticleTag), + ]) + article.author = user + return res.json({ article: await article.toJson(user) }) + } catch (error) { + next(error) + } +}) + +// Get article +router.get('/:article', auth.optional, async function (req, res, next) { + try { + const results = await Promise.all([ + req.payload + ? req.app.get('sequelize').models.User.findByPk(req.payload.id) + : null, + req.article.getAuthor(), + ]) + const [user] = results + return res.json({ article: await req.article.toJson(user) }) + } catch (error) { + next(error) + } +}) + +// Update article +router.put('/:article', auth.required, async function (req, res, next) { + try { + const user = await req.app + .get('sequelize') + .models.User.findByPk(req.payload.id) + if (req.article.authorId.toString() === req.payload.id.toString()) { + const article = req.article + if (req.body.article) { + if (typeof req.body.article.title !== 'undefined') { + article.title = req.body.article.title + } + if (typeof req.body.article.description !== 'undefined') { + article.description = req.body.article.description + } + if (typeof req.body.article.body !== 'undefined') { + article.body = req.body.article.body + } + const tagList = req.body.article.tagList + if (validateArticle(req, res, article, tagList)) return + await req.app + .get('sequelize') + .transaction( + Transaction.ISOLATION_LEVELS.SERIALIZABLE, + async (transaction) => { + await article.deleteEmptyTags(transaction) + await Promise.all([ + typeof tagList === 'undefined' + ? null + : setArticleTags(req, article, tagList, transaction), + article.save({ transaction }), + ]) + } + ) + } + return res.json({ article: await article.toJson(user) }) + } else { + return res.sendStatus(403) + } + } catch (error) { + next(error) + } +}) + +// Delete article +router.delete('/:article', auth.required, async function (req, res, next) { + try { + const user = req.app.get('sequelize').models.User.findByPk(req.payload.id) + if (!user) { + return res.sendStatus(401) + } + if (req.article.author.id.toString() === req.payload.id.toString()) { + await req.article.destroy2() + return res.sendStatus(204) + } else { + return res.sendStatus(403) + } + } catch (error) { + next(error) + } +}) + +// Favorite an article +router.post( + '/:article/favorite', + auth.required, + async function (req, res, next) { + try { + const articleId = req.article.id + const [user, article] = await Promise.all([ + req.app.get('sequelize').models.User.findByPk(req.payload.id), + req.app.get('sequelize').models.Article.findByPk(articleId), + ]) + if (!user) { + return res.sendStatus(401) + } + if (!article) { + return res.sendStatus(404) + } + await user.addFavorite(articleId) + // TODO same as ArticleTag + //await lib.deleteOldestForDemo(req.app.get('sequelize').models.UserFavoriteArticle) + return res.json({ article: await article.toJson(user) }) + } catch (error) { + next(error) + } + } +) + +// Unfavorite an article +router.delete( + '/:article/favorite', + auth.required, + async function (req, res, next) { + try { + const articleId = req.article.id + const [user, article] = await Promise.all([ + req.app.get('sequelize').models.User.findByPk(req.payload.id), + req.app.get('sequelize').models.Article.findByPk(articleId), + ]) + if (!user) { + return res.sendStatus(401) + } + if (!article) { + return res.sendStatus(404) + } + await user.removeFavorite(articleId) + return res.json({ article: await article.toJson(user) }) + } catch (error) { + next(error) + } + } +) + +// Return an article's comments +router.get( + '/:article/comments', + auth.optional, + async function (req, res, next) { + try { + let user + if (req.payload) { + user = await req.app + .get('sequelize') + .models.User.findByPk(req.payload.id) + } else { + user = null + } + const comments = await req.article.getComments({ + order: [['createdAt', 'DESC']], + include: [ + { model: req.app.get('sequelize').models.User, as: 'author' }, + ], + }) + return res.json({ + comments: await Promise.all( + comments.map(function (comment) { + return comment.toJson(user) + }) + ), + }) + } catch (error) { + next(error) + } + } +) + +// Create a new comment +router.post( + '/:article/comments', + auth.required, + async function (req, res, next) { + try { + const user = await req.app + .get('sequelize') + .models.User.findByPk(req.payload.id) + if (!user) { + return res.sendStatus(401) + } + if (!req.body.comment) { + return res.status(422).json({ errors: { comment: "can't be blank" } }) + } + const comment = await req.app.get('sequelize').models.Comment.create( + Object.assign({}, req.body.comment, { + articleId: req.article.id, + authorId: user.id, + }) + ) + await lib.deleteOldestForDemo(req.app.get('sequelize').models.Comment) + comment.author = user + return res.json({ comment: await comment.toJson(user) }) + } catch (error) { + next(error) + } + } +) + +// Delete a comment +router.delete( + '/:article/comments/:comment', + auth.required, + async function (req, res, next) { + try { + const author = await req.comment.getAuthor() + if (author.id.toString() === req.payload.id.toString()) { + await req.comment.destroy() + return res.sendStatus(204) + } else { + res.sendStatus(403) + } + } catch (error) { + next(error) + } + } +) + +module.exports = router diff --git a/api/index.js b/api/index.js new file mode 100644 index 00000000..fd6fdcbf --- /dev/null +++ b/api/index.js @@ -0,0 +1,12 @@ +const router = require('express').Router() + +// heroku bootstrap +router.get('/', function (req, res) { + res.json({ message: 'backend is up' }) +}) +router.use('/', require('./users')) +router.use('/profiles', require('./profiles')) +router.use('/articles', require('./articles')) +router.use('/tags', require('./tags')) + +module.exports = router diff --git a/api/profiles.js b/api/profiles.js new file mode 100644 index 00000000..579f3bc7 --- /dev/null +++ b/api/profiles.js @@ -0,0 +1,84 @@ +const router = require('express').Router() +const auth = require('../auth') + +// Preload user profile on routes with ':username' +router.param('username', function (req, res, next, username) { + req.app + .get('sequelize') + .models.User.findOne({ where: { username: username } }) + .then(function (user) { + if (!user) { + return res.sendStatus(404) + } + req.profile = user + return next() + }) + .catch(next) +}) + +router.get('/:username', auth.optional, async function (req, res, next) { + try { + let toProfileJSONForUser + if (req.payload) { + const user = await req.app + .get('sequelize') + .models.User.findByPk(req.payload.id) + if (user) { + toProfileJSONForUser = user + } else { + toProfileJSONForUser = false + } + } else { + toProfileJSONForUser = false + } + return res.json({ + profile: await req.profile.toProfileJSONFor(toProfileJSONForUser), + }) + } catch (error) { + next(error) + } +}) + +router.post( + '/:username/follow', + auth.required, + async function (req, res, next) { + try { + let profileId = req.profile.id + const user = await req.app + .get('sequelize') + .models.User.findByPk(req.payload.id) + if (!user) { + return res.sendStatus(401) + } + await user.addFollow(profileId) + // TODO same as ArticleTag + //await lib.deleteOldestForDemo(req.app.get('sequelize').models.UserFollowUser) + return res.json({ profile: await req.profile.toProfileJSONFor(user) }) + } catch (error) { + next(error) + } + } +) + +router.delete( + '/:username/follow', + auth.required, + async function (req, res, next) { + try { + let profileId = req.profile.id + const user = await req.app + .get('sequelize') + .models.User.findByPk(req.payload.id) + if (!user) { + return res.sendStatus(401) + } + await user.removeFollow(profileId) + return res.json({ profile: await req.profile.toProfileJSONFor(user) }) + } catch (error) { + next(error) + } + } +) + +module.exports = router diff --git a/api/tags.js b/api/tags.js new file mode 100644 index 00000000..feeea865 --- /dev/null +++ b/api/tags.js @@ -0,0 +1,14 @@ +const router = require('express').Router() + +const lib = require('../lib') + +// return a list of tags +router.get('/', async function (req, res, next) { + try { + return res.json({ tags: await lib.getIndexTags(req.app.get('sequelize')) }) + } catch (error) { + next(error) + } +}) + +module.exports = router diff --git a/api/users.js b/api/users.js new file mode 100644 index 00000000..676dca12 --- /dev/null +++ b/api/users.js @@ -0,0 +1,106 @@ +const router = require('express').Router() +const passport = require('passport') + +const auth = require('../auth') +const lib = require('../lib') + +router.get('/user', auth.required, async function (req, res, next) { + try { + const user = await req.app + .get('sequelize') + .models.User.findByPk(req.payload.id) + if (!user) { + return res.sendStatus(401) + } + return res.json({ user: user.toAuthJSON() }) + } catch (error) { + next(error) + } +}) + +router.put('/user', auth.required, async function (req, res, next) { + try { + const user = await req.app + .get('sequelize') + .models.User.findByPk(req.payload.id) + if (!user) { + return res.sendStatus(401) + } + if (req.body.user) { + // Only update fields that were actually passed. + if (typeof req.body.user.username !== 'undefined') { + user.username = req.body.user.username + } + if (typeof req.body.user.email !== 'undefined') { + user.email = req.body.user.email + } + if (typeof req.body.user.bio !== 'undefined') { + user.bio = req.body.user.bio + } + if (typeof req.body.user.image !== 'undefined') { + user.image = req.body.user.image + } + if (typeof req.body.user.password !== 'undefined') { + req.app + .get('sequelize') + .models.User.setPassword(user, req.body.user.password) + } + await user.save() + } + return res.json({ user: user.toAuthJSON() }) + } catch (error) { + next(error) + } +}) + +router.post('/users/login', function (req, res, next) { + if (!req.body.user) { + return res.status(422).json({ errors: { user: "can't be blank" } }) + } + if (!req.body.user.email) { + return res.status(422).json({ errors: { email: "can't be blank" } }) + } + if (!req.body.user.password) { + return res.status(422).json({ errors: { password: "can't be blank" } }) + } + passport.authenticate( + 'local', + { session: false }, + function (err, user, info) { + if (err) { + return next(err) + } + if (user) { + user.token = user.generateJWT() + return res.json({ user: user.toAuthJSON() }) + } else { + return res.status(422).json(info) + } + } + )(req, res, next) +}) + +router.post('/users', async function (req, res, next) { + try { + let user = new (req.app.get('sequelize').models.User)() + if (!req.body.user) { + return res.status(422).json({ errors: { user: "can't be blank" } }) + } + if (!req.body.user.password) { + return res.status(422).json({ errors: { password: "can't be blank" } }) + } + user.username = req.body.user.username + user.email = req.body.user.email + user.ip = lib.getClientIp(req) + req.app + .get('sequelize') + .models.User.setPassword(user, req.body.user.password) + await user.save() + await lib.deleteOldestForDemo(req.app.get('sequelize').models.User) + return res.json({ user: user.toAuthJSON() }) + } catch (error) { + next(error) + } +}) + +module.exports = router diff --git a/app.js b/app.js old mode 100644 new mode 100755 index 8097646a..329684d9 --- a/app.js +++ b/app.js @@ -1,36 +1,51 @@ +#!/usr/bin/env node + // https://stackoverflow.com/questions/7697038/more-than-10-lines-in-a-node-js-stack-error -Error.stackTraceLimit = Infinity; +Error.stackTraceLimit = Infinity const bodyParser = require('body-parser') const cors = require('cors') -const errorhandler = require('errorhandler') const express = require('express') -const http = require('http') -const methods = require('methods') const morgan = require('morgan') +const next = require('next') const passport = require('passport') -const passport_local = require('passport-local'); -const path = require('path') +const passport_local = require('passport-local') const session = require('express-session') +const api = require('./api') +const lib = require('./lib') const models = require('./models') -const config = require('./config') +const config = require('./front/config') + +/** + * - port: 0 means find random empty port + * - startNext: if false, don't start Next.js, run just the API + **/ +async function start(port, startNext, cb) { + const app = express() + let nextApp + let nextHandle + if (startNext) { + nextApp = next({ dev: !config.isProductionNext }) + nextHandle = nextApp.getRequestHandler() + } -function doStart(app) { - const sequelize = models(__dirname); + const sequelize = models.getSequelize(__dirname) + app.set('sequelize', sequelize) passport.use( new passport_local.Strategy( { usernameField: 'user[email]', - passwordField: 'user[password]' + passwordField: 'user[password]', }, - function(email, password, done) { + function (email, password, done) { sequelize.models.User.findOne({ where: { email: email } }) - .then(function(user) { + .then(function (user) { if (!user || !sequelize.models.User.validPassword(user, password)) { - return done(null, false, { errors: { 'email or password': 'is invalid' } }) + return done(null, false, { + errors: { 'email or password': 'is invalid' }, + }) } - return done(null, user) }) .catch(done) @@ -41,57 +56,96 @@ function doStart(app) { // Normal express config defaults if (config.verbose) { + // https://stackoverflow.com/questions/42099925/logging-all-requests-in-node-js-express/64668730#64668730 app.use(morgan('combined')) } app.use(bodyParser.urlencoded({ extended: false })) app.use(bodyParser.json()) app.use(require('method-override')()) - const buildDir = path.join(__dirname, 'react-redux-realworld-example-app', 'build'); - app.use(express.static(buildDir)); + + // Next handles anything outside of /api. app.get(new RegExp('^(?!' + config.apiPath + '(/|$))'), function (req, res) { - res.sendFile(path.join(buildDir, 'index.html')); - }); - app.use(session({ secret: 'conduit', cookie: { maxAge: 60000 }, resave: false, saveUninitialized: false })) - app.use(require('./routes')) + // We pass the sequelize that we have already created and connected to the database + // so that the Next.js backend can just use that connection. This is in particular mandatory + // if we wish to use SQLite in-memory database, because there is no way to make two separate + // connections to the same in-memory database. In memory databases are used by the test system. + req.sequelize = sequelize + return nextHandle(req, res) + }) + app.use( + session({ + secret: config.secret, + cookie: { maxAge: 60000 }, + resave: false, + saveUninitialized: false, + }) + ) + + // Handle API routes. + { + const router = express.Router() + router.use(config.apiPath, api) + app.use(router) + } // 404 handler. - app.use(function (req, res, next) { - res.status(404).send('error: 404 Not Found ' + req.path) + app.use(function (req, res) { + res.status(404).json('error: 404 Not Found') }) // Error handlers - if (config.isProduction) { - app.use(function(err, req, res, next) { - console.error(err.stack) - res.status(500).send('error: 500 Internal Server Error') - }); - } else { - app.use(errorhandler()) - } + app.use(function (err, req, res, next) { + // Automatiaclly handle Sequelize validation errors. + if (err instanceof sequelize.Sequelize.ValidationError) { + if (!config.isProduction) { + // The fuller errors can be helpful during development. + console.error(err) + } + const errors = {} + for (let errItem of err.errors) { + let errorsForColumn = errors[errItem.path] + if (errorsForColumn === undefined) { + errorsForColumn = [] + errors[errItem.path] = errorsForColumn + } + errorsForColumn.push(errItem.message) + } + return res.status(422).json({ errors }) + } else if (err instanceof lib.ValidationError) { + return res.status(err.status).json({ + errors: err.errors, + }) + } + return next(err) + }) - if (!module.parent) { - (async () => { + if (startNext) { + await nextApp.prepare() + } + await sequelize.authenticate() + // Just a convenience DB create so we don't have to force new users to do it manually. + await models.sync(sequelize) + return new Promise((resolve, reject) => { + const server = app.listen(port, async function () { try { - await sequelize.authenticate(); - await sequelize.sync(); - app.set('sequelize', sequelize) - start(); + cb && (await cb(server)) } catch (e) { - console.error(e); - process.exit(1) + reject(e) + this.close() + throw e } - })() - } + }) + server.on('close', async function () { + await sequelize.close() + resolve() + }) + }) } -function start(cb) { - const server = app.listen(config.port, function() { - console.log('Backend listening on: http://localhost:' + config.port) - cb && cb(server) +if (require.main === module) { + start(config.port, true, (server) => { + console.log('Listening on: http://localhost:' + server.address().port) }) } -const app = express() -doStart(app) - -module.exports = { app, start } +module.exports = { start } diff --git a/auth.js b/auth.js new file mode 100644 index 00000000..57b99cff --- /dev/null +++ b/auth.js @@ -0,0 +1,44 @@ +const jwt = require('express-jwt') + +const secret = require('./front/config').secret + +function getTokenFromHeader(authorization) { + if ( + (authorization && authorization.split(' ')[0] === 'Token') || + (authorization && authorization.split(' ')[0] === 'Bearer') + ) { + return authorization.split(' ')[1] + } + return null +} + +function getTokenFromRequest(req) { + let ret = getTokenFromHeader(req.headers.authorization) + if (ret) return ret + // If one day we want to allow API GET requests with the cookie. + // Does not work for Next.js routes. + //if ( + // req.method === 'GET' || + // req.method === 'HEAD' || + // req.method === 'OPTIONS' + //) { + // ret = front.getCookieFromReq(req, 'auth') + // if (ret) + // return ret + //} + return null +} + +module.exports = { + required: jwt({ + secret: secret, + userProperty: 'payload', + getToken: getTokenFromRequest, + }), + optional: jwt({ + secret: secret, + userProperty: 'payload', + credentialsRequired: false, + getToken: getTokenFromRequest, + }), +} diff --git a/back/ArticlePage.ts b/back/ArticlePage.ts new file mode 100644 index 00000000..042c6f46 --- /dev/null +++ b/back/ArticlePage.ts @@ -0,0 +1,62 @@ +import { GetStaticProps, GetStaticPaths } from 'next' + +import { ArticlePageProps } from 'front/ArticlePage' +import { fallback, revalidate, prerenderAll } from 'front/config' +import sequelize from 'db' + +export const getStaticPathsArticle: GetStaticPaths = async () => { + let paths + if (prerenderAll) { + paths = (await sequelize.models.Article.findAll()).map((article) => { + return { + params: { + pid: article.slug, + }, + } + }) + } else { + paths = [] + } + return { + fallback, + paths, + } +} + +export function getStaticPropsArticle( + addRevalidate?, + addComments? +): GetStaticProps { + return async ({ params: { pid } }) => { + const article = await sequelize.models.Article.findOne({ + where: { slug: pid }, + include: [{ model: sequelize.models.User, as: 'author' }], + }) + if (!article) { + return { + notFound: true, + } + } + let comments + if (addComments) { + comments = await article.getComments({ + order: [['createdAt', 'DESC']], + include: [{ model: sequelize.models.User, as: 'author' }], + }) + } + const props: ArticlePageProps = { article: await article.toJson() } + if (addComments) { + props.comments = await Promise.all( + comments.map((comment) => comment.toJson()) + ) + } + const ret: Awaited> = { + props, + } + // We can only add this for getStaticProps, not getServerSideProps. + if (addRevalidate) { + ret.revalidate = revalidate + } + return ret + } +} diff --git a/back/IndexPage.ts b/back/IndexPage.ts new file mode 100644 index 00000000..dbe8ffd8 --- /dev/null +++ b/back/IndexPage.ts @@ -0,0 +1,68 @@ +import { GetStaticProps } from 'next' +import { MyGetServerSideProps } from 'front/types' +import { verify } from 'jsonwebtoken' + +import { getCookieFromReq, AUTH_COOKIE_NAME } from 'front' +import { articleLimit, revalidate, secret } from 'front/config' +import sequelize from 'db' +import { getIndexTags } from 'lib' + +async function getLoggedOutProps() { + const articles = await sequelize.models.Article.findAndCountAll({ + order: [['createdAt', 'DESC']], + limit: articleLimit, + }) + return { + articles: await Promise.all( + articles.rows.map((article) => article.toJson()) + ), + articlesCount: articles.count, + tags: await getIndexTags(sequelize), + } +} + +export async function getLoggedInUser(req, res) { + const authCookie = getCookieFromReq(req, AUTH_COOKIE_NAME) + let verifiedUser + if (authCookie) { + try { + verifiedUser = verify(authCookie, secret) + } catch (e) { + return null + } + } else { + return null + } + const user = await sequelize.models.User.findByPk(verifiedUser.id) + if (user === null) { + res.clearCookie(AUTH_COOKIE_NAME) + } + return user +} + +export const getServerSidePropsHoc: MyGetServerSideProps = async ({ + req, + res, +}) => { + const loggedInUser = await getLoggedInUser(req, res) + let props + if (loggedInUser) { + const [articles, tags] = await Promise.all([ + loggedInUser.findAndCountArticlesByFollowedToJson(0, articleLimit), + getIndexTags(req.sequelize), + ]) + props = Object.assign(articles, { tags }) + } else { + props = await getLoggedOutProps() + } + // Not required by Next, just to factor things out in our demo which has both ISR and SSR. + props.ssr = true + return { props } +} + +export const getStaticPropsHoc: GetStaticProps = async () => { + return { + props: await getLoggedOutProps(), + revalidate, + } +} diff --git a/back/ProfilePage.ts b/back/ProfilePage.ts new file mode 100644 index 00000000..f73253fb --- /dev/null +++ b/back/ProfilePage.ts @@ -0,0 +1,71 @@ +import { GetStaticProps, GetStaticPaths } from 'next' + +import { articleLimit, fallback, revalidate, prerenderAll } from 'front/config' +import sequelize from 'db' + +export const getStaticPathsProfile: GetStaticPaths = async () => { + let paths + if (prerenderAll) { + paths = ( + await sequelize.models.User.findAll({ + order: [['username', 'ASC']], + }) + ).map((user) => { + return { + params: { + pid: user.username, + }, + } + }) + } else { + paths = [] + } + return { + fallback, + paths, + } +} + +export function getStaticPropsProfile(tab): GetStaticProps { + return async ({ params: { pid } }) => { + const include = [] + if (tab === 'my-posts') { + include.push({ + model: sequelize.models.User, + as: 'author', + where: { username: pid }, + }) + } else if (tab === 'favorites') { + include.push({ + model: sequelize.models.User, + as: 'favoritedBy', + where: { username: pid }, + }) + } + const [articles, user] = await Promise.all([ + sequelize.models.Article.findAndCountAll({ + order: [['createdAt', 'DESC']], + limit: articleLimit, + include, + }), + sequelize.models.User.findOne({ + where: { username: pid }, + }), + ]) + if (!user) { + return { + notFound: true, + } + } + return { + revalidate, + props: { + profile: await user.toProfileJSONFor(), + articles: await Promise.all( + articles.rows.map((article) => article.toJson()) + ), + articlesCount: articles.count, + }, + } + } +} diff --git a/bin/generate-demo-data.js b/bin/generate-demo-data.js index 7d3f28e1..ecf2eb26 100755 --- a/bin/generate-demo-data.js +++ b/bin/generate-demo-data.js @@ -3,41 +3,77 @@ const assert = require('assert') const path = require('path') -const config = require('../config') +const config = require('../front/config') -function myParseInt(value, dummyPrevious) { - const parsedValue = parseInt(value); +function myParseInt(value) { + const parsedValue = parseInt(value) if (isNaN(parsedValue)) { - throw new commander.InvalidOptionArgumentError('Not a number.'); + throw new commander.InvalidOptionArgumentError('Not a number.') } - return parsedValue; + return parsedValue } -const commander = require('commander'); -commander.option('-a, --n-articles-per-user ', 'n articles per user', myParseInt, 10); -commander.option('-c, --n-max-comments-per-article ', 'maximum number of comments per article', myParseInt, 3); -commander.option('-f, --n-follows-per-user ', 'n follows per user', myParseInt, 2); -commander.option('-t, --n-tags ', 'n favorites per user', myParseInt, 5); -commander.option('-T, --n-max-tags-per-article ', 'maximum number of tags per article', myParseInt, 3); -commander.option('--force-production', 'allow running in production, DELETES ALL DATA', false); -commander.option('-u, --n-users ', 'n users', myParseInt, 10); -commander.option('-v, --n-favorites-per-user ', 'n favorites per user', myParseInt, 5); -commander.parse(process.argv); +const commander = require('commander') +commander.option( + '-a, --n-articles-per-user ', + 'n articles per user', + myParseInt, + 50 +) +commander.option( + '-c, --n-max-comments-per-article ', + 'maximum number of comments per article', + myParseInt, + 3 +) +commander.option( + '--empty', + 'ignore everything else and make an empty database instead', + false +) +commander.option( + '-f, --n-follows-per-user ', + 'n follows per user', + myParseInt, + 2 +) +commander.option('-t, --n-tags ', 'n favorites per user', myParseInt, 5) +commander.option( + '-T, --n-max-tags-per-article ', + 'maximum number of tags per article', + myParseInt, + 3 +) +commander.option( + '--force-production', + 'allow running in production, DELETES ALL DATA', + false +) +commander.option('-u, --n-users ', 'n users', myParseInt, 10) +commander.option( + '-v, --n-favorites-per-user ', + 'n favorites per user', + myParseInt, + 5 +) +commander.parse(process.argv) if (!commander.forceProduction) { assert(!config.isProduction) } -(async () => { -const test_lib = require('../test_lib') -const sequelize = await test_lib.generateDemoData({ - directory: path.dirname(__dirname), - nArticlesPerUser: commander.nArticlesPerUser, - nMaxCommentsPerArticle: commander.nMaxCommentsPerArticle, - nMaxTagsPerArticle: commander.nMaxTagsPerArticle, - nFavoritesPerUser: commander.nFavoritesPerUser, - nFollowsPerUser: commander.nFollowsPerUser, - nRags: commander.nTags, - nUsers: commander.nUsers, -}) -await sequelize.close() +;(async () => { + const test_lib = require('../test_lib') + const sequelize = await test_lib.generateDemoData({ + directory: path.dirname(__dirname), + empty: commander.empty, + nArticlesPerUser: commander.nArticlesPerUser, + nMaxCommentsPerArticle: commander.nMaxCommentsPerArticle, + nMaxTagsPerArticle: commander.nMaxTagsPerArticle, + nFavoritesPerUser: commander.nFavoritesPerUser, + nFollowsPerUser: commander.nFollowsPerUser, + nRags: commander.nTags, + nUsers: commander.nUsers, + verbose: true, + }) + await sequelize.close() })() diff --git a/bin/sync-db.js b/bin/sync-db.js new file mode 100755 index 00000000..656f7784 --- /dev/null +++ b/bin/sync-db.js @@ -0,0 +1,42 @@ +#!/usr/bin/env node + +// Sync the database. If the database exists, migrate. +// Otherwise, just create directly from the latest DB settings to speed things up. +// +// Originally added for next build since we don't know how to run hooks. +// before next build, and the database wouldn't exist otherwise. + +;(async () => { + const path = require('path') + const child_process = require('child_process') + const { DatabaseError } = require('sequelize') + const config = require('../front/config') + const models = require('../models') + + const sequelize = models.getSequelize(path.dirname(__dirname)) + let dbEmpty = true + try { + await sequelize.models.SequelizeMeta.findOne() + dbEmpty = false + } catch (e) { + if (e instanceof DatabaseError) { + await models.sync(sequelize) + } + } + if (!dbEmpty) { + const env = process.env + if (config.postgres) { + env.NODE_ENV = 'production' + } + const out = child_process.spawnSync( + 'npx', + ['sequelize-cli', 'db:migrate'], + { + env, + } + ) + console.error(out.stdout.toString()) + console.error(out.stderr.toString()) + process.exit(out.status) + } +})() diff --git a/config/index.js b/config/index.js deleted file mode 100644 index eb19e24b..00000000 --- a/config/index.js +++ /dev/null @@ -1,11 +0,0 @@ -const path = require('path') -const fs = require('fs') - -module.exports = { - apiPath: '/api', - databaseUrl: process.env.DATABASE_URL || '', - isProduction: process.env.NODE_ENV === 'production', - secret: process.env.NODE_ENV === 'production' ? process.env.SECRET : 'secret', - port: process.env.PORT || 3000, - verbose: process.env.VERBOSE -} diff --git a/cypress.config.js b/cypress.config.js new file mode 100644 index 00000000..7cb5a510 --- /dev/null +++ b/cypress.config.js @@ -0,0 +1,34 @@ +import { defineConfig } from 'cypress'; +import { faker } from '@faker-js/faker'; +import { clear } from './dataBase'; + +module.exports = defineConfig({ + e2e: { + baseUrl: 'http://localhost:3000', + setupNodeEvents(on, config) { + on('task', { + generateUser() { + let randomNumber = Math.ceil(Math.random(1000) * 1000); + let userName = faker.name.firstName() + `${randomNumber}`; + return { + username: userName.toLowerCase(), + email: 'test'+`${randomNumber}`+'@mail.com', + password: '12345Qwert!', + }; + }, + generateArticle() { + return { + title: faker.lorem.word(), + description: faker.lorem.words(), + body: faker.lorem.words(), + tag: faker.lorem.word() + };; + }, + 'db:clear'() { + clear(); + return null; + }, + }); + }, + }, +}); diff --git a/cypress/e2e/article.cy.js b/cypress/e2e/article.cy.js new file mode 100644 index 00000000..a5819c51 --- /dev/null +++ b/cypress/e2e/article.cy.js @@ -0,0 +1,24 @@ +/// +/// + +describe('Article', () => { + before(() => { + + }); + + beforeEach(() => { + cy.task('db:clear'); + }); + + it('should be created using New Article form', () => { + + }); + + it('should be edited using Edit button', () => { + + }); + + it('should be deleted using Delete button', () => { + + }); +}); diff --git a/cypress/e2e/settings.cy.js b/cypress/e2e/settings.cy.js new file mode 100644 index 00000000..2715de13 --- /dev/null +++ b/cypress/e2e/settings.cy.js @@ -0,0 +1,70 @@ +/// + +import { faker } from '@faker-js/faker'; +import { PageObject } from '../support/PageObject'; + +const page = new PageObject(); + +describe('Settings Page - profile updates', () => { + before(() => { + // wyczyść bazę danych przed uruchomieniem testów + cy.task('db:reset'); + }); + + beforeEach(() => { + // logowanie istniejącego użytkownika + cy.signInAsTestUser(); + cy.visit('/settings'); + }); + + it('should update bio successfully', () => { + const newBio = faker.lorem.sentence(); + + cy.get('[data-cy="settings-bio"]').clear(); + cy.get('[data-cy="settings-bio"]').type(newBio); + + cy.get('[data-cy="settings-submit"]').click(); + + cy.reload(); + + cy.get('[data-cy="settings-bio"]').should('have.value', newBio); + }); + + it('should update username successfully', () => { + const newUsername = faker.internet.userName(); + + cy.get('[data-cy="settings-username"]').clear(); + cy.get('[data-cy="settings-username"]').type(newUsername); + + cy.get('[data-cy="settings-submit"]').click(); + + cy.reload(); + + cy.get('[data-cy="settings-username"]').should('have.value', newUsername); + }); + + it('should update email successfully', () => { + const newEmail = faker.internet.email(); + + cy.get('[data-cy="settings-email"]').clear(); + cy.get('[data-cy="settings-email"]').type(newEmail); + + cy.get('[data-cy="settings-submit"]').click(); + + cy.reload(); + + cy.get('[data-cy="settings-email"]').should('have.value', newEmail); + }); + + it('should update password successfully', () => { + const newPassword = faker.internet.password(10); + + cy.get('[data-cy="settings-password"]').clear(); + cy.get('[data-cy="settings-password"]').type(newPassword); + + cy.get('[data-cy="settings-submit"]').click(); + + // Sprawdzenie komunikatu o sukcesie + cy.get('body').should('contain.text', 'Update successful'); + }); +}); diff --git a/cypress/e2e/signIn.cy.js b/cypress/e2e/signIn.cy.js new file mode 100644 index 00000000..130e6165 --- /dev/null +++ b/cypress/e2e/signIn.cy.js @@ -0,0 +1,34 @@ +/// +/// + +import SignInPageObject from '../support/pages/signIn.pageObject'; +import homePageObject from '../support/pages/home.pageObject'; + +const signInPage = new SignInPageObject(); +const homePage = new homePageObject(); + +describe('Sign In page', () => { + let user; + + before(() => { + cy.task('db:clear'); + cy.task('generateUser').then((generateUser) => { + user = generateUser; + }); + }); + + it('should provide an ability to log in with existing credentials', () => { + signInPage.visit(); + cy.register(user.email, user.username, user.password); + + signInPage.typeEmail(user.email); + signInPage.typePassword(user.password); + signInPage.clickSignInBtn(); + + homePage.assertHeaderContainUsername(user.username); + }); + + it('should not provide an ability to log in with wrong credentials', () => { + + }); +}); diff --git a/cypress/e2e/signUp.cy.js b/cypress/e2e/signUp.cy.js new file mode 100644 index 00000000..574c1c58 --- /dev/null +++ b/cypress/e2e/signUp.cy.js @@ -0,0 +1,13 @@ +/// +/// + +describe('Sign Up page', () => { + + before(() => { + + }); + + it('should ...', () => { + + }); +}); diff --git a/cypress/e2e/user.cy.js b/cypress/e2e/user.cy.js new file mode 100644 index 00000000..9becda35 --- /dev/null +++ b/cypress/e2e/user.cy.js @@ -0,0 +1,12 @@ +/// +/// + +describe('Follow/unfollow button', () => { + before(() => { + + }); + + it.skip('should provide an ability to follow the another user', () => { + + }); +}); diff --git a/cypress/fixtures/example.json b/cypress/fixtures/example.json new file mode 100644 index 00000000..02e42543 --- /dev/null +++ b/cypress/fixtures/example.json @@ -0,0 +1,5 @@ +{ + "name": "Using fixtures to represent data", + "email": "hello@cypress.io", + "body": "Fixtures are a great way to mock data for responses to routes" +} diff --git a/cypress/support/PageObject.js b/cypress/support/PageObject.js new file mode 100644 index 00000000..231b1a49 --- /dev/null +++ b/cypress/support/PageObject.js @@ -0,0 +1,7 @@ +class PageObject { + visit(url) { + cy.visit(url || this.url); + } +} + +export default PageObject; diff --git a/cypress/support/commands.js b/cypress/support/commands.js new file mode 100644 index 00000000..1507eebc --- /dev/null +++ b/cypress/support/commands.js @@ -0,0 +1,62 @@ +/// +// *********************************************** +// This example commands.ts shows you how to +// create various custom commands and overwrite +// existing commands. +// +// For more comprehensive examples of custom +// commands please read more here: +// https://on.cypress.io/custom-commands +// *********************************************** +// +// +// -- This is a parent command -- +// Cypress.Commands.add('login', (email, password) => { ... }) +// +// +// -- This is a child command -- +// Cypress.Commands.add('drag', { prevSubject: 'element'}, (subject, options) => { ... }) +// +// +// -- This is a dual command -- +// Cypress.Commands.add('dismiss', { prevSubject: 'optional'}, (subject, options) => { ... }) +// +// +// -- This will overwrite an existing command -- +// Cypress.Commands.overwrite('visit', (originalFn, url, options) => { ... }) +// + +Cypress.Commands.add('getByDataCy', (selector) => { + cy.get(`[data-cy^="${selector}"]`); +}); + +Cypress.Commands.add('register', (email = 'riot@qa.team', username = 'riot', password = '12345Qwert!') => { + cy.request('POST', '/api/users', { + user: { + email, + username, + password + } + }); +}); + +Cypress.Commands.add('login', (email = 'riot@qa.team', username = 'riot', password = '12345Qwert!') => { + cy.request('POST', '/api/users', { + user: { + email, + username, + password + } + }).then(response => { + const user = { + bio: response.body.user.bio, + effectiveImage: "https://static.productionready.io/images/smiley-cyrus.jpg", + email: response.body.user.email, + image: response.body.user.image, + token: response.body.user.token, + username: response.body.user.username, + }; + window.localStorage.setItem('user', JSON.stringify(user)); + cy.setCookie('auth', response.body.user.token); + }); +}); diff --git a/cypress/support/e2e.js b/cypress/support/e2e.js new file mode 100644 index 00000000..f80f74f8 --- /dev/null +++ b/cypress/support/e2e.js @@ -0,0 +1,20 @@ +// *********************************************************** +// This example support/e2e.ts is processed and +// loaded automatically before your test files. +// +// This is a great place to put global configuration and +// behavior that modifies Cypress. +// +// You can change the location of this file or turn off +// automatically serving support files with the +// 'supportFile' configuration option. +// +// You can read more here: +// https://on.cypress.io/configuration +// *********************************************************** + +// Import commands.js using ES2015 syntax: +import './commands' + +// Alternatively you can use CommonJS syntax: +// require('./commands') \ No newline at end of file diff --git a/cypress/support/index.d.ts b/cypress/support/index.d.ts new file mode 100644 index 00000000..845cd164 --- /dev/null +++ b/cypress/support/index.d.ts @@ -0,0 +1,9 @@ +/// + +declare namespace Cypress { + interface Chainable { + getByDataCy(selector: string): Chainable + register(email: string, username: string, password: string): Chainable + login(email: string, username: string, password: string): Chainable + } +} diff --git a/cypress/support/pages/home.pageObject.js b/cypress/support/pages/home.pageObject.js new file mode 100644 index 00000000..34ddd67a --- /dev/null +++ b/cypress/support/pages/home.pageObject.js @@ -0,0 +1,16 @@ +import PageObject from '../PageObject'; + +class HomePageObject extends PageObject { + url = '/#/'; + + get usernameLink() { + return cy.getByDataCy('profile-link'); + } + + assertHeaderContainUsername(username) { + this.usernameLink + .should('contain', username); + } +} + +export default HomePageObject; diff --git a/cypress/support/pages/signIn.pageObject.js b/cypress/support/pages/signIn.pageObject.js new file mode 100644 index 00000000..01a7bd65 --- /dev/null +++ b/cypress/support/pages/signIn.pageObject.js @@ -0,0 +1,31 @@ +import PageObject from '../PageObject'; + +class SignInPageObject extends PageObject { + url = '/user/login'; + + get emailField() { + return cy.getByDataCy('email-sign-in'); + } + + get passwordField() { + return cy.getByDataCy('password-sign-in'); + } + + get signInBtn() { + return cy.getByDataCy('sign-in-btn'); + } + + typeEmail(email) { + this.emailField.type(email); + } + + typePassword(password) { + this.passwordField.type(password); + } + + clickSignInBtn() { + this.signInBtn.click(); + } +} + +export default SignInPageObject; diff --git a/dataBase.js b/dataBase.js new file mode 100644 index 00000000..41f0ea23 --- /dev/null +++ b/dataBase.js @@ -0,0 +1,30 @@ +const { Sequelize } = require('sequelize'); + +const sequilize = new Sequelize({ + dialect: 'sqlite', + storage: './db.sqlite3' +}); + +async function clear() { + const t = await sequilize.transaction(); + + try { + await sequilize.query('DELETE FROM Article;'); + await sequilize.query('DELETE FROM User;'); + await sequilize.query('DELETE FROM Tag;'); + await sequilize.query('DELETE FROM ArticleTag;'); + await sequilize.query('DELETE FROM UserFollowUser;'); + await sequilize.query('DELETE FROM UserFavoriteArticle;'); + await sequilize.query('DELETE FROM Comment;'); + + await t.commit(); + + console.log('DB was cleared'); + } catch (error) { + await t.rollback(); + + console.log(`Can't clear DB`); + } +} + +module.exports = { clear }; diff --git a/db.ts b/db.ts new file mode 100644 index 00000000..04f75d1b --- /dev/null +++ b/db.ts @@ -0,0 +1,9 @@ +import path from 'path' + +const models = require('./models') + +// TODO sync. But we have to stop the server +// before listen for that. Don't know how to do it. +const sequelize = models.getSequelize(path.join(process.cwd())) + +export default sequelize diff --git a/demo.productionready.io.main.css b/demo.productionready.io.main.css new file mode 100644 index 00000000..e9170507 --- /dev/null +++ b/demo.productionready.io.main.css @@ -0,0 +1,6382 @@ +/* Copied from http://demo.productionready.io/main.css before that decays like + * everything else in that project, and also to embed in a single webpack package + * for better user experience. The source for this processed CSS was lost, authors + * didn't reply when asked for it, so so be it, we prettified it with prettier. + * to at least make it more understandable if needed: + * https://github.com/gothinkster/realworld/issues/662 + * + * We can't find any readily available specific Mono fonts: + * https://github.com/gothinkster/realworld/discussions/843 + * so just going with none for now. + **/ +.logo-font { + font-family: titillium web, sans-serif; +} +html { + position: relative; + min-height: 100vh; + padding-bottom: 100px; +} /*!* Bootstrap v4.0.0-alpha.2 (http://getbootstrap.com) +* Copyright 2011-2016 Twitter, Inc. +* Licensed under MIT (https://github.com/twbs/bootstrap/blob/master/LICENSE)*/ /*!normalize.css commit fe56763 | MIT License | github.com/necolas/normalize.css*/ +html { + font-family: sans-serif; + -ms-text-size-adjust: 100%; + -webkit-text-size-adjust: 100%; +} +body { + margin: 0; +} +article, +aside, +details, +figcaption, +figure, +footer, +header, +main, +menu, +nav, +section, +summary { + display: block; +} +audio, +canvas, +progress, +video { + display: inline-block; + vertical-align: baseline; +} +audio:not([controls]) { + display: none; + height: 0; +} +[hidden], +template { + display: none; +} +a { + background-color: transparent; +} +a:active { + outline: 0; +} +a:hover { + outline: 0; +} +abbr[title] { + border-bottom: 1px dotted; +} +b, +strong { + font-weight: 700; +} +dfn { + font-style: italic; +} +h1 { + font-size: 2em; + margin: 0.67em 0; +} +mark { + background: #ff0; + color: #000; +} +small { + font-size: 80%; +} +sub, +sup { + font-size: 75%; + line-height: 0; + position: relative; + vertical-align: baseline; +} +sup { + top: -0.5em; +} +sub { + bottom: -0.25em; +} +img { + border: 0; +} +svg:not(:root) { + overflow: hidden; +} +figure { + margin: 1em 40px; +} +hr { + box-sizing: content-box; + height: 0; +} +pre { + overflow: auto; +} +code, +kbd, +pre, +samp { + font-family: monospace, monospace; + font-size: 1em; +} +button, +input, +optgroup, +select, +textarea { + color: inherit; + font: inherit; + margin: 0; +} +button { + overflow: visible; +} +button, +select { + text-transform: none; +} +button, +html input[type='button'], +input[type='reset'], +input[type='submit'] { + -webkit-appearance: button; + cursor: pointer; +} +button[disabled], +html input[disabled] { + cursor: default; +} +button::-moz-focus-inner, +input::-moz-focus-inner { + border: 0; + padding: 0; +} +input { + line-height: normal; +} +input[type='checkbox'], +input[type='radio'] { + box-sizing: border-box; + padding: 0; +} +input[type='number']::-webkit-inner-spin-button, +input[type='number']::-webkit-outer-spin-button { + height: auto; +} +input[type='search'] { + -webkit-appearance: textfield; +} +input[type='search']::-webkit-search-cancel-button, +input[type='search']::-webkit-search-decoration { + -webkit-appearance: none; +} +fieldset { + border: 1px solid silver; + margin: 0 2px; + padding: 0.35em 0.625em 0.75em; +} +legend { + border: 0; + padding: 0; +} +textarea { + overflow: auto; +} +optgroup { + font-weight: 700; +} +table { + border-collapse: collapse; + border-spacing: 0; +} +td, +th { + padding: 0; +} +@media print { + *, + *::before, + *::after, + *::first-letter, + *::first-line { + text-shadow: none !important; + box-shadow: none !important; + } + a, + a:visited { + text-decoration: underline; + } + abbr[title]::after { + content: ' (' attr(title) ')'; + } + pre, + blockquote { + border: 1px solid #999; + page-break-inside: avoid; + } + thead { + display: table-header-group; + } + tr, + img { + page-break-inside: avoid; + } + p, + h2, + h3 { + orphans: 3; + widows: 3; + } + h2, + h3 { + page-break-after: avoid; + } + .navbar { + display: none; + } + .btn > .caret, + .dropup > .btn > .caret { + border-top-color: #000 !important; + } + .tag { + border: 1px solid #000; + } + .table { + border-collapse: collapse !important; + } + .table td, + .table th { + background-color: #fff !important; + } + .table-bordered th, + .table-bordered td { + border: 1px solid #ddd !important; + } +} +html { + box-sizing: border-box; +} +*, +*::before, +*::after { + box-sizing: inherit; +} +@-ms-viewport { + width: device-width; +} +html { + font-size: 16px; + -ms-overflow-style: scrollbar; + -webkit-tap-highlight-color: transparent; +} +body { + font-family: source sans pro, sans-serif; + font-size: 1rem; + line-height: 1.5; + color: #373a3c; + background-color: #fff; +} +[tabindex='-1']:focus { + outline: none !important; +} +h1, +h2, +h3, +h4, +h5, +h6 { + margin-top: 0; + margin-bottom: 0.5rem; +} +p { + margin-top: 0; + margin-bottom: 1rem; +} +abbr[title], +abbr[data-original-title] { + cursor: help; + border-bottom: 1px dotted #818a91; +} +address { + margin-bottom: 1rem; + font-style: normal; + line-height: inherit; +} +ol, +ul, +dl { + margin-top: 0; + margin-bottom: 1rem; +} +ol ol, +ul ul, +ol ul, +ul ol { + margin-bottom: 0; +} +dt { + font-weight: 700; +} +dd { + margin-bottom: 0.5rem; + margin-left: 0; +} +blockquote { + margin: 0 0 1rem; +} +a { + color: #5cb85c; + text-decoration: none; +} +a:focus, +a:hover { + color: #3d8b3d; + text-decoration: underline; +} +a:focus { + outline: thin dotted; + outline: 5px auto -webkit-focus-ring-color; + outline-offset: -2px; +} +a:not([href]) { + color: inherit; + text-decoration: none; +} +a:not([href]):focus, +a:not([href]):hover { + color: inherit; + text-decoration: none; +} +a:not([href]):focus { + outline: none; +} +pre { + margin-top: 0; + margin-bottom: 1rem; +} +figure { + margin: 0 0 1rem; +} +img { + vertical-align: middle; +} +[role='button'] { + cursor: pointer; +} +a, +area, +button, +[role='button'], +input, +label, +select, +summary, +textarea { + touch-action: manipulation; +} +table { + background-color: transparent; +} +caption { + padding-top: 0.75rem; + padding-bottom: 0.75rem; + color: #818a91; + text-align: left; + caption-side: bottom; +} +th { + text-align: left; +} +label { + display: inline-block; + margin-bottom: 0.5rem; +} +button:focus { + outline: 1px dotted; + outline: 5px auto -webkit-focus-ring-color; +} +input, +button, +select, +textarea { + margin: 0; + line-height: inherit; + border-radius: 0; +} +textarea { + resize: vertical; +} +fieldset { + min-width: 0; + padding: 0; + margin: 0; + border: 0; +} +legend { + display: block; + width: 100%; + padding: 0; + margin-bottom: 0.5rem; + font-size: 1.5rem; + line-height: inherit; +} +input[type='search'] { + -webkit-appearance: none; +} +output { + display: inline-block; +} +[hidden] { + display: none !important; +} +h1, +h2, +h3, +h4, +h5, +h6, +.h1, +.h2, +.h3, +.h4, +.h5, +.h6 { + margin-bottom: 0.5rem; + font-family: inherit; + font-weight: 500; + line-height: 1.1; + color: inherit; +} +h1, +.h1 { + font-size: 2.5rem; +} +h2, +.h2 { + font-size: 2rem; +} +h3, +.h3 { + font-size: 1.75rem; +} +h4, +.h4 { + font-size: 1.5rem; +} +h5, +.h5 { + font-size: 1.25rem; +} +h6, +.h6 { + font-size: 1rem; +} +.lead { + font-size: 1.25rem; + font-weight: 300; +} +.display-1 { + font-size: 6rem; + font-weight: 300; +} +.display-2 { + font-size: 5.5rem; + font-weight: 300; +} +.display-3 { + font-size: 4.5rem; + font-weight: 300; +} +.display-4 { + font-size: 3.5rem; + font-weight: 300; +} +hr { + margin-top: 1rem; + margin-bottom: 1rem; + border: 0; + border-top: 1px solid rgba(0, 0, 0, 0.1); +} +small, +.small { + font-size: 80%; + font-weight: 400; +} +mark, +.mark { + padding: 0.2em; + background-color: #fcf8e3; +} +.list-unstyled { + padding-left: 0; + list-style: none; +} +.list-inline { + padding-left: 0; + list-style: none; +} +.list-inline-item { + display: inline-block; +} +.list-inline-item:not(:last-child) { + margin-right: 5px; +} +.initialism { + font-size: 90%; + text-transform: uppercase; +} +.blockquote { + padding: 0.5rem 1rem; + margin-bottom: 1rem; + font-size: 1.25rem; + border-left: 0.25rem solid #eceeef; +} +.blockquote-footer { + display: block; + font-size: 80%; + color: #818a91; +} +.blockquote-footer::before { + content: '\2014 \00A0'; +} +.blockquote-reverse { + padding-right: 1rem; + padding-left: 0; + text-align: right; + border-right: 0.25rem solid #eceeef; + border-left: 0; +} +.blockquote-reverse .blockquote-footer::before { + content: ''; +} +.blockquote-reverse .blockquote-footer::after { + content: '\00A0 \2014'; +} +.img-fluid, +.carousel-inner > .carousel-item > img, +.carousel-inner > .carousel-item > a > img { + display: block; + max-width: 100%; + height: auto; +} +.img-rounded { + border-radius: 0.3rem; +} +.img-thumbnail { + padding: 0.25rem; + background-color: #fff; + border: 1px solid #ddd; + border-radius: 0.25rem; + transition: all 0.2s ease-in-out; + display: inline-block; + max-width: 100%; + height: auto; +} +.img-circle { + border-radius: 50%; +} +.figure { + display: inline-block; +} +.figure-img { + margin-bottom: 0.5rem; + line-height: 1; +} +.figure-caption { + font-size: 90%; + color: #818a91; +} +code, +kbd, +pre, +samp { + font-family: Menlo, Monaco, Consolas, liberation mono, courier new, monospace; +} +code { + padding: 0.2rem 0.4rem; + font-size: 90%; + color: #bd4147; + background-color: #f7f7f9; + border-radius: 0.25rem; +} +kbd { + padding: 0.2rem 0.4rem; + font-size: 90%; + color: #fff; + background-color: #333; + border-radius: 0.2rem; +} +kbd kbd { + padding: 0; + font-size: 100%; + font-weight: 700; +} +pre { + display: block; + margin-top: 0; + margin-bottom: 1rem; + font-size: 90%; + color: #373a3c; +} +pre code { + padding: 0; + font-size: inherit; + color: inherit; + background-color: transparent; + border-radius: 0; +} +.pre-scrollable { + max-height: 340px; + overflow-y: scroll; +} +.container { + margin-left: auto; + margin-right: auto; + padding-left: 15px; + padding-right: 15px; +} +@media (min-width: 544px) { + .container { + max-width: 576px; + } +} +@media (min-width: 768px) { + .container { + max-width: 720px; + } +} +@media (min-width: 992px) { + .container { + max-width: 940px; + } +} +@media (min-width: 1200px) { + .container { + max-width: 1140px; + } +} +.container-fluid { + margin-left: auto; + margin-right: auto; + padding-left: 15px; + padding-right: 15px; +} +.row { + display: flex; + flex-wrap: wrap; + margin-left: -15px; + margin-right: -15px; +} +.col-xs { + position: relative; + flex-basis: 0; + flex-grow: 1; + max-width: 100%; + min-height: 1px; + padding-right: 15px; + padding-left: 15px; +} +.col-xs-1 { + position: relative; + min-height: 1px; + padding-right: 15px; + padding-left: 15px; + flex: 0 0 8.33333%; + max-width: 8.33333%; +} +.col-xs-2 { + position: relative; + min-height: 1px; + padding-right: 15px; + padding-left: 15px; + flex: 0 0 16.66667%; + max-width: 16.66667%; +} +.col-xs-3 { + position: relative; + min-height: 1px; + padding-right: 15px; + padding-left: 15px; + flex: 0 0 25%; + max-width: 25%; +} +.col-xs-4 { + position: relative; + min-height: 1px; + padding-right: 15px; + padding-left: 15px; + flex: 0 0 33.33333%; + max-width: 33.33333%; +} +.col-xs-5 { + position: relative; + min-height: 1px; + padding-right: 15px; + padding-left: 15px; + flex: 0 0 41.66667%; + max-width: 41.66667%; +} +.col-xs-6 { + position: relative; + min-height: 1px; + padding-right: 15px; + padding-left: 15px; + flex: 0 0 50%; + max-width: 50%; +} +.col-xs-7 { + position: relative; + min-height: 1px; + padding-right: 15px; + padding-left: 15px; + flex: 0 0 58.33333%; + max-width: 58.33333%; +} +.col-xs-8 { + position: relative; + min-height: 1px; + padding-right: 15px; + padding-left: 15px; + flex: 0 0 66.66667%; + max-width: 66.66667%; +} +.col-xs-9 { + position: relative; + min-height: 1px; + padding-right: 15px; + padding-left: 15px; + flex: 0 0 75%; + max-width: 75%; +} +.col-xs-10 { + position: relative; + min-height: 1px; + padding-right: 15px; + padding-left: 15px; + flex: 0 0 83.33333%; + max-width: 83.33333%; +} +.col-xs-11 { + position: relative; + min-height: 1px; + padding-right: 15px; + padding-left: 15px; + flex: 0 0 91.66667%; + max-width: 91.66667%; +} +.col-xs-12 { + position: relative; + min-height: 1px; + padding-right: 15px; + padding-left: 15px; + flex: 0 0 100%; + max-width: 100%; +} +.pull-xs-0 { + right: auto; +} +.pull-xs-1 { + right: 8.33333%; +} +.pull-xs-2 { + right: 16.66667%; +} +.pull-xs-3 { + right: 25%; +} +.pull-xs-4 { + right: 33.33333%; +} +.pull-xs-5 { + right: 41.66667%; +} +.pull-xs-6 { + right: 50%; +} +.pull-xs-7 { + right: 58.33333%; +} +.pull-xs-8 { + right: 66.66667%; +} +.pull-xs-9 { + right: 75%; +} +.pull-xs-10 { + right: 83.33333%; +} +.pull-xs-11 { + right: 91.66667%; +} +.pull-xs-12 { + right: 100%; +} +.push-xs-0 { + left: auto; +} +.push-xs-1 { + left: 8.33333%; +} +.push-xs-2 { + left: 16.66667%; +} +.push-xs-3 { + left: 25%; +} +.push-xs-4 { + left: 33.33333%; +} +.push-xs-5 { + left: 41.66667%; +} +.push-xs-6 { + left: 50%; +} +.push-xs-7 { + left: 58.33333%; +} +.push-xs-8 { + left: 66.66667%; +} +.push-xs-9 { + left: 75%; +} +.push-xs-10 { + left: 83.33333%; +} +.push-xs-11 { + left: 91.66667%; +} +.push-xs-12 { + left: 100%; +} +.offset-xs-1 { + margin-left: 8.33333%; +} +.offset-xs-2 { + margin-left: 16.66667%; +} +.offset-xs-3 { + margin-left: 25%; +} +.offset-xs-4 { + margin-left: 33.33333%; +} +.offset-xs-5 { + margin-left: 41.66667%; +} +.offset-xs-6 { + margin-left: 50%; +} +.offset-xs-7 { + margin-left: 58.33333%; +} +.offset-xs-8 { + margin-left: 66.66667%; +} +.offset-xs-9 { + margin-left: 75%; +} +.offset-xs-10 { + margin-left: 83.33333%; +} +.offset-xs-11 { + margin-left: 91.66667%; +} +@media (min-width: 544px) { + .col-sm { + position: relative; + flex-basis: 0; + flex-grow: 1; + max-width: 100%; + min-height: 1px; + padding-right: 15px; + padding-left: 15px; + } + .col-sm-1 { + position: relative; + min-height: 1px; + padding-right: 15px; + padding-left: 15px; + flex: 0 0 8.33333%; + max-width: 8.33333%; + } + .col-sm-2 { + position: relative; + min-height: 1px; + padding-right: 15px; + padding-left: 15px; + flex: 0 0 16.66667%; + max-width: 16.66667%; + } + .col-sm-3 { + position: relative; + min-height: 1px; + padding-right: 15px; + padding-left: 15px; + flex: 0 0 25%; + max-width: 25%; + } + .col-sm-4 { + position: relative; + min-height: 1px; + padding-right: 15px; + padding-left: 15px; + flex: 0 0 33.33333%; + max-width: 33.33333%; + } + .col-sm-5 { + position: relative; + min-height: 1px; + padding-right: 15px; + padding-left: 15px; + flex: 0 0 41.66667%; + max-width: 41.66667%; + } + .col-sm-6 { + position: relative; + min-height: 1px; + padding-right: 15px; + padding-left: 15px; + flex: 0 0 50%; + max-width: 50%; + } + .col-sm-7 { + position: relative; + min-height: 1px; + padding-right: 15px; + padding-left: 15px; + flex: 0 0 58.33333%; + max-width: 58.33333%; + } + .col-sm-8 { + position: relative; + min-height: 1px; + padding-right: 15px; + padding-left: 15px; + flex: 0 0 66.66667%; + max-width: 66.66667%; + } + .col-sm-9 { + position: relative; + min-height: 1px; + padding-right: 15px; + padding-left: 15px; + flex: 0 0 75%; + max-width: 75%; + } + .col-sm-10 { + position: relative; + min-height: 1px; + padding-right: 15px; + padding-left: 15px; + flex: 0 0 83.33333%; + max-width: 83.33333%; + } + .col-sm-11 { + position: relative; + min-height: 1px; + padding-right: 15px; + padding-left: 15px; + flex: 0 0 91.66667%; + max-width: 91.66667%; + } + .col-sm-12 { + position: relative; + min-height: 1px; + padding-right: 15px; + padding-left: 15px; + flex: 0 0 100%; + max-width: 100%; + } + .pull-sm-0 { + right: auto; + } + .pull-sm-1 { + right: 8.33333%; + } + .pull-sm-2 { + right: 16.66667%; + } + .pull-sm-3 { + right: 25%; + } + .pull-sm-4 { + right: 33.33333%; + } + .pull-sm-5 { + right: 41.66667%; + } + .pull-sm-6 { + right: 50%; + } + .pull-sm-7 { + right: 58.33333%; + } + .pull-sm-8 { + right: 66.66667%; + } + .pull-sm-9 { + right: 75%; + } + .pull-sm-10 { + right: 83.33333%; + } + .pull-sm-11 { + right: 91.66667%; + } + .pull-sm-12 { + right: 100%; + } + .push-sm-0 { + left: auto; + } + .push-sm-1 { + left: 8.33333%; + } + .push-sm-2 { + left: 16.66667%; + } + .push-sm-3 { + left: 25%; + } + .push-sm-4 { + left: 33.33333%; + } + .push-sm-5 { + left: 41.66667%; + } + .push-sm-6 { + left: 50%; + } + .push-sm-7 { + left: 58.33333%; + } + .push-sm-8 { + left: 66.66667%; + } + .push-sm-9 { + left: 75%; + } + .push-sm-10 { + left: 83.33333%; + } + .push-sm-11 { + left: 91.66667%; + } + .push-sm-12 { + left: 100%; + } + .offset-sm-0 { + margin-left: 0%; + } + .offset-sm-1 { + margin-left: 8.33333%; + } + .offset-sm-2 { + margin-left: 16.66667%; + } + .offset-sm-3 { + margin-left: 25%; + } + .offset-sm-4 { + margin-left: 33.33333%; + } + .offset-sm-5 { + margin-left: 41.66667%; + } + .offset-sm-6 { + margin-left: 50%; + } + .offset-sm-7 { + margin-left: 58.33333%; + } + .offset-sm-8 { + margin-left: 66.66667%; + } + .offset-sm-9 { + margin-left: 75%; + } + .offset-sm-10 { + margin-left: 83.33333%; + } + .offset-sm-11 { + margin-left: 91.66667%; + } +} +@media (min-width: 768px) { + .col-md { + position: relative; + flex-basis: 0; + flex-grow: 1; + max-width: 100%; + min-height: 1px; + padding-right: 15px; + padding-left: 15px; + } + .col-md-1 { + position: relative; + min-height: 1px; + padding-right: 15px; + padding-left: 15px; + flex: 0 0 8.33333%; + max-width: 8.33333%; + } + .col-md-2 { + position: relative; + min-height: 1px; + padding-right: 15px; + padding-left: 15px; + flex: 0 0 16.66667%; + max-width: 16.66667%; + } + .col-md-3 { + position: relative; + min-height: 1px; + padding-right: 15px; + padding-left: 15px; + flex: 0 0 25%; + max-width: 25%; + } + .col-md-4 { + position: relative; + min-height: 1px; + padding-right: 15px; + padding-left: 15px; + flex: 0 0 33.33333%; + max-width: 33.33333%; + } + .col-md-5 { + position: relative; + min-height: 1px; + padding-right: 15px; + padding-left: 15px; + flex: 0 0 41.66667%; + max-width: 41.66667%; + } + .col-md-6 { + position: relative; + min-height: 1px; + padding-right: 15px; + padding-left: 15px; + flex: 0 0 50%; + max-width: 50%; + } + .col-md-7 { + position: relative; + min-height: 1px; + padding-right: 15px; + padding-left: 15px; + flex: 0 0 58.33333%; + max-width: 58.33333%; + } + .col-md-8 { + position: relative; + min-height: 1px; + padding-right: 15px; + padding-left: 15px; + flex: 0 0 66.66667%; + max-width: 66.66667%; + } + .col-md-9 { + position: relative; + min-height: 1px; + padding-right: 15px; + padding-left: 15px; + flex: 0 0 75%; + max-width: 75%; + } + .col-md-10 { + position: relative; + min-height: 1px; + padding-right: 15px; + padding-left: 15px; + flex: 0 0 83.33333%; + max-width: 83.33333%; + } + .col-md-11 { + position: relative; + min-height: 1px; + padding-right: 15px; + padding-left: 15px; + flex: 0 0 91.66667%; + max-width: 91.66667%; + } + .col-md-12 { + position: relative; + min-height: 1px; + padding-right: 15px; + padding-left: 15px; + flex: 0 0 100%; + max-width: 100%; + } + .pull-md-0 { + right: auto; + } + .pull-md-1 { + right: 8.33333%; + } + .pull-md-2 { + right: 16.66667%; + } + .pull-md-3 { + right: 25%; + } + .pull-md-4 { + right: 33.33333%; + } + .pull-md-5 { + right: 41.66667%; + } + .pull-md-6 { + right: 50%; + } + .pull-md-7 { + right: 58.33333%; + } + .pull-md-8 { + right: 66.66667%; + } + .pull-md-9 { + right: 75%; + } + .pull-md-10 { + right: 83.33333%; + } + .pull-md-11 { + right: 91.66667%; + } + .pull-md-12 { + right: 100%; + } + .push-md-0 { + left: auto; + } + .push-md-1 { + left: 8.33333%; + } + .push-md-2 { + left: 16.66667%; + } + .push-md-3 { + left: 25%; + } + .push-md-4 { + left: 33.33333%; + } + .push-md-5 { + left: 41.66667%; + } + .push-md-6 { + left: 50%; + } + .push-md-7 { + left: 58.33333%; + } + .push-md-8 { + left: 66.66667%; + } + .push-md-9 { + left: 75%; + } + .push-md-10 { + left: 83.33333%; + } + .push-md-11 { + left: 91.66667%; + } + .push-md-12 { + left: 100%; + } + .offset-md-0 { + margin-left: 0%; + } + .offset-md-1 { + margin-left: 8.33333%; + } + .offset-md-2 { + margin-left: 16.66667%; + } + .offset-md-3 { + margin-left: 25%; + } + .offset-md-4 { + margin-left: 33.33333%; + } + .offset-md-5 { + margin-left: 41.66667%; + } + .offset-md-6 { + margin-left: 50%; + } + .offset-md-7 { + margin-left: 58.33333%; + } + .offset-md-8 { + margin-left: 66.66667%; + } + .offset-md-9 { + margin-left: 75%; + } + .offset-md-10 { + margin-left: 83.33333%; + } + .offset-md-11 { + margin-left: 91.66667%; + } +} +@media (min-width: 992px) { + .col-lg { + position: relative; + flex-basis: 0; + flex-grow: 1; + max-width: 100%; + min-height: 1px; + padding-right: 15px; + padding-left: 15px; + } + .col-lg-1 { + position: relative; + min-height: 1px; + padding-right: 15px; + padding-left: 15px; + flex: 0 0 8.33333%; + max-width: 8.33333%; + } + .col-lg-2 { + position: relative; + min-height: 1px; + padding-right: 15px; + padding-left: 15px; + flex: 0 0 16.66667%; + max-width: 16.66667%; + } + .col-lg-3 { + position: relative; + min-height: 1px; + padding-right: 15px; + padding-left: 15px; + flex: 0 0 25%; + max-width: 25%; + } + .col-lg-4 { + position: relative; + min-height: 1px; + padding-right: 15px; + padding-left: 15px; + flex: 0 0 33.33333%; + max-width: 33.33333%; + } + .col-lg-5 { + position: relative; + min-height: 1px; + padding-right: 15px; + padding-left: 15px; + flex: 0 0 41.66667%; + max-width: 41.66667%; + } + .col-lg-6 { + position: relative; + min-height: 1px; + padding-right: 15px; + padding-left: 15px; + flex: 0 0 50%; + max-width: 50%; + } + .col-lg-7 { + position: relative; + min-height: 1px; + padding-right: 15px; + padding-left: 15px; + flex: 0 0 58.33333%; + max-width: 58.33333%; + } + .col-lg-8 { + position: relative; + min-height: 1px; + padding-right: 15px; + padding-left: 15px; + flex: 0 0 66.66667%; + max-width: 66.66667%; + } + .col-lg-9 { + position: relative; + min-height: 1px; + padding-right: 15px; + padding-left: 15px; + flex: 0 0 75%; + max-width: 75%; + } + .col-lg-10 { + position: relative; + min-height: 1px; + padding-right: 15px; + padding-left: 15px; + flex: 0 0 83.33333%; + max-width: 83.33333%; + } + .col-lg-11 { + position: relative; + min-height: 1px; + padding-right: 15px; + padding-left: 15px; + flex: 0 0 91.66667%; + max-width: 91.66667%; + } + .col-lg-12 { + position: relative; + min-height: 1px; + padding-right: 15px; + padding-left: 15px; + flex: 0 0 100%; + max-width: 100%; + } + .pull-lg-0 { + right: auto; + } + .pull-lg-1 { + right: 8.33333%; + } + .pull-lg-2 { + right: 16.66667%; + } + .pull-lg-3 { + right: 25%; + } + .pull-lg-4 { + right: 33.33333%; + } + .pull-lg-5 { + right: 41.66667%; + } + .pull-lg-6 { + right: 50%; + } + .pull-lg-7 { + right: 58.33333%; + } + .pull-lg-8 { + right: 66.66667%; + } + .pull-lg-9 { + right: 75%; + } + .pull-lg-10 { + right: 83.33333%; + } + .pull-lg-11 { + right: 91.66667%; + } + .pull-lg-12 { + right: 100%; + } + .push-lg-0 { + left: auto; + } + .push-lg-1 { + left: 8.33333%; + } + .push-lg-2 { + left: 16.66667%; + } + .push-lg-3 { + left: 25%; + } + .push-lg-4 { + left: 33.33333%; + } + .push-lg-5 { + left: 41.66667%; + } + .push-lg-6 { + left: 50%; + } + .push-lg-7 { + left: 58.33333%; + } + .push-lg-8 { + left: 66.66667%; + } + .push-lg-9 { + left: 75%; + } + .push-lg-10 { + left: 83.33333%; + } + .push-lg-11 { + left: 91.66667%; + } + .push-lg-12 { + left: 100%; + } + .offset-lg-0 { + margin-left: 0%; + } + .offset-lg-1 { + margin-left: 8.33333%; + } + .offset-lg-2 { + margin-left: 16.66667%; + } + .offset-lg-3 { + margin-left: 25%; + } + .offset-lg-4 { + margin-left: 33.33333%; + } + .offset-lg-5 { + margin-left: 41.66667%; + } + .offset-lg-6 { + margin-left: 50%; + } + .offset-lg-7 { + margin-left: 58.33333%; + } + .offset-lg-8 { + margin-left: 66.66667%; + } + .offset-lg-9 { + margin-left: 75%; + } + .offset-lg-10 { + margin-left: 83.33333%; + } + .offset-lg-11 { + margin-left: 91.66667%; + } +} +@media (min-width: 1200px) { + .col-xl { + position: relative; + flex-basis: 0; + flex-grow: 1; + max-width: 100%; + min-height: 1px; + padding-right: 15px; + padding-left: 15px; + } + .col-xl-1 { + position: relative; + min-height: 1px; + padding-right: 15px; + padding-left: 15px; + flex: 0 0 8.33333%; + max-width: 8.33333%; + } + .col-xl-2 { + position: relative; + min-height: 1px; + padding-right: 15px; + padding-left: 15px; + flex: 0 0 16.66667%; + max-width: 16.66667%; + } + .col-xl-3 { + position: relative; + min-height: 1px; + padding-right: 15px; + padding-left: 15px; + flex: 0 0 25%; + max-width: 25%; + } + .col-xl-4 { + position: relative; + min-height: 1px; + padding-right: 15px; + padding-left: 15px; + flex: 0 0 33.33333%; + max-width: 33.33333%; + } + .col-xl-5 { + position: relative; + min-height: 1px; + padding-right: 15px; + padding-left: 15px; + flex: 0 0 41.66667%; + max-width: 41.66667%; + } + .col-xl-6 { + position: relative; + min-height: 1px; + padding-right: 15px; + padding-left: 15px; + flex: 0 0 50%; + max-width: 50%; + } + .col-xl-7 { + position: relative; + min-height: 1px; + padding-right: 15px; + padding-left: 15px; + flex: 0 0 58.33333%; + max-width: 58.33333%; + } + .col-xl-8 { + position: relative; + min-height: 1px; + padding-right: 15px; + padding-left: 15px; + flex: 0 0 66.66667%; + max-width: 66.66667%; + } + .col-xl-9 { + position: relative; + min-height: 1px; + padding-right: 15px; + padding-left: 15px; + flex: 0 0 75%; + max-width: 75%; + } + .col-xl-10 { + position: relative; + min-height: 1px; + padding-right: 15px; + padding-left: 15px; + flex: 0 0 83.33333%; + max-width: 83.33333%; + } + .col-xl-11 { + position: relative; + min-height: 1px; + padding-right: 15px; + padding-left: 15px; + flex: 0 0 91.66667%; + max-width: 91.66667%; + } + .col-xl-12 { + position: relative; + min-height: 1px; + padding-right: 15px; + padding-left: 15px; + flex: 0 0 100%; + max-width: 100%; + } + .pull-xl-0 { + right: auto; + } + .pull-xl-1 { + right: 8.33333%; + } + .pull-xl-2 { + right: 16.66667%; + } + .pull-xl-3 { + right: 25%; + } + .pull-xl-4 { + right: 33.33333%; + } + .pull-xl-5 { + right: 41.66667%; + } + .pull-xl-6 { + right: 50%; + } + .pull-xl-7 { + right: 58.33333%; + } + .pull-xl-8 { + right: 66.66667%; + } + .pull-xl-9 { + right: 75%; + } + .pull-xl-10 { + right: 83.33333%; + } + .pull-xl-11 { + right: 91.66667%; + } + .pull-xl-12 { + right: 100%; + } + .push-xl-0 { + left: auto; + } + .push-xl-1 { + left: 8.33333%; + } + .push-xl-2 { + left: 16.66667%; + } + .push-xl-3 { + left: 25%; + } + .push-xl-4 { + left: 33.33333%; + } + .push-xl-5 { + left: 41.66667%; + } + .push-xl-6 { + left: 50%; + } + .push-xl-7 { + left: 58.33333%; + } + .push-xl-8 { + left: 66.66667%; + } + .push-xl-9 { + left: 75%; + } + .push-xl-10 { + left: 83.33333%; + } + .push-xl-11 { + left: 91.66667%; + } + .push-xl-12 { + left: 100%; + } + .offset-xl-0 { + margin-left: 0%; + } + .offset-xl-1 { + margin-left: 8.33333%; + } + .offset-xl-2 { + margin-left: 16.66667%; + } + .offset-xl-3 { + margin-left: 25%; + } + .offset-xl-4 { + margin-left: 33.33333%; + } + .offset-xl-5 { + margin-left: 41.66667%; + } + .offset-xl-6 { + margin-left: 50%; + } + .offset-xl-7 { + margin-left: 58.33333%; + } + .offset-xl-8 { + margin-left: 66.66667%; + } + .offset-xl-9 { + margin-left: 75%; + } + .offset-xl-10 { + margin-left: 83.33333%; + } + .offset-xl-11 { + margin-left: 91.66667%; + } +} +.table { + width: 100%; + max-width: 100%; + margin-bottom: 1rem; +} +.table th, +.table td { + padding: 0.75rem; + vertical-align: top; + border-top: 1px solid #eceeef; +} +.table thead th { + vertical-align: bottom; + border-bottom: 2px solid #eceeef; +} +.table tbody + tbody { + border-top: 2px solid #eceeef; +} +.table .table { + background-color: #fff; +} +.table-sm th, +.table-sm td { + padding: 0.3rem; +} +.table-bordered { + border: 1px solid #eceeef; +} +.table-bordered th, +.table-bordered td { + border: 1px solid #eceeef; +} +.table-bordered thead th, +.table-bordered thead td { + border-bottom-width: 2px; +} +.table-striped tbody tr:nth-of-type(odd) { + background-color: #f9f9f9; +} +.table-hover tbody tr:hover { + background-color: #f5f5f5; +} +.table-active, +.table-active > th, +.table-active > td { + background-color: #f5f5f5; +} +.table-hover .table-active:hover { + background-color: #e8e8e8; +} +.table-hover .table-active:hover > td, +.table-hover .table-active:hover > th { + background-color: #e8e8e8; +} +.table-success, +.table-success > th, +.table-success > td { + background-color: #dff0d8; +} +.table-hover .table-success:hover { + background-color: #d0e9c6; +} +.table-hover .table-success:hover > td, +.table-hover .table-success:hover > th { + background-color: #d0e9c6; +} +.table-info, +.table-info > th, +.table-info > td { + background-color: #d9edf7; +} +.table-hover .table-info:hover { + background-color: #c4e3f3; +} +.table-hover .table-info:hover > td, +.table-hover .table-info:hover > th { + background-color: #c4e3f3; +} +.table-warning, +.table-warning > th, +.table-warning > td { + background-color: #fcf8e3; +} +.table-hover .table-warning:hover { + background-color: #faf2cc; +} +.table-hover .table-warning:hover > td, +.table-hover .table-warning:hover > th { + background-color: #faf2cc; +} +.table-danger, +.table-danger > th, +.table-danger > td { + background-color: #f2dede; +} +.table-hover .table-danger:hover { + background-color: #ebcccc; +} +.table-hover .table-danger:hover > td, +.table-hover .table-danger:hover > th { + background-color: #ebcccc; +} +.table-responsive { + display: block; + width: 100%; + min-height: 0.01%; + overflow-x: auto; +} +.thead-inverse th { + color: #fff; + background-color: #373a3c; +} +.thead-default th { + color: #55595c; + background-color: #eceeef; +} +.table-inverse { + color: #eceeef; + background-color: #373a3c; +} +.table-inverse.table-bordered { + border: 0; +} +.table-inverse th, +.table-inverse td, +.table-inverse thead th { + border-color: #55595c; +} +.table-reflow thead { + float: left; +} +.table-reflow tbody { + display: block; + white-space: nowrap; +} +.table-reflow th, +.table-reflow td { + border-top: 1px solid #eceeef; + border-left: 1px solid #eceeef; +} +.table-reflow th:last-child, +.table-reflow td:last-child { + border-right: 1px solid #eceeef; +} +.table-reflow thead:last-child tr:last-child th, +.table-reflow thead:last-child tr:last-child td, +.table-reflow tbody:last-child tr:last-child th, +.table-reflow tbody:last-child tr:last-child td, +.table-reflow tfoot:last-child tr:last-child th, +.table-reflow tfoot:last-child tr:last-child td { + border-bottom: 1px solid #eceeef; +} +.table-reflow tr { + float: left; +} +.table-reflow tr th, +.table-reflow tr td { + display: block !important; + border: 1px solid #eceeef; +} +.form-control { + display: block; + width: 100%; + padding: 0.5rem 0.75rem; + font-size: 1rem; + line-height: 1.25; + color: #55595c; + background-color: #fff; + background-image: none; + background-clip: padding-box; + border: 1px solid rgba(0, 0, 0, 0.15); + border-radius: 0.25rem; +} +.form-control::-ms-expand { + background-color: transparent; + border: 0; +} +.form-control:focus { + border-color: #66afe9; + outline: none; +} +.form-control::placeholder { + color: #999; + opacity: 1; +} +.form-control:disabled, +.form-control[readonly] { + background-color: #eceeef; + opacity: 1; +} +.form-control:disabled { + cursor: not-allowed; +} +select.form-control:not([size]):not([multiple]) { + height: 2.5rem; +} +.form-control-file, +.form-control-range { + display: block; +} +.form-control-label { + padding: 0.5rem 0.75rem; + margin-bottom: 0; +} +.form-control-legend { + padding: 0.5rem 0.75rem; + margin-bottom: 0; + font-size: 1rem; +} +_::-webkit-full-page-media.form-control, +input[type='date'].form-control, +input[type='time'].form-control, +input[type='datetime-local'].form-control, +input[type='month'].form-control { + line-height: 2.5rem; +} +_::-webkit-full-page-media.input-sm, +.input-group-sm _::-webkit-full-page-media.form-control, +input[type='date'].input-sm, +.input-group-sm input[type='date'].form-control, +input[type='time'].input-sm, +.input-group-sm input[type='time'].form-control, +input[type='datetime-local'].input-sm, +.input-group-sm input[type='datetime-local'].form-control, +input[type='month'].input-sm, +.input-group-sm input[type='month'].form-control { + line-height: 1.8125rem; +} +_::-webkit-full-page-media.input-lg, +.input-group-lg _::-webkit-full-page-media.form-control, +input[type='date'].input-lg, +.input-group-lg input[type='date'].form-control, +input[type='time'].input-lg, +.input-group-lg input[type='time'].form-control, +input[type='datetime-local'].input-lg, +.input-group-lg input[type='datetime-local'].form-control, +input[type='month'].input-lg, +.input-group-lg input[type='month'].form-control { + line-height: 3.16667rem; +} +.form-control-static { + min-height: 2.5rem; + padding-top: 0.5rem; + padding-bottom: 0.5rem; + margin-bottom: 0; +} +.form-control-static.form-control-sm, +.input-group-sm > .form-control-static.form-control, +.input-group-sm > .form-control-static.input-group-addon, +.input-group-sm > .input-group-btn > .form-control-static.btn, +.form-control-static.form-control-lg, +.input-group-lg > .form-control-static.form-control, +.input-group-lg > .form-control-static.input-group-addon, +.input-group-lg > .input-group-btn > .form-control-static.btn { + padding-right: 0; + padding-left: 0; +} +.form-control-sm, +.input-group-sm > .form-control, +.input-group-sm > .input-group-addon, +.input-group-sm > .input-group-btn > .btn { + padding: 0.25rem 0.5rem; + font-size: 0.875rem; + border-radius: 0.2rem; +} +.form-control-lg, +.input-group-lg > .form-control, +.input-group-lg > .input-group-addon, +.input-group-lg > .input-group-btn > .btn { + padding: 0.75rem 1.5rem; + font-size: 1.25rem; + border-radius: 0.3rem; +} +.form-group { + margin-bottom: 1rem; +} +.radio, +.checkbox { + position: relative; + display: block; + margin-bottom: 0.75rem; +} +.radio label, +.checkbox label { + padding-left: 1.25rem; + margin-bottom: 0; + cursor: pointer; +} +.radio label input:only-child, +.checkbox label input:only-child { + position: static; +} +.radio input[type='radio'], +.radio-inline input[type='radio'], +.checkbox input[type='checkbox'], +.checkbox-inline input[type='checkbox'] { + position: absolute; + margin-top: 0.25rem; + margin-left: -1.25rem; +} +.radio + .radio, +.checkbox + .checkbox { + margin-top: -0.25rem; +} +.radio-inline, +.checkbox-inline { + position: relative; + display: inline-block; + padding-left: 1.25rem; + margin-bottom: 0; + vertical-align: middle; + cursor: pointer; +} +.radio-inline + .radio-inline, +.checkbox-inline + .checkbox-inline { + margin-top: 0; + margin-left: 0.75rem; +} +input[type='radio']:disabled, +input[type='radio'].disabled, +input[type='checkbox']:disabled, +input[type='checkbox'].disabled { + cursor: not-allowed; +} +.radio-inline.disabled, +.checkbox-inline.disabled { + cursor: not-allowed; +} +.radio.disabled label, +.checkbox.disabled label { + cursor: not-allowed; +} +.form-control-success, +.form-control-warning, +.form-control-danger { + padding-right: 2.25rem; + background-repeat: no-repeat; + background-position: center right 0.625rem; + background-size: 1.25rem 1.25rem; +} +.has-success .text-help, +.has-success .form-control-label, +.has-success .radio, +.has-success .checkbox, +.has-success .radio-inline, +.has-success .checkbox-inline, +.has-success.radio label, +.has-success.checkbox label, +.has-success.radio-inline label, +.has-success.checkbox-inline label, +.has-success .custom-control { + color: #5cb85c; +} +.has-success .form-control { + border-color: #5cb85c; +} +.has-success .input-group-addon { + color: #5cb85c; + border-color: #5cb85c; + background-color: #eaf6ea; +} +.has-success .form-control-feedback { + color: #5cb85c; +} +.has-success .form-control-success { + background-image: url(data:image/svg+xml;charset=utf8;base64,PHN2ZyB4bWxucz0naHR0cDovL3d3dy53My5vcmcvMjAwMC9zdmcnIHZpZXdCb3g9JzAgMCA4IDgnPjxwYXRoIGZpbGw9JyM1Y2I4NWMnIGQ9J00yLjMgNi43M0wuNiA0LjUzYy0uNC0xLjA0LjQ2LTEuNCAxLjEtLjhsMS4xIDEuNCAzLjQtMy44Yy42LS42MyAxLjYtLjI3IDEuMi43bC00IDQuNmMtLjQzLjUtLjguNC0xLjEuMXonLz48L3N2Zz4=); +} +.has-warning .text-help, +.has-warning .form-control-label, +.has-warning .radio, +.has-warning .checkbox, +.has-warning .radio-inline, +.has-warning .checkbox-inline, +.has-warning.radio label, +.has-warning.checkbox label, +.has-warning.radio-inline label, +.has-warning.checkbox-inline label, +.has-warning .custom-control { + color: #f0ad4e; +} +.has-warning .form-control { + border-color: #f0ad4e; +} +.has-warning .input-group-addon { + color: #f0ad4e; + border-color: #f0ad4e; + background-color: #fff; +} +.has-warning .form-control-feedback { + color: #f0ad4e; +} +.has-warning .form-control-warning { + background-image: url(data:image/svg+xml;charset=utf8;base64,PHN2ZyB4bWxucz0naHR0cDovL3d3dy53My5vcmcvMjAwMC9zdmcnIHZpZXdCb3g9JzAgMCA4IDgnPjxwYXRoIGZpbGw9JyNmMGFkNGUnIGQ9J000LjQgNS4zMjRoLS44di0yLjQ2aC44em0wIDEuNDJoLS44VjUuODloLjh6TTMuNzYuNjNMLjA0IDcuMDc1Yy0uMTE1LjIuMDE2LjQyNS4yNi40MjZoNy4zOTdjLjI0MiAwIC4zNzItLjIyNi4yNTgtLjQyNkM2LjcyNiA0LjkyNCA1LjQ3IDIuNzkgNC4yNTMuNjNjLS4xMTMtLjE3NC0uMzktLjE3NC0uNDk0IDB6Jy8+PC9zdmc+); +} +.has-danger .text-help, +.has-danger .form-control-label, +.has-danger .radio, +.has-danger .checkbox, +.has-danger .radio-inline, +.has-danger .checkbox-inline, +.has-danger.radio label, +.has-danger.checkbox label, +.has-danger.radio-inline label, +.has-danger.checkbox-inline label, +.has-danger .custom-control { + color: #b85c5c; +} +.has-danger .form-control { + border-color: #b85c5c; +} +.has-danger .input-group-addon { + color: #b85c5c; + border-color: #b85c5c; + background-color: #f6eaea; +} +.has-danger .form-control-feedback { + color: #b85c5c; +} +.has-danger .form-control-danger { + background-image: url(data:image/svg+xml;charset=utf8;base64,PHN2ZyB4bWxucz0naHR0cDovL3d3dy53My5vcmcvMjAwMC9zdmcnIGZpbGw9JyNkOTUzNGYnIHZpZXdCb3g9Jy0yIC0yIDcgNyc+PHBhdGggc3Ryb2tlPScjZDk1MzRmJyBkPSdNMCAwbDMgM20wLTNMMCAzJy8+PGNpcmNsZSByPScuNScvPjxjaXJjbGUgY3g9JzMnIHI9Jy41Jy8+PGNpcmNsZSBjeT0nMycgcj0nLjUnLz48Y2lyY2xlIGN4PSczJyBjeT0nMycgcj0nLjUnLz48L3N2Zz4=); +} +@media (min-width: 544px) { + .form-inline .form-group { + display: inline-block; + margin-bottom: 0; + vertical-align: middle; + } + .form-inline .form-control { + display: inline-block; + width: auto; + vertical-align: middle; + } + .form-inline .form-control-static { + display: inline-block; + } + .form-inline .input-group { + display: inline-table; + vertical-align: middle; + } + .form-inline .input-group .input-group-addon, + .form-inline .input-group .input-group-btn, + .form-inline .input-group .form-control { + width: auto; + } + .form-inline .input-group > .form-control { + width: 100%; + } + .form-inline .form-control-label { + margin-bottom: 0; + vertical-align: middle; + } + .form-inline .radio, + .form-inline .checkbox { + display: inline-block; + margin-top: 0; + margin-bottom: 0; + vertical-align: middle; + } + .form-inline .radio label, + .form-inline .checkbox label { + padding-left: 0; + } + .form-inline .radio input[type='radio'], + .form-inline .checkbox input[type='checkbox'] { + position: relative; + margin-left: 0; + } + .form-inline .has-feedback .form-control-feedback { + top: 0; + } +} +.btn { + display: inline-block; + font-weight: 400; + line-height: 1.25; + text-align: center; + white-space: nowrap; + vertical-align: middle; + cursor: pointer; + user-select: none; + border: 1px solid transparent; + padding: 0.5rem 1rem; + font-size: 1rem; + border-radius: 0.25rem; +} +.btn:focus, +.btn.focus, +.btn:active:focus, +.btn:active.focus, +.btn.active:focus, +.btn.active.focus { + outline: thin dotted; + outline: 5px auto -webkit-focus-ring-color; + outline-offset: -2px; +} +.btn:focus, +.btn:hover { + text-decoration: none; +} +.btn.focus { + text-decoration: none; +} +.btn:active, +.btn.active { + background-image: none; + outline: 0; +} +.btn.disabled, +.btn:disabled { + cursor: not-allowed; + opacity: 0.65; +} +a.btn.disabled, +fieldset[disabled] a.btn { + pointer-events: none; +} +.btn-primary { + color: #fff; + background-color: #5cb85c; + border-color: #5cb85c; +} +.btn-primary:hover { + color: #fff; + background-color: #449d44; + border-color: #419641; +} +.btn-primary:focus, +.btn-primary.focus { + color: #fff; + background-color: #449d44; + border-color: #419641; +} +.btn-primary:active, +.btn-primary.active, +.open > .btn-primary.dropdown-toggle { + color: #fff; + background-color: #449d44; + border-color: #419641; + background-image: none; +} +.btn-primary:active:hover, +.btn-primary:active:focus, +.btn-primary:active.focus, +.btn-primary.active:hover, +.btn-primary.active:focus, +.btn-primary.active.focus, +.open > .btn-primary.dropdown-toggle:hover, +.open > .btn-primary.dropdown-toggle:focus, +.open > .btn-primary.dropdown-toggle.focus { + color: #fff; + background-color: #398439; + border-color: #2d672d; +} +.btn-primary.disabled:focus, +.btn-primary.disabled.focus, +.btn-primary:disabled:focus, +.btn-primary:disabled.focus { + background-color: #5cb85c; + border-color: #5cb85c; +} +.btn-primary.disabled:hover, +.btn-primary:disabled:hover { + background-color: #5cb85c; + border-color: #5cb85c; +} +.btn-secondary { + color: #373a3c; + background-color: #fff; + border-color: #ccc; +} +.btn-secondary:hover { + color: #373a3c; + background-color: #e6e6e6; + border-color: #adadad; +} +.btn-secondary:focus, +.btn-secondary.focus { + color: #373a3c; + background-color: #e6e6e6; + border-color: #adadad; +} +.btn-secondary:active, +.btn-secondary.active, +.open > .btn-secondary.dropdown-toggle { + color: #373a3c; + background-color: #e6e6e6; + border-color: #adadad; + background-image: none; +} +.btn-secondary:active:hover, +.btn-secondary:active:focus, +.btn-secondary:active.focus, +.btn-secondary.active:hover, +.btn-secondary.active:focus, +.btn-secondary.active.focus, +.open > .btn-secondary.dropdown-toggle:hover, +.open > .btn-secondary.dropdown-toggle:focus, +.open > .btn-secondary.dropdown-toggle.focus { + color: #373a3c; + background-color: #d4d4d4; + border-color: #8c8c8c; +} +.btn-secondary.disabled:focus, +.btn-secondary.disabled.focus, +.btn-secondary:disabled:focus, +.btn-secondary:disabled.focus { + background-color: #fff; + border-color: #ccc; +} +.btn-secondary.disabled:hover, +.btn-secondary:disabled:hover { + background-color: #fff; + border-color: #ccc; +} +.btn-info { + color: #fff; + background-color: #5bc0de; + border-color: #5bc0de; +} +.btn-info:hover { + color: #fff; + background-color: #31b0d5; + border-color: #2aabd2; +} +.btn-info:focus, +.btn-info.focus { + color: #fff; + background-color: #31b0d5; + border-color: #2aabd2; +} +.btn-info:active, +.btn-info.active, +.open > .btn-info.dropdown-toggle { + color: #fff; + background-color: #31b0d5; + border-color: #2aabd2; + background-image: none; +} +.btn-info:active:hover, +.btn-info:active:focus, +.btn-info:active.focus, +.btn-info.active:hover, +.btn-info.active:focus, +.btn-info.active.focus, +.open > .btn-info.dropdown-toggle:hover, +.open > .btn-info.dropdown-toggle:focus, +.open > .btn-info.dropdown-toggle.focus { + color: #fff; + background-color: #269abc; + border-color: #1f7e9a; +} +.btn-info.disabled:focus, +.btn-info.disabled.focus, +.btn-info:disabled:focus, +.btn-info:disabled.focus { + background-color: #5bc0de; + border-color: #5bc0de; +} +.btn-info.disabled:hover, +.btn-info:disabled:hover { + background-color: #5bc0de; + border-color: #5bc0de; +} +.btn-success { + color: #fff; + background-color: #5cb85c; + border-color: #5cb85c; +} +.btn-success:hover { + color: #fff; + background-color: #449d44; + border-color: #419641; +} +.btn-success:focus, +.btn-success.focus { + color: #fff; + background-color: #449d44; + border-color: #419641; +} +.btn-success:active, +.btn-success.active, +.open > .btn-success.dropdown-toggle { + color: #fff; + background-color: #449d44; + border-color: #419641; + background-image: none; +} +.btn-success:active:hover, +.btn-success:active:focus, +.btn-success:active.focus, +.btn-success.active:hover, +.btn-success.active:focus, +.btn-success.active.focus, +.open > .btn-success.dropdown-toggle:hover, +.open > .btn-success.dropdown-toggle:focus, +.open > .btn-success.dropdown-toggle.focus { + color: #fff; + background-color: #398439; + border-color: #2d672d; +} +.btn-success.disabled:focus, +.btn-success.disabled.focus, +.btn-success:disabled:focus, +.btn-success:disabled.focus { + background-color: #5cb85c; + border-color: #5cb85c; +} +.btn-success.disabled:hover, +.btn-success:disabled:hover { + background-color: #5cb85c; + border-color: #5cb85c; +} +.btn-warning { + color: #fff; + background-color: #f0ad4e; + border-color: #f0ad4e; +} +.btn-warning:hover { + color: #fff; + background-color: #ec971f; + border-color: #eb9316; +} +.btn-warning:focus, +.btn-warning.focus { + color: #fff; + background-color: #ec971f; + border-color: #eb9316; +} +.btn-warning:active, +.btn-warning.active, +.open > .btn-warning.dropdown-toggle { + color: #fff; + background-color: #ec971f; + border-color: #eb9316; + background-image: none; +} +.btn-warning:active:hover, +.btn-warning:active:focus, +.btn-warning:active.focus, +.btn-warning.active:hover, +.btn-warning.active:focus, +.btn-warning.active.focus, +.open > .btn-warning.dropdown-toggle:hover, +.open > .btn-warning.dropdown-toggle:focus, +.open > .btn-warning.dropdown-toggle.focus { + color: #fff; + background-color: #d58512; + border-color: #b06d0f; +} +.btn-warning.disabled:focus, +.btn-warning.disabled.focus, +.btn-warning:disabled:focus, +.btn-warning:disabled.focus { + background-color: #f0ad4e; + border-color: #f0ad4e; +} +.btn-warning.disabled:hover, +.btn-warning:disabled:hover { + background-color: #f0ad4e; + border-color: #f0ad4e; +} +.btn-danger { + color: #fff; + background-color: #b85c5c; + border-color: #b85c5c; +} +.btn-danger:hover { + color: #fff; + background-color: #9d4444; + border-color: #964141; +} +.btn-danger:focus, +.btn-danger.focus { + color: #fff; + background-color: #9d4444; + border-color: #964141; +} +.btn-danger:active, +.btn-danger.active, +.open > .btn-danger.dropdown-toggle { + color: #fff; + background-color: #9d4444; + border-color: #964141; + background-image: none; +} +.btn-danger:active:hover, +.btn-danger:active:focus, +.btn-danger:active.focus, +.btn-danger.active:hover, +.btn-danger.active:focus, +.btn-danger.active.focus, +.open > .btn-danger.dropdown-toggle:hover, +.open > .btn-danger.dropdown-toggle:focus, +.open > .btn-danger.dropdown-toggle.focus { + color: #fff; + background-color: #843939; + border-color: #672d2d; +} +.btn-danger.disabled:focus, +.btn-danger.disabled.focus, +.btn-danger:disabled:focus, +.btn-danger:disabled.focus { + background-color: #b85c5c; + border-color: #b85c5c; +} +.btn-danger.disabled:hover, +.btn-danger:disabled:hover { + background-color: #b85c5c; + border-color: #b85c5c; +} +.btn-outline-primary { + color: #5cb85c; + background-image: none; + background-color: transparent; + border-color: #5cb85c; +} +.btn-outline-primary:hover { + color: #fff; + background-color: #5cb85c; + border-color: #5cb85c; +} +.btn-outline-primary:focus, +.btn-outline-primary.focus { + color: #fff; + background-color: #5cb85c; + border-color: #5cb85c; +} +.btn-outline-primary:active, +.btn-outline-primary.active, +.open > .btn-outline-primary.dropdown-toggle { + color: #fff; + background-color: #5cb85c; + border-color: #5cb85c; +} +.btn-outline-primary:active:hover, +.btn-outline-primary:active:focus, +.btn-outline-primary:active.focus, +.btn-outline-primary.active:hover, +.btn-outline-primary.active:focus, +.btn-outline-primary.active.focus, +.open > .btn-outline-primary.dropdown-toggle:hover, +.open > .btn-outline-primary.dropdown-toggle:focus, +.open > .btn-outline-primary.dropdown-toggle.focus { + color: #fff; + background-color: #398439; + border-color: #2d672d; +} +.btn-outline-primary.disabled:focus, +.btn-outline-primary.disabled.focus, +.btn-outline-primary:disabled:focus, +.btn-outline-primary:disabled.focus { + border-color: #a3d7a3; +} +.btn-outline-primary.disabled:hover, +.btn-outline-primary:disabled:hover { + border-color: #a3d7a3; +} +.btn-outline-secondary { + color: #ccc; + background-image: none; + background-color: transparent; + border-color: #ccc; +} +.btn-outline-secondary:hover { + color: #fff; + background-color: #ccc; + border-color: #ccc; +} +.btn-outline-secondary:focus, +.btn-outline-secondary.focus { + color: #fff; + background-color: #ccc; + border-color: #ccc; +} +.btn-outline-secondary:active, +.btn-outline-secondary.active, +.open > .btn-outline-secondary.dropdown-toggle { + color: #fff; + background-color: #ccc; + border-color: #ccc; +} +.btn-outline-secondary:active:hover, +.btn-outline-secondary:active:focus, +.btn-outline-secondary:active.focus, +.btn-outline-secondary.active:hover, +.btn-outline-secondary.active:focus, +.btn-outline-secondary.active.focus, +.open > .btn-outline-secondary.dropdown-toggle:hover, +.open > .btn-outline-secondary.dropdown-toggle:focus, +.open > .btn-outline-secondary.dropdown-toggle.focus { + color: #fff; + background-color: #a1a1a1; + border-color: #8c8c8c; +} +.btn-outline-secondary.disabled:focus, +.btn-outline-secondary.disabled.focus, +.btn-outline-secondary:disabled:focus, +.btn-outline-secondary:disabled.focus { + border-color: #fff; +} +.btn-outline-secondary.disabled:hover, +.btn-outline-secondary:disabled:hover { + border-color: #fff; +} +.btn-outline-info { + color: #5bc0de; + background-image: none; + background-color: transparent; + border-color: #5bc0de; +} +.btn-outline-info:hover { + color: #fff; + background-color: #5bc0de; + border-color: #5bc0de; +} +.btn-outline-info:focus, +.btn-outline-info.focus { + color: #fff; + background-color: #5bc0de; + border-color: #5bc0de; +} +.btn-outline-info:active, +.btn-outline-info.active, +.open > .btn-outline-info.dropdown-toggle { + color: #fff; + background-color: #5bc0de; + border-color: #5bc0de; +} +.btn-outline-info:active:hover, +.btn-outline-info:active:focus, +.btn-outline-info:active.focus, +.btn-outline-info.active:hover, +.btn-outline-info.active:focus, +.btn-outline-info.active.focus, +.open > .btn-outline-info.dropdown-toggle:hover, +.open > .btn-outline-info.dropdown-toggle:focus, +.open > .btn-outline-info.dropdown-toggle.focus { + color: #fff; + background-color: #269abc; + border-color: #1f7e9a; +} +.btn-outline-info.disabled:focus, +.btn-outline-info.disabled.focus, +.btn-outline-info:disabled:focus, +.btn-outline-info:disabled.focus { + border-color: #b0e1ef; +} +.btn-outline-info.disabled:hover, +.btn-outline-info:disabled:hover { + border-color: #b0e1ef; +} +.btn-outline-success { + color: #5cb85c; + background-image: none; + background-color: transparent; + border-color: #5cb85c; +} +.btn-outline-success:hover { + color: #fff; + background-color: #5cb85c; + border-color: #5cb85c; +} +.btn-outline-success:focus, +.btn-outline-success.focus { + color: #fff; + background-color: #5cb85c; + border-color: #5cb85c; +} +.btn-outline-success:active, +.btn-outline-success.active, +.open > .btn-outline-success.dropdown-toggle { + color: #fff; + background-color: #5cb85c; + border-color: #5cb85c; +} +.btn-outline-success:active:hover, +.btn-outline-success:active:focus, +.btn-outline-success:active.focus, +.btn-outline-success.active:hover, +.btn-outline-success.active:focus, +.btn-outline-success.active.focus, +.open > .btn-outline-success.dropdown-toggle:hover, +.open > .btn-outline-success.dropdown-toggle:focus, +.open > .btn-outline-success.dropdown-toggle.focus { + color: #fff; + background-color: #398439; + border-color: #2d672d; +} +.btn-outline-success.disabled:focus, +.btn-outline-success.disabled.focus, +.btn-outline-success:disabled:focus, +.btn-outline-success:disabled.focus { + border-color: #a3d7a3; +} +.btn-outline-success.disabled:hover, +.btn-outline-success:disabled:hover { + border-color: #a3d7a3; +} +.btn-outline-warning { + color: #f0ad4e; + background-image: none; + background-color: transparent; + border-color: #f0ad4e; +} +.btn-outline-warning:hover { + color: #fff; + background-color: #f0ad4e; + border-color: #f0ad4e; +} +.btn-outline-warning:focus, +.btn-outline-warning.focus { + color: #fff; + background-color: #f0ad4e; + border-color: #f0ad4e; +} +.btn-outline-warning:active, +.btn-outline-warning.active, +.open > .btn-outline-warning.dropdown-toggle { + color: #fff; + background-color: #f0ad4e; + border-color: #f0ad4e; +} +.btn-outline-warning:active:hover, +.btn-outline-warning:active:focus, +.btn-outline-warning:active.focus, +.btn-outline-warning.active:hover, +.btn-outline-warning.active:focus, +.btn-outline-warning.active.focus, +.open > .btn-outline-warning.dropdown-toggle:hover, +.open > .btn-outline-warning.dropdown-toggle:focus, +.open > .btn-outline-warning.dropdown-toggle.focus { + color: #fff; + background-color: #d58512; + border-color: #b06d0f; +} +.btn-outline-warning.disabled:focus, +.btn-outline-warning.disabled.focus, +.btn-outline-warning:disabled:focus, +.btn-outline-warning:disabled.focus { + border-color: #f8d9ac; +} +.btn-outline-warning.disabled:hover, +.btn-outline-warning:disabled:hover { + border-color: #f8d9ac; +} +.btn-outline-danger { + color: #b85c5c; + background-image: none; + background-color: transparent; + border-color: #b85c5c; +} +.btn-outline-danger:hover { + color: #fff; + background-color: #b85c5c; + border-color: #b85c5c; +} +.btn-outline-danger:focus, +.btn-outline-danger.focus { + color: #fff; + background-color: #b85c5c; + border-color: #b85c5c; +} +.btn-outline-danger:active, +.btn-outline-danger.active, +.open > .btn-outline-danger.dropdown-toggle { + color: #fff; + background-color: #b85c5c; + border-color: #b85c5c; +} +.btn-outline-danger:active:hover, +.btn-outline-danger:active:focus, +.btn-outline-danger:active.focus, +.btn-outline-danger.active:hover, +.btn-outline-danger.active:focus, +.btn-outline-danger.active.focus, +.open > .btn-outline-danger.dropdown-toggle:hover, +.open > .btn-outline-danger.dropdown-toggle:focus, +.open > .btn-outline-danger.dropdown-toggle.focus { + color: #fff; + background-color: #843939; + border-color: #672d2d; +} +.btn-outline-danger.disabled:focus, +.btn-outline-danger.disabled.focus, +.btn-outline-danger:disabled:focus, +.btn-outline-danger:disabled.focus { + border-color: #d7a3a3; +} +.btn-outline-danger.disabled:hover, +.btn-outline-danger:disabled:hover { + border-color: #d7a3a3; +} +.btn-link { + font-weight: 400; + color: #5cb85c; + border-radius: 0; +} +.btn-link, +.btn-link:active, +.btn-link.active, +.btn-link:disabled { + background-color: transparent; +} +.btn-link, +.btn-link:focus, +.btn-link:active { + border-color: transparent; +} +.btn-link:hover { + border-color: transparent; +} +.btn-link:focus, +.btn-link:hover { + color: #3d8b3d; + text-decoration: underline; + background-color: transparent; +} +.btn-link:disabled:focus, +.btn-link:disabled:hover { + color: #818a91; + text-decoration: none; +} +.btn-lg, +.btn-group-lg > .btn { + padding: 0.75rem 1.5rem; + font-size: 1.25rem; + border-radius: 0.3rem; +} +.btn-sm, +.btn-group-sm > .btn { + padding: 0.25rem 0.5rem; + font-size: 0.875rem; + border-radius: 0.2rem; +} +.btn-block { + display: block; + width: 100%; +} +.btn-block + .btn-block { + margin-top: 5px; +} +input[type='submit'].btn-block, +input[type='reset'].btn-block, +input[type='button'].btn-block { + width: 100%; +} +.fade { + opacity: 0; + transition: opacity 0.15s linear; +} +.fade.in { + opacity: 1; +} +.collapse { + display: none; +} +.collapse.in { + display: block; +} +.collapsing { + position: relative; + height: 0; + overflow: hidden; + transition-timing-function: ease; + transition-duration: 0.35s; + transition-property: height; +} +.dropup, +.dropdown { + position: relative; +} +.dropdown-toggle::after { + display: inline-block; + width: 0; + height: 0; + margin-right: 0.25rem; + margin-left: 0.25rem; + vertical-align: middle; + content: ''; + border-top: 0.3em solid; + border-right: 0.3em solid transparent; + border-left: 0.3em solid transparent; +} +.dropdown-toggle:focus { + outline: 0; +} +.dropup .dropdown-toggle::after { + border-top: 0; + border-bottom: 0.3em solid; +} +.dropdown-menu { + position: absolute; + top: 100%; + left: 0; + z-index: 1000; + display: none; + float: left; + min-width: 160px; + padding: 5px 0; + margin: 2px 0 0; + font-size: 1rem; + color: #373a3c; + text-align: left; + list-style: none; + background-color: #fff; + background-clip: padding-box; + border: 1px solid rgba(0, 0, 0, 0.15); + border-radius: 0.25rem; +} +.dropdown-divider { + height: 1px; + margin: 0.5rem 0; + overflow: hidden; + background-color: #e5e5e5; +} +.dropdown-item { + display: block; + width: 100%; + padding: 3px 20px; + clear: both; + font-weight: 400; + color: #373a3c; + text-align: inherit; + white-space: nowrap; + background: 0 0; + border: 0; +} +.dropdown-item:focus, +.dropdown-item:hover { + color: #2b2d2f; + text-decoration: none; + background-color: #f5f5f5; +} +.dropdown-item.active, +.dropdown-item.active:focus, +.dropdown-item.active:hover { + color: #fff; + text-decoration: none; + background-color: #5cb85c; + outline: 0; +} +.dropdown-item.disabled, +.dropdown-item.disabled:focus, +.dropdown-item.disabled:hover { + color: #818a91; +} +.dropdown-item.disabled:focus, +.dropdown-item.disabled:hover { + text-decoration: none; + cursor: not-allowed; + background-color: transparent; + background-image: none; + filter: 'progid:DXImageTransform.Microsoft.gradient(enabled = false)'; +} +.open > .dropdown-menu { + display: block; +} +.open > a { + outline: 0; +} +.dropdown-menu-right { + right: 0; + left: auto; +} +.dropdown-menu-left { + right: auto; + left: 0; +} +.dropdown-header { + display: block; + padding: 5px 20px; + font-size: 0.875rem; + color: #818a91; + white-space: nowrap; +} +.dropdown-backdrop { + position: fixed; + top: 0; + right: 0; + bottom: 0; + left: 0; + z-index: 990; +} +.dropup .caret, +.navbar-fixed-bottom .dropdown .caret { + content: ''; + border-top: 0; + border-bottom: 0.3em solid; +} +.dropup .dropdown-menu, +.navbar-fixed-bottom .dropdown .dropdown-menu { + top: auto; + bottom: 100%; + margin-bottom: 2px; +} +.btn-group, +.btn-group-vertical { + position: relative; + display: inline-block; + vertical-align: middle; +} +.btn-group > .btn, +.btn-group-vertical > .btn { + position: relative; + float: left; +} +.btn-group > .btn:focus, +.btn-group > .btn:active, +.btn-group > .btn.active, +.btn-group-vertical > .btn:focus, +.btn-group-vertical > .btn:active, +.btn-group-vertical > .btn.active { + z-index: 2; +} +.btn-group > .btn:hover, +.btn-group-vertical > .btn:hover { + z-index: 2; +} +.btn-group .btn + .btn, +.btn-group .btn + .btn-group, +.btn-group .btn-group + .btn, +.btn-group .btn-group + .btn-group { + margin-left: -1px; +} +.btn-toolbar { + margin-left: -5px; +} +.btn-toolbar::after { + content: ''; + display: table; + clear: both; +} +.btn-toolbar .btn-group, +.btn-toolbar .input-group { + float: left; +} +.btn-toolbar > .btn, +.btn-toolbar > .btn-group, +.btn-toolbar > .input-group { + margin-left: 5px; +} +.btn-group > .btn:not(:first-child):not(:last-child):not(.dropdown-toggle) { + border-radius: 0; +} +.btn-group > .btn:first-child { + margin-left: 0; +} +.btn-group > .btn:first-child:not(:last-child):not(.dropdown-toggle) { + border-bottom-right-radius: 0; + border-top-right-radius: 0; +} +.btn-group > .btn:last-child:not(:first-child), +.btn-group > .dropdown-toggle:not(:first-child) { + border-bottom-left-radius: 0; + border-top-left-radius: 0; +} +.btn-group > .btn-group { + float: left; +} +.btn-group > .btn-group:not(:first-child):not(:last-child) > .btn { + border-radius: 0; +} +.btn-group > .btn-group:first-child:not(:last-child) > .btn:last-child, +.btn-group > .btn-group:first-child:not(:last-child) > .dropdown-toggle { + border-bottom-right-radius: 0; + border-top-right-radius: 0; +} +.btn-group > .btn-group:last-child:not(:first-child) > .btn:first-child { + border-bottom-left-radius: 0; + border-top-left-radius: 0; +} +.btn-group .dropdown-toggle:active, +.btn-group.open .dropdown-toggle { + outline: 0; +} +.btn-group > .btn + .dropdown-toggle { + padding-right: 8px; + padding-left: 8px; +} +.btn-group > .btn-lg + .dropdown-toggle, +.btn-group-lg.btn-group > .btn + .dropdown-toggle { + padding-right: 12px; + padding-left: 12px; +} +.btn .caret { + margin-left: 0; +} +.btn-lg .caret, +.btn-group-lg > .btn .caret { + border-width: 0.3em 0.3em 0; + border-bottom-width: 0; +} +.dropup .btn-lg .caret, +.dropup .btn-group-lg > .btn .caret { + border-width: 0 0.3em 0.3em; +} +.btn-group-vertical > .btn, +.btn-group-vertical > .btn-group, +.btn-group-vertical > .btn-group > .btn { + display: block; + float: none; + width: 100%; + max-width: 100%; +} +.btn-group-vertical > .btn-group::after { + content: ''; + display: table; + clear: both; +} +.btn-group-vertical > .btn-group > .btn { + float: none; +} +.btn-group-vertical > .btn + .btn, +.btn-group-vertical > .btn + .btn-group, +.btn-group-vertical > .btn-group + .btn, +.btn-group-vertical > .btn-group + .btn-group { + margin-top: -1px; + margin-left: 0; +} +.btn-group-vertical > .btn:not(:first-child):not(:last-child) { + border-radius: 0; +} +.btn-group-vertical > .btn:first-child:not(:last-child) { + border-bottom-right-radius: 0; + border-bottom-left-radius: 0; +} +.btn-group-vertical > .btn:last-child:not(:first-child) { + border-top-right-radius: 0; + border-top-left-radius: 0; +} +.btn-group-vertical > .btn-group:not(:first-child):not(:last-child) > .btn { + border-radius: 0; +} +.btn-group-vertical > .btn-group:first-child:not(:last-child) > .btn:last-child, +.btn-group-vertical + > .btn-group:first-child:not(:last-child) + > .dropdown-toggle { + border-bottom-right-radius: 0; + border-bottom-left-radius: 0; +} +.btn-group-vertical + > .btn-group:last-child:not(:first-child) + > .btn:first-child { + border-top-right-radius: 0; + border-top-left-radius: 0; +} +[data-toggle='buttons'] > .btn input[type='radio'], +[data-toggle='buttons'] > .btn input[type='checkbox'], +[data-toggle='buttons'] > .btn-group > .btn input[type='radio'], +[data-toggle='buttons'] > .btn-group > .btn input[type='checkbox'] { + position: absolute; + clip: rect(0, 0, 0, 0); + pointer-events: none; +} +.input-group { + position: relative; + width: 100%; + display: flex; +} +.input-group .form-control { + position: relative; + z-index: 2; + flex: 1; + margin-bottom: 0; +} +.input-group .form-control:focus, +.input-group .form-control:active, +.input-group .form-control:hover { + z-index: 3; +} +.input-group-addon:not(:first-child):not(:last-child), +.input-group-btn:not(:first-child):not(:last-child), +.input-group .form-control:not(:first-child):not(:last-child) { + border-radius: 0; +} +.input-group-addon, +.input-group-btn { + white-space: nowrap; + vertical-align: middle; +} +.input-group-addon { + padding: 0.5rem 0.75rem; + margin-bottom: 0; + font-size: 1rem; + font-weight: 400; + line-height: 1.25; + color: #55595c; + text-align: center; + background-color: #eceeef; + border: 1px solid rgba(0, 0, 0, 0.15); + border-radius: 0.25rem; +} +.input-group-addon.form-control-sm, +.input-group-sm > .input-group-addon, +.input-group-sm > .input-group-btn > .input-group-addon.btn { + padding: 0.25rem 0.5rem; + font-size: 0.875rem; + border-radius: 0.2rem; +} +.input-group-addon.form-control-lg, +.input-group-lg > .input-group-addon, +.input-group-lg > .input-group-btn > .input-group-addon.btn { + padding: 0.75rem 1.5rem; + font-size: 1.25rem; + border-radius: 0.3rem; +} +.input-group-addon input[type='radio'], +.input-group-addon input[type='checkbox'] { + margin-top: 0; +} +.input-group .form-control:not(:last-child), +.input-group-addon:not(:last-child), +.input-group-btn:not(:last-child) > .btn, +.input-group-btn:not(:last-child) > .btn-group > .btn, +.input-group-btn:not(:last-child) > .dropdown-toggle, +.input-group-btn:not(:first-child) + > .btn:not(:last-child):not(.dropdown-toggle), +.input-group-btn:not(:first-child) > .btn-group:not(:last-child) > .btn { + border-bottom-right-radius: 0; + border-top-right-radius: 0; +} +.input-group-addon:not(:last-child) { + border-right: 0; +} +.input-group .form-control:not(:first-child), +.input-group-addon:not(:first-child), +.input-group-btn:not(:first-child) > .btn, +.input-group-btn:not(:first-child) > .btn-group > .btn, +.input-group-btn:not(:first-child) > .dropdown-toggle, +.input-group-btn:not(:last-child) > .btn:not(:first-child), +.input-group-btn:not(:last-child) > .btn-group:not(:first-child) > .btn { + border-bottom-left-radius: 0; + border-top-left-radius: 0; +} +.form-control + .input-group-addon:not(:first-child) { + border-left: 0; +} +.input-group-btn { + position: relative; + font-size: 0; + white-space: nowrap; +} +.input-group-btn > .btn { + position: relative; +} +.input-group-btn > .btn + .btn { + margin-left: -1px; +} +.input-group-btn > .btn:focus, +.input-group-btn > .btn:active, +.input-group-btn > .btn:hover { + z-index: 3; +} +.input-group-btn:not(:last-child) > .btn, +.input-group-btn:not(:last-child) > .btn-group { + margin-right: -1px; +} +.input-group-btn:not(:first-child) > .btn, +.input-group-btn:not(:first-child) > .btn-group { + z-index: 2; + margin-left: -1px; +} +.input-group-btn:not(:first-child) > .btn:focus, +.input-group-btn:not(:first-child) > .btn:active, +.input-group-btn:not(:first-child) > .btn:hover, +.input-group-btn:not(:first-child) > .btn-group:focus, +.input-group-btn:not(:first-child) > .btn-group:active, +.input-group-btn:not(:first-child) > .btn-group:hover { + z-index: 3; +} +.custom-control { + position: relative; + display: inline; + padding-left: 1.5rem; + cursor: pointer; +} +.custom-control + .custom-control { + margin-left: 1rem; +} +.custom-control-input { + position: absolute; + z-index: -1; + opacity: 0; +} +.custom-control-input:checked ~ .custom-control-indicator { + color: #fff; + background-color: #0074d9; +} +.custom-control-input:focus ~ .custom-control-indicator { + box-shadow: 0 0 0 0.075rem #fff, 0 0 0 0.2rem #0074d9; +} +.custom-control-input:active ~ .custom-control-indicator { + color: #fff; + background-color: #84c6ff; +} +.custom-control-input:disabled ~ .custom-control-indicator { + cursor: not-allowed; + background-color: #eee; +} +.custom-control-input:disabled ~ .custom-control-description { + color: #767676; + cursor: not-allowed; +} +.custom-control-indicator { + position: absolute; + top: 0.0625rem; + left: 0; + display: block; + width: 1rem; + height: 1rem; + pointer-events: none; + user-select: none; + background-color: #ddd; + background-repeat: no-repeat; + background-position: center center; + background-size: 50% 50%; +} +.custom-checkbox .custom-control-indicator { + border-radius: 0.25rem; +} +.custom-checkbox .custom-control-input:checked ~ .custom-control-indicator { + background-image: url(data:image/svg+xml;charset=utf8;base64,PHN2ZyB4bWxucz0naHR0cDovL3d3dy53My5vcmcvMjAwMC9zdmcnIHZpZXdCb3g9JzAgMCA4IDgnPjxwYXRoIGZpbGw9JyNmZmYnIGQ9J002LjU2NC43NWwtMy41OSAzLjYxMi0xLjUzOC0xLjU1TDAgNC4yNiAyLjk3NCA3LjI1IDggMi4xOTN6Jy8+PC9zdmc+); +} +.custom-checkbox + .custom-control-input:indeterminate + ~ .custom-control-indicator { + background-color: #0074d9; + background-image: url(data:image/svg+xml;charset=utf8;base64,PHN2ZyB4bWxucz0naHR0cDovL3d3dy53My5vcmcvMjAwMC9zdmcnIHZpZXdCb3g9JzAgMCA0IDQnPjxwYXRoIHN0cm9rZT0nI2ZmZicgZD0nTTAgMmg0Jy8+PC9zdmc+); +} +.custom-radio .custom-control-indicator { + border-radius: 50%; +} +.custom-radio .custom-control-input:checked ~ .custom-control-indicator { + background-image: url(data:image/svg+xml;charset=utf8;base64,PHN2ZyB4bWxucz0naHR0cDovL3d3dy53My5vcmcvMjAwMC9zdmcnIHZpZXdCb3g9Jy00IC00IDggOCc+PGNpcmNsZSByPSczJyBmaWxsPScjZmZmJy8+PC9zdmc+); +} +.custom-controls-stacked .custom-control { + display: inline; +} +.custom-controls-stacked .custom-control::after { + display: block; + margin-bottom: 0.25rem; + content: ''; +} +.custom-controls-stacked .custom-control + .custom-control { + margin-left: 0; +} +.custom-select { + display: inline-block; + max-width: 100%; + padding: 0.375rem 1.75rem 0.375rem 0.75rem; + padding-right: 0.75rem \9; + color: #55595c; + vertical-align: middle; + background: #fff + url(data:image/svg+xml;charset=utf8;base64,PHN2ZyB4bWxucz0naHR0cDovL3d3dy53My5vcmcvMjAwMC9zdmcnIHZpZXdCb3g9JzAgMCA0IDUnPjxwYXRoIGZpbGw9JyMzMzMnIGQ9J00yIDBMMCAyaDR6bTAgNUwwIDNoNHonLz48L3N2Zz4=) + no-repeat right 0.75rem center; + background-image: none \9; + background-size: 8px 10px; + border: 1px solid rgba(0, 0, 0, 0.15); + border-radius: 0.25rem; + -moz-appearance: none; + -webkit-appearance: none; +} +.custom-select:focus { + border-color: #51a7e8; + outline: none; +} +.custom-select::-ms-expand { + opacity: 0; +} +.custom-select-sm { + padding-top: 0.375rem; + padding-bottom: 0.375rem; + font-size: 75%; +} +.custom-file { + position: relative; + display: inline-block; + max-width: 100%; + height: 2.5rem; + cursor: pointer; +} +.custom-file-input { + min-width: 14rem; + max-width: 100%; + margin: 0; + filter: alpha(opacity=0); + opacity: 0; +} +.custom-file-control { + position: absolute; + top: 0; + right: 0; + left: 0; + z-index: 5; + height: 2.5rem; + padding: 0.5rem 1rem; + line-height: 1.5; + color: #555; + user-select: none; + background-color: #fff; + border: 1px solid #ddd; + border-radius: 0.25rem; +} +.custom-file-control:lang(en)::after { + content: 'Choose file...'; +} +.custom-file-control::before { + position: absolute; + top: -1px; + right: -1px; + bottom: -1px; + z-index: 6; + display: block; + height: 2.5rem; + padding: 0.5rem 1rem; + line-height: 1.5; + color: #555; + background-color: #eee; + border: 1px solid #ddd; + border-radius: 0 0.25rem 0.25rem 0; +} +.custom-file-control:lang(en)::before { + content: 'Browse'; +} +.nav { + padding-left: 0; + margin-bottom: 0; + list-style: none; +} +.nav-link { + display: inline-block; +} +.nav-link:focus, +.nav-link:hover { + text-decoration: none; +} +.nav-link.disabled { + color: #818a91; +} +.nav-link.disabled, +.nav-link.disabled:focus, +.nav-link.disabled:hover { + color: #818a91; + cursor: not-allowed; + background-color: transparent; +} +.nav-inline .nav-item { + display: inline-block; +} +.nav-inline .nav-item + .nav-item, +.nav-inline .nav-link + .nav-link { + margin-left: 1rem; +} +.nav-tabs { + border-bottom: 1px solid #ddd; +} +.nav-tabs::after { + content: ''; + display: table; + clear: both; +} +.nav-tabs .nav-item { + float: left; + margin-bottom: -1px; +} +.nav-tabs .nav-item + .nav-item { + margin-left: 0.2rem; +} +.nav-tabs .nav-link { + display: block; + padding: 0.5em 1em; + border: 1px solid transparent; + border-radius: 0.25rem 0.25rem 0 0; +} +.nav-tabs .nav-link:focus, +.nav-tabs .nav-link:hover { + border-color: #eceeef #eceeef #ddd; +} +.nav-tabs .nav-link.disabled, +.nav-tabs .nav-link.disabled:focus, +.nav-tabs .nav-link.disabled:hover { + color: #818a91; + background-color: transparent; + border-color: transparent; +} +.nav-tabs .nav-link.active, +.nav-tabs .nav-link.active:focus, +.nav-tabs .nav-link.active:hover, +.nav-tabs .nav-item.open .nav-link, +.nav-tabs .nav-item.open .nav-link:focus, +.nav-tabs .nav-item.open .nav-link:hover { + color: #55595c; + background-color: #fff; + border-color: #ddd #ddd transparent; +} +.nav-tabs .dropdown-menu { + margin-top: -1px; + border-top-right-radius: 0; + border-top-left-radius: 0; +} +.nav-pills::after { + content: ''; + display: table; + clear: both; +} +.nav-pills .nav-item { + float: left; +} +.nav-pills .nav-item + .nav-item { + margin-left: 0.2rem; +} +.nav-pills .nav-link { + display: block; + padding: 0.5em 1em; + border-radius: 0.25rem; +} +.nav-pills .nav-link.active, +.nav-pills .nav-link.active:focus, +.nav-pills .nav-link.active:hover, +.nav-pills .nav-item.open .nav-link, +.nav-pills .nav-item.open .nav-link:focus, +.nav-pills .nav-item.open .nav-link:hover { + color: #fff; + cursor: default; + background-color: #5cb85c; +} +.nav-stacked .nav-item { + display: block; + float: none; +} +.nav-stacked .nav-item + .nav-item { + margin-top: 0.2rem; + margin-left: 0; +} +.tab-content > .tab-pane { + display: none; +} +.tab-content > .active { + display: block; +} +.navbar { + position: relative; + padding: 0.5rem 1rem; +} +.navbar::after { + content: ''; + display: table; + clear: both; +} +@media (min-width: 544px) { + .navbar { + border-radius: 0.25rem; + } +} +.navbar-full { + z-index: 1000; +} +@media (min-width: 544px) { + .navbar-full { + border-radius: 0; + } +} +.navbar-fixed-top, +.navbar-fixed-bottom { + position: fixed; + right: 0; + left: 0; + z-index: 1030; +} +@media (min-width: 544px) { + .navbar-fixed-top, + .navbar-fixed-bottom { + border-radius: 0; + } +} +.navbar-fixed-top { + top: 0; +} +.navbar-fixed-bottom { + bottom: 0; +} +.navbar-sticky-top { + position: sticky; + top: 0; + z-index: 1030; + width: 100%; +} +@media (min-width: 544px) { + .navbar-sticky-top { + border-radius: 0; + } +} +.navbar-brand { + float: left; + padding-top: 0.25rem; + padding-bottom: 0.25rem; + margin-right: 1rem; + font-size: 1.25rem; +} +.navbar-brand:focus, +.navbar-brand:hover { + text-decoration: none; +} +.navbar-brand > img { + display: block; +} +.navbar-divider { + float: left; + width: 1px; + padding-top: 0.425rem; + padding-bottom: 0.425rem; + margin-right: 1rem; + margin-left: 1rem; + overflow: hidden; +} +.navbar-divider::before { + content: '\00a0'; +} +.navbar-toggler { + padding: 0.5rem 0.75rem; + font-size: 1.25rem; + line-height: 1; + background: 0 0; + border: 1px solid transparent; + border-radius: 0.25rem; +} +.navbar-toggler:focus, +.navbar-toggler:hover { + text-decoration: none; +} +@media (min-width: 544px) { + .navbar-toggleable-xs { + display: block !important; + } +} +@media (min-width: 768px) { + .navbar-toggleable-sm { + display: block !important; + } +} +@media (min-width: 992px) { + .navbar-toggleable-md { + display: block !important; + } +} +.navbar-nav .nav-item { + float: left; +} +.navbar-nav .nav-link { + display: block; + padding-top: 0.425rem; + padding-bottom: 0.425rem; +} +.navbar-nav .nav-link + .nav-link { + margin-left: 1rem; +} +.navbar-nav .nav-item + .nav-item { + margin-left: 1rem; +} +.navbar-light .navbar-brand { + color: rgba(0, 0, 0, 0.8); +} +.navbar-light .navbar-brand:focus, +.navbar-light .navbar-brand:hover { + color: rgba(0, 0, 0, 0.8); +} +.navbar-light .navbar-nav .nav-link { + color: rgba(0, 0, 0, 0.3); +} +.navbar-light .navbar-nav .nav-link:focus, +.navbar-light .navbar-nav .nav-link:hover { + color: rgba(0, 0, 0, 0.6); +} +.navbar-light .navbar-nav .open > .nav-link, +.navbar-light .navbar-nav .open > .nav-link:focus, +.navbar-light .navbar-nav .open > .nav-link:hover, +.navbar-light .navbar-nav .active > .nav-link, +.navbar-light .navbar-nav .active > .nav-link:focus, +.navbar-light .navbar-nav .active > .nav-link:hover, +.navbar-light .navbar-nav .nav-link.open, +.navbar-light .navbar-nav .nav-link.open:focus, +.navbar-light .navbar-nav .nav-link.open:hover, +.navbar-light .navbar-nav .nav-link.active, +.navbar-light .navbar-nav .nav-link.active:focus, +.navbar-light .navbar-nav .nav-link.active:hover { + color: rgba(0, 0, 0, 0.8); +} +.navbar-light .navbar-divider { + background-color: rgba(0, 0, 0, 0.075); +} +.navbar-dark .navbar-brand { + color: #fff; +} +.navbar-dark .navbar-brand:focus, +.navbar-dark .navbar-brand:hover { + color: #fff; +} +.navbar-dark .navbar-nav .nav-link { + color: rgba(255, 255, 255, 0.5); +} +.navbar-dark .navbar-nav .nav-link:focus, +.navbar-dark .navbar-nav .nav-link:hover { + color: rgba(255, 255, 255, 0.75); +} +.navbar-dark .navbar-nav .open > .nav-link, +.navbar-dark .navbar-nav .open > .nav-link:focus, +.navbar-dark .navbar-nav .open > .nav-link:hover, +.navbar-dark .navbar-nav .active > .nav-link, +.navbar-dark .navbar-nav .active > .nav-link:focus, +.navbar-dark .navbar-nav .active > .nav-link:hover, +.navbar-dark .navbar-nav .nav-link.open, +.navbar-dark .navbar-nav .nav-link.open:focus, +.navbar-dark .navbar-nav .nav-link.open:hover, +.navbar-dark .navbar-nav .nav-link.active, +.navbar-dark .navbar-nav .nav-link.active:focus, +.navbar-dark .navbar-nav .nav-link.active:hover { + color: #fff; +} +.navbar-dark .navbar-divider { + background-color: rgba(255, 255, 255, 0.075); +} +.card { + position: relative; + display: block; + margin-bottom: 0.75rem; + background-color: #fff; + border-radius: 0.25rem; + box-shadow: inset 0 0 0 1px rgba(0, 0, 0, 0.125); +} +.card-block { + padding: 1.25rem; +} +.card-block::after { + content: ''; + display: table; + clear: both; +} +.card-title { + margin-bottom: 0.75rem; +} +.card-subtitle { + margin-top: -0.375rem; + margin-bottom: 0; +} +.card-text:last-child { + margin-bottom: 0; +} +.card-link:hover { + text-decoration: none; +} +.card-link + .card-link { + margin-left: 1.25rem; +} +.card > .list-group:first-child .list-group-item:first-child { + border-top-left-radius: 0.25rem; + border-top-right-radius: 0.25rem; +} +.card > .list-group:last-child .list-group-item:last-child { + border-bottom-right-radius: 0.25rem; + border-bottom-left-radius: 0.25rem; +} +.card-header { + padding: 0.75rem 1.25rem; + background-color: #f5f5f5; + box-shadow: inset 0 0 0 1px rgba(0, 0, 0, 0.125); +} +.card-header::after { + content: ''; + display: table; + clear: both; +} +.card-header:first-child { + border-radius: 0.25rem 0.25rem 0 0; +} +.card-footer { + padding: 0.75rem 1.25rem; + background-color: #f5f5f5; + box-shadow: inset 0 0 0 1px rgba(0, 0, 0, 0.125); +} +.card-footer::after { + content: ''; + display: table; + clear: both; +} +.card-footer:last-child { + border-radius: 0 0 0.25rem 0.25rem; +} +.card-header-tabs { + margin-right: -0.625rem; + margin-bottom: -0.75rem; + margin-left: -0.625rem; + border-bottom: 0; +} +.card-header-tabs .nav-item { + margin-bottom: 0; +} +.card-header-pills { + margin-right: -0.625rem; + margin-left: -0.625rem; +} +.card-primary { + background-color: #5cb85c; + border-color: #5cb85c; +} +.card-success { + background-color: #5cb85c; + border-color: #5cb85c; +} +.card-info { + background-color: #5bc0de; + border-color: #5bc0de; +} +.card-warning { + background-color: #f0ad4e; + border-color: #f0ad4e; +} +.card-danger { + background-color: #b85c5c; + border-color: #b85c5c; +} +.card-outline-primary { + background-color: transparent; + border-color: #5cb85c; +} +.card-outline-secondary { + background-color: transparent; + border-color: #ccc; +} +.card-outline-info { + background-color: transparent; + border-color: #5bc0de; +} +.card-outline-success { + background-color: transparent; + border-color: #5cb85c; +} +.card-outline-warning { + background-color: transparent; + border-color: #f0ad4e; +} +.card-outline-danger { + background-color: transparent; + border-color: #b85c5c; +} +.card-inverse .card-header, +.card-inverse .card-footer { + border-bottom: 1px solid rgba(255, 255, 255, 0.2); +} +.card-inverse .card-header, +.card-inverse .card-footer, +.card-inverse .card-title, +.card-inverse .card-blockquote { + color: #fff; +} +.card-inverse .card-link, +.card-inverse .card-text, +.card-inverse .card-blockquote > footer { + color: rgba(255, 255, 255, 0.65); +} +.card-inverse .card-link:focus, +.card-inverse .card-link:hover { + color: #fff; +} +.card-blockquote { + padding: 0; + margin-bottom: 0; + border-left: 0; +} +.card-img { + border-radius: 0.25rem; +} +.card-img-overlay { + position: absolute; + top: 0; + right: 0; + bottom: 0; + left: 0; + padding: 1.25rem; +} +.card-img-top { + border-radius: 0.25rem 0.25rem 0 0; +} +.card-img-bottom { + border-radius: 0 0 0.25rem 0.25rem; +} +@media (min-width: 544px) { + .card-deck { + display: flex; + flex-flow: row wrap; + margin-right: -0.625rem; + margin-left: -0.625rem; + } + .card-deck .card { + flex: 1 0 0; + margin-right: 0.625rem; + margin-left: 0.625rem; + } +} +@media (min-width: 544px) { + .card-group { + display: flex; + flex-flow: row wrap; + } + .card-group .card { + flex: 1 0 0; + } + .card-group .card + .card { + margin-left: 0; + border-left: 0; + } + .card-group .card:first-child { + border-bottom-right-radius: 0; + border-top-right-radius: 0; + } + .card-group .card:first-child .card-img-top { + border-top-right-radius: 0; + } + .card-group .card:first-child .card-img-bottom { + border-bottom-right-radius: 0; + } + .card-group .card:last-child { + border-bottom-left-radius: 0; + border-top-left-radius: 0; + } + .card-group .card:last-child .card-img-top { + border-top-left-radius: 0; + } + .card-group .card:last-child .card-img-bottom { + border-bottom-left-radius: 0; + } + .card-group .card:not(:first-child):not(:last-child) { + border-radius: 0; + } + .card-group .card:not(:first-child):not(:last-child) .card-img-top, + .card-group .card:not(:first-child):not(:last-child) .card-img-bottom { + border-radius: 0; + } +} +@media (min-width: 544px) { + .card-columns { + column-count: 3; + column-gap: 1.25rem; + } + .card-columns .card { + display: inline-block; + width: 100%; + } +} +.breadcrumb { + padding: 0.75rem 1rem; + margin-bottom: 1rem; + list-style: none; + background-color: #eceeef; + border-radius: 0.25rem; +} +.breadcrumb::after { + content: ''; + display: table; + clear: both; +} +.breadcrumb-item { + float: left; +} +.breadcrumb-item + .breadcrumb-item::before { + display: inline-block; + padding-right: 0.5rem; + padding-left: 0.5rem; + color: #818a91; + content: '/'; +} +.breadcrumb-item + .breadcrumb-item:hover::before { + text-decoration: underline; +} +.breadcrumb-item + .breadcrumb-item:hover::before { + text-decoration: none; +} +.breadcrumb-item.active { + color: #818a91; +} +.pagination { + display: inline-block; + padding-left: 0; + margin-top: 1rem; + margin-bottom: 1rem; + border-radius: 0.25rem; +} +.page-item { + display: inline; +} +.page-item:first-child .page-link { + margin-left: 0; + border-bottom-left-radius: 0.25rem; + border-top-left-radius: 0.25rem; +} +.page-item:last-child .page-link { + border-bottom-right-radius: 0.25rem; + border-top-right-radius: 0.25rem; +} +.page-item.active .page-link, +.page-item.active .page-link:focus, +.page-item.active .page-link:hover { + z-index: 2; + color: #fff; + cursor: default; + background-color: #5cb85c; + border-color: #5cb85c; +} +.page-item.disabled .page-link, +.page-item.disabled .page-link:focus, +.page-item.disabled .page-link:hover { + color: #818a91; + pointer-events: none; + cursor: not-allowed; + background-color: #fff; + border-color: #ddd; +} +.page-link { + position: relative; + float: left; + padding: 0.5rem 0.75rem; + margin-left: -1px; + color: #5cb85c; + text-decoration: none; + background-color: #fff; + border: 1px solid #ddd; +} +.page-link:focus, +.page-link:hover { + color: #3d8b3d; + background-color: #eceeef; + border-color: #ddd; +} +.pagination-lg .page-link { + padding: 0.75rem 1.5rem; + font-size: 1.25rem; +} +.pagination-lg .page-item:first-child .page-link { + border-bottom-left-radius: 0.3rem; + border-top-left-radius: 0.3rem; +} +.pagination-lg .page-item:last-child .page-link { + border-bottom-right-radius: 0.3rem; + border-top-right-radius: 0.3rem; +} +.pagination-sm .page-link { + padding: 0.275rem 0.75rem; + font-size: 0.875rem; +} +.pagination-sm .page-item:first-child .page-link { + border-bottom-left-radius: 0.2rem; + border-top-left-radius: 0.2rem; +} +.pagination-sm .page-item:last-child .page-link { + border-bottom-right-radius: 0.2rem; + border-top-right-radius: 0.2rem; +} +.tag { + display: inline-block; + padding: 0.25em 0.4em; + font-size: 75%; + font-weight: 700; + line-height: 1; + color: #fff; + text-align: center; + white-space: nowrap; + vertical-align: baseline; + border-radius: 0.25rem; +} +.tag:empty { + display: none; +} +.btn .tag { + position: relative; + top: -1px; +} +a.tag:focus, +a.tag:hover { + color: #fff; + text-decoration: none; + cursor: pointer; +} +.tag-pill { + padding-right: 0.6em; + padding-left: 0.6em; + border-radius: 10rem; +} +.tag-default { + background-color: #818a91; +} +.tag-default[href]:focus, +.tag-default[href]:hover { + background-color: #687077; +} +.tag-primary { + background-color: #5cb85c; +} +.tag-primary[href]:focus, +.tag-primary[href]:hover { + background-color: #449d44; +} +.tag-success { + background-color: #5cb85c; +} +.tag-success[href]:focus, +.tag-success[href]:hover { + background-color: #449d44; +} +.tag-info { + background-color: #5bc0de; +} +.tag-info[href]:focus, +.tag-info[href]:hover { + background-color: #31b0d5; +} +.tag-warning { + background-color: #f0ad4e; +} +.tag-warning[href]:focus, +.tag-warning[href]:hover { + background-color: #ec971f; +} +.tag-danger { + background-color: #b85c5c; +} +.tag-danger[href]:focus, +.tag-danger[href]:hover { + background-color: #9d4444; +} +.jumbotron { + padding: 2rem 1rem; + margin-bottom: 2rem; + background-color: #eceeef; + border-radius: 0.3rem; +} +@media (min-width: 544px) { + .jumbotron { + padding: 4rem 2rem; + } +} +.jumbotron-hr { + border-top-color: #d0d5d8; +} +.jumbotron-fluid { + padding-right: 0; + padding-left: 0; + border-radius: 0; +} +.alert { + padding: 15px; + margin-bottom: 1rem; + border: 1px solid transparent; + border-radius: 0.25rem; +} +.alert-heading { + color: inherit; +} +.alert-link { + font-weight: 700; +} +.alert-dismissible { + padding-right: 35px; +} +.alert-dismissible .close { + position: relative; + top: -2px; + right: -21px; + color: inherit; +} +.alert-success { + background-color: #dff0d8; + border-color: #d0e9c6; + color: #3c763d; +} +.alert-success hr { + border-top-color: #c1e2b3; +} +.alert-success .alert-link { + color: #2b542c; +} +.alert-info { + background-color: #d9edf7; + border-color: #bcdff1; + color: #31708f; +} +.alert-info hr { + border-top-color: #a6d5ec; +} +.alert-info .alert-link { + color: #245269; +} +.alert-warning { + background-color: #fcf8e3; + border-color: #faf2cc; + color: #8a6d3b; +} +.alert-warning hr { + border-top-color: #f7ecb5; +} +.alert-warning .alert-link { + color: #66512c; +} +.alert-danger { + background-color: #f2dede; + border-color: #ebcccc; + color: #a94442; +} +.alert-danger hr { + border-top-color: #e4b9b9; +} +.alert-danger .alert-link { + color: #843534; +} +@keyframes progress-bar-stripes { + from { + background-position: 1rem 0; + } + to { + background-position: 0 0; + } +} +.progress { + display: block; + width: 100%; + height: 1rem; + margin-bottom: 1rem; +} +.progress[value] { + background-color: #eee; + border: 0; + appearance: none; + border-radius: 0.25rem; +} +.progress[value]::-ms-fill { + background-color: #0074d9; + border: 0; +} +.progress[value]::-moz-progress-bar { + background-color: #0074d9; + border-bottom-left-radius: 0.25rem; + border-top-left-radius: 0.25rem; +} +.progress[value]::-webkit-progress-value { + background-color: #0074d9; + border-bottom-left-radius: 0.25rem; + border-top-left-radius: 0.25rem; +} +.progress[value='100']::-moz-progress-bar { + border-bottom-right-radius: 0.25rem; + border-top-right-radius: 0.25rem; +} +.progress[value='100']::-webkit-progress-value { + border-bottom-right-radius: 0.25rem; + border-top-right-radius: 0.25rem; +} +.progress[value]::-webkit-progress-bar { + background-color: #eee; + border-radius: 0.25rem; +} +base::-moz-progress-bar, +.progress[value] { + background-color: #eee; + border-radius: 0.25rem; +} +@media screen and (min-width: 0\0) { + .progress { + background-color: #eee; + border-radius: 0.25rem; + } + .progress-bar { + display: inline-block; + height: 1rem; + text-indent: -999rem; + background-color: #0074d9; + border-bottom-left-radius: 0.25rem; + border-top-left-radius: 0.25rem; + } + .progress[width='100%'] { + border-bottom-right-radius: 0.25rem; + border-top-right-radius: 0.25rem; + } +} +.progress-striped[value]::-webkit-progress-value { + background-image: linear-gradient( + 45deg, + rgba(255, 255, 255, 0.15) 25%, + transparent 25%, + transparent 50%, + rgba(255, 255, 255, 0.15) 50%, + rgba(255, 255, 255, 0.15) 75%, + transparent 75%, + transparent + ); + background-size: 1rem 1rem; +} +.progress-striped[value]::-moz-progress-bar { + background-image: linear-gradient( + 45deg, + rgba(255, 255, 255, 0.15) 25%, + transparent 25%, + transparent 50%, + rgba(255, 255, 255, 0.15) 50%, + rgba(255, 255, 255, 0.15) 75%, + transparent 75%, + transparent + ); + background-size: 1rem 1rem; +} +.progress-striped[value]::-ms-fill { + background-image: linear-gradient( + 45deg, + rgba(255, 255, 255, 0.15) 25%, + transparent 25%, + transparent 50%, + rgba(255, 255, 255, 0.15) 50%, + rgba(255, 255, 255, 0.15) 75%, + transparent 75%, + transparent + ); + background-size: 1rem 1rem; +} +@media screen and (min-width: 0\0) { + .progress-bar-striped { + background-image: linear-gradient( + 45deg, + rgba(255, 255, 255, 0.15) 25%, + transparent 25%, + transparent 50%, + rgba(255, 255, 255, 0.15) 50%, + rgba(255, 255, 255, 0.15) 75%, + transparent 75%, + transparent + ); + background-size: 1rem 1rem; + } +} +.progress-animated[value]::-webkit-progress-value { + animation: progress-bar-stripes 2s linear infinite; +} +.progress-animated[value]::-moz-progress-bar { + animation: progress-bar-stripes 2s linear infinite; +} +@media screen and (min-width: 0\0) { + .progress-animated .progress-bar-striped { + animation: progress-bar-stripes 2s linear infinite; + } +} +.progress-success[value]::-webkit-progress-value { + background-color: #5cb85c; +} +.progress-success[value]::-moz-progress-bar { + background-color: #5cb85c; +} +.progress-success[value]::-ms-fill { + background-color: #5cb85c; +} +@media screen and (min-width: 0\0) { + .progress-success .progress-bar { + background-color: #5cb85c; + } +} +.progress-info[value]::-webkit-progress-value { + background-color: #5bc0de; +} +.progress-info[value]::-moz-progress-bar { + background-color: #5bc0de; +} +.progress-info[value]::-ms-fill { + background-color: #5bc0de; +} +@media screen and (min-width: 0\0) { + .progress-info .progress-bar { + background-color: #5bc0de; + } +} +.progress-warning[value]::-webkit-progress-value { + background-color: #f0ad4e; +} +.progress-warning[value]::-moz-progress-bar { + background-color: #f0ad4e; +} +.progress-warning[value]::-ms-fill { + background-color: #f0ad4e; +} +@media screen and (min-width: 0\0) { + .progress-warning .progress-bar { + background-color: #f0ad4e; + } +} +.progress-danger[value]::-webkit-progress-value { + background-color: #b85c5c; +} +.progress-danger[value]::-moz-progress-bar { + background-color: #b85c5c; +} +.progress-danger[value]::-ms-fill { + background-color: #b85c5c; +} +@media screen and (min-width: 0\0) { + .progress-danger .progress-bar { + background-color: #b85c5c; + } +} +.media { + display: flex; + margin-bottom: 1rem; +} +.media-body { + flex: 1; +} +.media-middle { + align-self: center; +} +.media-bottom { + align-self: flex-end; +} +.media-object { + display: block; +} +.media-object.img-thumbnail { + max-width: none; +} +.media-right { + padding-left: 10px; +} +.media-left { + padding-right: 10px; +} +.media-heading { + margin-top: 0; + margin-bottom: 5px; +} +.media-list { + padding-left: 0; + list-style: none; +} +.list-group { + padding-left: 0; + margin-bottom: 0; +} +.list-group-item { + position: relative; + display: block; + padding: 0.75rem 1.25rem; + margin-bottom: -1px; + background-color: #fff; + border: 1px solid #ddd; +} +.list-group-item:first-child { + border-top-right-radius: 0.25rem; + border-top-left-radius: 0.25rem; +} +.list-group-item:last-child { + margin-bottom: 0; + border-bottom-right-radius: 0.25rem; + border-bottom-left-radius: 0.25rem; +} +.list-group-item.disabled, +.list-group-item.disabled:focus, +.list-group-item.disabled:hover { + color: #818a91; + cursor: not-allowed; + background-color: #eceeef; +} +.list-group-item.disabled .list-group-item-heading, +.list-group-item.disabled:focus .list-group-item-heading, +.list-group-item.disabled:hover .list-group-item-heading { + color: inherit; +} +.list-group-item.disabled .list-group-item-text, +.list-group-item.disabled:focus .list-group-item-text, +.list-group-item.disabled:hover .list-group-item-text { + color: #818a91; +} +.list-group-item.active, +.list-group-item.active:focus, +.list-group-item.active:hover { + z-index: 2; + color: #fff; + text-decoration: none; + background-color: #5cb85c; + border-color: #5cb85c; +} +.list-group-item.active .list-group-item-heading, +.list-group-item.active .list-group-item-heading > small, +.list-group-item.active .list-group-item-heading > .small, +.list-group-item.active:focus .list-group-item-heading, +.list-group-item.active:focus .list-group-item-heading > small, +.list-group-item.active:focus .list-group-item-heading > .small, +.list-group-item.active:hover .list-group-item-heading, +.list-group-item.active:hover .list-group-item-heading > small, +.list-group-item.active:hover .list-group-item-heading > .small { + color: inherit; +} +.list-group-item.active .list-group-item-text, +.list-group-item.active:focus .list-group-item-text, +.list-group-item.active:hover .list-group-item-text { + color: #eaf6ea; +} +.list-group-flush .list-group-item { + border-radius: 0; +} +.list-group-item-action { + width: 100%; + color: #555; + text-align: inherit; +} +.list-group-item-action .list-group-item-heading { + color: #333; +} +.list-group-item-action:focus, +.list-group-item-action:hover { + color: #555; + text-decoration: none; + background-color: #f5f5f5; +} +.list-group-item-success { + color: #3c763d; + background-color: #dff0d8; +} +a.list-group-item-success, +button.list-group-item-success { + color: #3c763d; +} +a.list-group-item-success .list-group-item-heading, +button.list-group-item-success .list-group-item-heading { + color: inherit; +} +a.list-group-item-success:focus, +a.list-group-item-success:hover, +button.list-group-item-success:focus, +button.list-group-item-success:hover { + color: #3c763d; + background-color: #d0e9c6; +} +a.list-group-item-success.active, +a.list-group-item-success.active:focus, +a.list-group-item-success.active:hover, +button.list-group-item-success.active, +button.list-group-item-success.active:focus, +button.list-group-item-success.active:hover { + color: #fff; + background-color: #3c763d; + border-color: #3c763d; +} +.list-group-item-info { + color: #31708f; + background-color: #d9edf7; +} +a.list-group-item-info, +button.list-group-item-info { + color: #31708f; +} +a.list-group-item-info .list-group-item-heading, +button.list-group-item-info .list-group-item-heading { + color: inherit; +} +a.list-group-item-info:focus, +a.list-group-item-info:hover, +button.list-group-item-info:focus, +button.list-group-item-info:hover { + color: #31708f; + background-color: #c4e3f3; +} +a.list-group-item-info.active, +a.list-group-item-info.active:focus, +a.list-group-item-info.active:hover, +button.list-group-item-info.active, +button.list-group-item-info.active:focus, +button.list-group-item-info.active:hover { + color: #fff; + background-color: #31708f; + border-color: #31708f; +} +.list-group-item-warning { + color: #8a6d3b; + background-color: #fcf8e3; +} +a.list-group-item-warning, +button.list-group-item-warning { + color: #8a6d3b; +} +a.list-group-item-warning .list-group-item-heading, +button.list-group-item-warning .list-group-item-heading { + color: inherit; +} +a.list-group-item-warning:focus, +a.list-group-item-warning:hover, +button.list-group-item-warning:focus, +button.list-group-item-warning:hover { + color: #8a6d3b; + background-color: #faf2cc; +} +a.list-group-item-warning.active, +a.list-group-item-warning.active:focus, +a.list-group-item-warning.active:hover, +button.list-group-item-warning.active, +button.list-group-item-warning.active:focus, +button.list-group-item-warning.active:hover { + color: #fff; + background-color: #8a6d3b; + border-color: #8a6d3b; +} +.list-group-item-danger { + color: #a94442; + background-color: #f2dede; +} +a.list-group-item-danger, +button.list-group-item-danger { + color: #a94442; +} +a.list-group-item-danger .list-group-item-heading, +button.list-group-item-danger .list-group-item-heading { + color: inherit; +} +a.list-group-item-danger:focus, +a.list-group-item-danger:hover, +button.list-group-item-danger:focus, +button.list-group-item-danger:hover { + color: #a94442; + background-color: #ebcccc; +} +a.list-group-item-danger.active, +a.list-group-item-danger.active:focus, +a.list-group-item-danger.active:hover, +button.list-group-item-danger.active, +button.list-group-item-danger.active:focus, +button.list-group-item-danger.active:hover { + color: #fff; + background-color: #a94442; + border-color: #a94442; +} +.list-group-item-heading { + margin-top: 0; + margin-bottom: 5px; +} +.list-group-item-text { + margin-bottom: 0; + line-height: 1.3; +} +.embed-responsive { + position: relative; + display: block; + height: 0; + padding: 0; + overflow: hidden; +} +.embed-responsive .embed-responsive-item, +.embed-responsive iframe, +.embed-responsive embed, +.embed-responsive object, +.embed-responsive video { + position: absolute; + top: 0; + bottom: 0; + left: 0; + width: 100%; + height: 100%; + border: 0; +} +.embed-responsive-21by9 { + padding-bottom: 42.85714%; +} +.embed-responsive-16by9 { + padding-bottom: 56.25%; +} +.embed-responsive-4by3 { + padding-bottom: 75%; +} +.embed-responsive-1by1 { + padding-bottom: 100%; +} +.close { + float: right; + font-size: 1.5rem; + font-weight: 700; + line-height: 1; + color: #000; + text-shadow: 0 1px 0 #fff; + opacity: 0.2; +} +.close:focus, +.close:hover { + color: #000; + text-decoration: none; + cursor: pointer; + opacity: 0.5; +} +button.close { + padding: 0; + cursor: pointer; + background: 0 0; + border: 0; + -webkit-appearance: none; +} +.modal-open { + overflow: hidden; +} +.modal { + position: fixed; + top: 0; + right: 0; + bottom: 0; + left: 0; + z-index: 1050; + display: none; + overflow: hidden; + outline: 0; + -webkit-overflow-scrolling: touch; +} +.modal.fade .modal-dialog { + transition: transform 0.3s ease-out; + transform: translate(0, -25%); +} +.modal.in .modal-dialog { + transform: translate(0, 0); +} +.modal-open .modal { + overflow-x: hidden; + overflow-y: auto; +} +.modal-dialog { + position: relative; + width: auto; + margin: 10px; +} +.modal-content { + position: relative; + background-color: #fff; + background-clip: padding-box; + border: 1px solid rgba(0, 0, 0, 0.2); + border-radius: 0.3rem; + outline: 0; +} +.modal-backdrop { + position: fixed; + top: 0; + right: 0; + bottom: 0; + left: 0; + z-index: 1040; + background-color: #000; +} +.modal-backdrop.fade { + opacity: 0; +} +.modal-backdrop.in { + opacity: 0.5; +} +.modal-header { + padding: 15px; + border-bottom: 1px solid #e5e5e5; +} +.modal-header::after { + content: ''; + display: table; + clear: both; +} +.modal-header .close { + margin-top: -2px; +} +.modal-title { + margin: 0; + line-height: 1.5; +} +.modal-body { + position: relative; + padding: 15px; +} +.modal-footer { + padding: 15px; + text-align: right; + border-top: 1px solid #e5e5e5; +} +.modal-footer::after { + content: ''; + display: table; + clear: both; +} +.modal-scrollbar-measure { + position: absolute; + top: -9999px; + width: 50px; + height: 50px; + overflow: scroll; +} +@media (min-width: 544px) { + .modal-dialog { + max-width: 600px; + margin: 30px auto; + } + .modal-sm { + max-width: 300px; + } +} +@media (min-width: 992px) { + .modal-lg { + max-width: 900px; + } +} +.tooltip { + position: absolute; + z-index: 1070; + display: block; + font-family: source sans pro, sans-serif; + font-style: normal; + font-weight: 400; + letter-spacing: normal; + line-break: auto; + line-height: 1.5; + text-align: left; + text-align: start; + text-decoration: none; + text-shadow: none; + text-transform: none; + white-space: normal; + word-break: normal; + word-spacing: normal; + font-size: 0.875rem; + word-wrap: break-word; + opacity: 0; +} +.tooltip.in { + opacity: 0.9; +} +.tooltip.tooltip-top, +.tooltip.bs-tether-element-attached-bottom { + padding: 5px 0; + margin-top: -3px; +} +.tooltip.tooltip-top .tooltip-arrow, +.tooltip.bs-tether-element-attached-bottom .tooltip-arrow { + bottom: 0; + left: 50%; + margin-left: -5px; + border-width: 5px 5px 0; + border-top-color: #000; +} +.tooltip.tooltip-right, +.tooltip.bs-tether-element-attached-left { + padding: 0 5px; + margin-left: 3px; +} +.tooltip.tooltip-right .tooltip-arrow, +.tooltip.bs-tether-element-attached-left .tooltip-arrow { + top: 50%; + left: 0; + margin-top: -5px; + border-width: 5px 5px 5px 0; + border-right-color: #000; +} +.tooltip.tooltip-bottom, +.tooltip.bs-tether-element-attached-top { + padding: 5px 0; + margin-top: 3px; +} +.tooltip.tooltip-bottom .tooltip-arrow, +.tooltip.bs-tether-element-attached-top .tooltip-arrow { + top: 0; + left: 50%; + margin-left: -5px; + border-width: 0 5px 5px; + border-bottom-color: #000; +} +.tooltip.tooltip-left, +.tooltip.bs-tether-element-attached-right { + padding: 0 5px; + margin-left: -3px; +} +.tooltip.tooltip-left .tooltip-arrow, +.tooltip.bs-tether-element-attached-right .tooltip-arrow { + top: 50%; + right: 0; + margin-top: -5px; + border-width: 5px 0 5px 5px; + border-left-color: #000; +} +.tooltip-inner { + max-width: 200px; + padding: 3px 8px; + color: #fff; + text-align: center; + background-color: #000; + border-radius: 0.25rem; +} +.tooltip-arrow { + position: absolute; + width: 0; + height: 0; + border-color: transparent; + border-style: solid; +} +.popover { + position: absolute; + top: 0; + left: 0; + z-index: 1060; + display: block; + max-width: 276px; + padding: 1px; + font-family: source sans pro, sans-serif; + font-style: normal; + font-weight: 400; + letter-spacing: normal; + line-break: auto; + line-height: 1.5; + text-align: left; + text-align: start; + text-decoration: none; + text-shadow: none; + text-transform: none; + white-space: normal; + word-break: normal; + word-spacing: normal; + font-size: 0.875rem; + word-wrap: break-word; + background-color: #fff; + background-clip: padding-box; + border: 1px solid rgba(0, 0, 0, 0.2); + border-radius: 0.3rem; +} +.popover.popover-top, +.popover.bs-tether-element-attached-bottom { + margin-top: -10px; +} +.popover.popover-top .popover-arrow, +.popover.bs-tether-element-attached-bottom .popover-arrow { + bottom: -11px; + left: 50%; + margin-left: -11px; + border-top-color: rgba(0, 0, 0, 0.25); + border-bottom-width: 0; +} +.popover.popover-top .popover-arrow::after, +.popover.bs-tether-element-attached-bottom .popover-arrow::after { + bottom: 1px; + margin-left: -10px; + content: ''; + border-top-color: #fff; + border-bottom-width: 0; +} +.popover.popover-right, +.popover.bs-tether-element-attached-left { + margin-left: 10px; +} +.popover.popover-right .popover-arrow, +.popover.bs-tether-element-attached-left .popover-arrow { + top: 50%; + left: -11px; + margin-top: -11px; + border-right-color: rgba(0, 0, 0, 0.25); + border-left-width: 0; +} +.popover.popover-right .popover-arrow::after, +.popover.bs-tether-element-attached-left .popover-arrow::after { + bottom: -10px; + left: 1px; + content: ''; + border-right-color: #fff; + border-left-width: 0; +} +.popover.popover-bottom, +.popover.bs-tether-element-attached-top { + margin-top: 10px; +} +.popover.popover-bottom .popover-arrow, +.popover.bs-tether-element-attached-top .popover-arrow { + top: -11px; + left: 50%; + margin-left: -11px; + border-top-width: 0; + border-bottom-color: rgba(0, 0, 0, 0.25); +} +.popover.popover-bottom .popover-arrow::after, +.popover.bs-tether-element-attached-top .popover-arrow::after { + top: 1px; + margin-left: -10px; + content: ''; + border-top-width: 0; + border-bottom-color: #fff; +} +.popover.popover-left, +.popover.bs-tether-element-attached-right { + margin-left: -10px; +} +.popover.popover-left .popover-arrow, +.popover.bs-tether-element-attached-right .popover-arrow { + top: 50%; + right: -11px; + margin-top: -11px; + border-right-width: 0; + border-left-color: rgba(0, 0, 0, 0.25); +} +.popover.popover-left .popover-arrow::after, +.popover.bs-tether-element-attached-right .popover-arrow::after { + right: 1px; + bottom: -10px; + content: ''; + border-right-width: 0; + border-left-color: #fff; +} +.popover-title { + padding: 8px 14px; + margin: 0; + font-size: 1rem; + background-color: #f7f7f7; + border-bottom: 1px solid #ebebeb; + border-radius: 0.2375rem 0.2375rem 0 0; +} +.popover-content { + padding: 9px 14px; +} +.popover-arrow, +.popover-arrow::after { + position: absolute; + display: block; + width: 0; + height: 0; + border-color: transparent; + border-style: solid; +} +.popover-arrow { + border-width: 11px; +} +.popover-arrow::after { + content: ''; + border-width: 10px; +} +.carousel { + position: relative; +} +.carousel-inner { + position: relative; + width: 100%; + overflow: hidden; +} +.carousel-inner > .carousel-item { + position: relative; + display: none; + transition: 0.6s ease-in-out left; +} +.carousel-inner > .carousel-item > img, +.carousel-inner > .carousel-item > a > img { + line-height: 1; +} +@media all and (transform-3d), (-webkit-transform-3d) { + .carousel-inner > .carousel-item { + transition: transform 0.6s ease-in-out; + backface-visibility: hidden; + perspective: 1000px; + } + .carousel-inner > .carousel-item.next, + .carousel-inner > .carousel-item.active.right { + left: 0; + transform: translate3d(100%, 0, 0); + } + .carousel-inner > .carousel-item.prev, + .carousel-inner > .carousel-item.active.left { + left: 0; + transform: translate3d(-100%, 0, 0); + } + .carousel-inner > .carousel-item.next.left, + .carousel-inner > .carousel-item.prev.right, + .carousel-inner > .carousel-item.active { + left: 0; + transform: translate3d(0, 0, 0); + } +} +.carousel-inner > .active, +.carousel-inner > .next, +.carousel-inner > .prev { + display: block; +} +.carousel-inner > .active { + left: 0; +} +.carousel-inner > .next, +.carousel-inner > .prev { + position: absolute; + top: 0; + width: 100%; +} +.carousel-inner > .next { + left: 100%; +} +.carousel-inner > .prev { + left: -100%; +} +.carousel-inner > .next.left, +.carousel-inner > .prev.right { + left: 0; +} +.carousel-inner > .active.left { + left: -100%; +} +.carousel-inner > .active.right { + left: 100%; +} +.carousel-control { + position: absolute; + top: 0; + bottom: 0; + left: 0; + width: 15%; + font-size: 20px; + color: #fff; + text-align: center; + text-shadow: 0 1px 2px rgba(0, 0, 0, 0.6); + opacity: 0.5; +} +.carousel-control.left { + background-image: linear-gradient( + to right, + rgba(0, 0, 0, 0.5) 0%, + rgba(0, 0, 0, 0.0001) 100% + ); + background-repeat: repeat-x; + filter: progid:DXImageTransform.Microsoft.gradient(startColorstr='#80000000',endColorstr='#00000000',GradientType=1); +} +.carousel-control.right { + right: 0; + left: auto; + background-image: linear-gradient( + to right, + rgba(0, 0, 0, 0.0001) 0%, + rgba(0, 0, 0, 0.5) 100% + ); + background-repeat: repeat-x; + filter: progid:DXImageTransform.Microsoft.gradient(startColorstr='#00000000',endColorstr='#80000000',GradientType=1); +} +.carousel-control:focus, +.carousel-control:hover { + color: #fff; + text-decoration: none; + outline: 0; + opacity: 0.9; +} +.carousel-control .icon-prev, +.carousel-control .icon-next { + position: absolute; + top: 50%; + z-index: 5; + display: inline-block; + width: 20px; + height: 20px; + margin-top: -10px; + font-family: serif; + line-height: 1; +} +.carousel-control .icon-prev { + left: 50%; + margin-left: -10px; +} +.carousel-control .icon-next { + right: 50%; + margin-right: -10px; +} +.carousel-control .icon-prev::before { + content: '\2039'; +} +.carousel-control .icon-next::before { + content: '\203a'; +} +.carousel-indicators { + position: absolute; + bottom: 10px; + left: 50%; + z-index: 15; + width: 60%; + padding-left: 0; + margin-left: -30%; + text-align: center; + list-style: none; +} +.carousel-indicators li { + display: inline-block; + width: 10px; + height: 10px; + margin: 1px; + text-indent: -999px; + cursor: pointer; + background-color: transparent; + border: 1px solid #fff; + border-radius: 10px; +} +.carousel-indicators .active { + width: 12px; + height: 12px; + margin: 0; + background-color: #fff; +} +.carousel-caption { + position: absolute; + right: 15%; + bottom: 20px; + left: 15%; + z-index: 10; + padding-top: 20px; + padding-bottom: 20px; + color: #fff; + text-align: center; + text-shadow: 0 1px 2px rgba(0, 0, 0, 0.6); +} +.carousel-caption .btn { + text-shadow: none; +} +@media (min-width: 544px) { + .carousel-control .icon-prev, + .carousel-control .icon-next { + width: 30px; + height: 30px; + margin-top: -15px; + font-size: 30px; + } + .carousel-control .icon-prev { + margin-left: -15px; + } + .carousel-control .icon-next { + margin-right: -15px; + } + .carousel-caption { + right: 20%; + left: 20%; + padding-bottom: 30px; + } + .carousel-indicators { + bottom: 20px; + } +} +.bg-inverse { + color: #eceeef; + background-color: #373a3c; +} +.bg-faded { + background-color: #f7f7f9; +} +.bg-primary { + color: #fff !important; + background-color: #5cb85c !important; +} +a.bg-primary:focus, +a.bg-primary:hover { + background-color: #449d44 !important; +} +.bg-success { + color: #fff !important; + background-color: #5cb85c !important; +} +a.bg-success:focus, +a.bg-success:hover { + background-color: #449d44 !important; +} +.bg-info { + color: #fff !important; + background-color: #5bc0de !important; +} +a.bg-info:focus, +a.bg-info:hover { + background-color: #31b0d5 !important; +} +.bg-warning { + color: #fff !important; + background-color: #f0ad4e !important; +} +a.bg-warning:focus, +a.bg-warning:hover { + background-color: #ec971f !important; +} +.bg-danger { + color: #fff !important; + background-color: #b85c5c !important; +} +a.bg-danger:focus, +a.bg-danger:hover { + background-color: #9d4444 !important; +} +.clearfix::after { + content: ''; + display: table; + clear: both; +} +.pull-xs-left { + float: left !important; +} +.pull-xs-right { + float: right !important; +} +.pull-xs-none { + float: none !important; +} +@media (min-width: 544px) { + .pull-sm-left { + float: left !important; + } + .pull-sm-right { + float: right !important; + } + .pull-sm-none { + float: none !important; + } +} +@media (min-width: 768px) { + .pull-md-left { + float: left !important; + } + .pull-md-right { + float: right !important; + } + .pull-md-none { + float: none !important; + } +} +@media (min-width: 992px) { + .pull-lg-left { + float: left !important; + } + .pull-lg-right { + float: right !important; + } + .pull-lg-none { + float: none !important; + } +} +@media (min-width: 1200px) { + .pull-xl-left { + float: left !important; + } + .pull-xl-right { + float: right !important; + } + .pull-xl-none { + float: none !important; + } +} +.sr-only { + position: absolute; + width: 1px; + height: 1px; + padding: 0; + margin: -1px; + overflow: hidden; + clip: rect(0, 0, 0, 0); + border: 0; +} +.sr-only-focusable:active, +.sr-only-focusable:focus { + position: static; + width: auto; + height: auto; + margin: 0; + overflow: visible; + clip: auto; +} +.m-x-auto { + margin-right: auto !important; + margin-left: auto !important; +} +.m-a-0 { + margin: 0 !important; +} +.m-t-0 { + margin-top: 0 !important; +} +.m-r-0 { + margin-right: 0 !important; +} +.m-b-0 { + margin-bottom: 0 !important; +} +.m-l-0 { + margin-left: 0 !important; +} +.m-x-0 { + margin-right: 0 !important; + margin-left: 0 !important; +} +.m-y-0 { + margin-top: 0 !important; + margin-bottom: 0 !important; +} +.m-a-1 { + margin: 1rem !important; +} +.m-t-1 { + margin-top: 1rem !important; +} +.m-r-1 { + margin-right: 1rem !important; +} +.m-b-1 { + margin-bottom: 1rem !important; +} +.m-l-1 { + margin-left: 1rem !important; +} +.m-x-1 { + margin-right: 1rem !important; + margin-left: 1rem !important; +} +.m-y-1 { + margin-top: 1rem !important; + margin-bottom: 1rem !important; +} +.m-a-2 { + margin: 1.5rem !important; +} +.m-t-2 { + margin-top: 1.5rem !important; +} +.m-r-2 { + margin-right: 1.5rem !important; +} +.m-b-2 { + margin-bottom: 1.5rem !important; +} +.m-l-2 { + margin-left: 1.5rem !important; +} +.m-x-2 { + margin-right: 1.5rem !important; + margin-left: 1.5rem !important; +} +.m-y-2 { + margin-top: 1.5rem !important; + margin-bottom: 1.5rem !important; +} +.m-a-3 { + margin: 3rem !important; +} +.m-t-3 { + margin-top: 3rem !important; +} +.m-r-3 { + margin-right: 3rem !important; +} +.m-b-3 { + margin-bottom: 3rem !important; +} +.m-l-3 { + margin-left: 3rem !important; +} +.m-x-3 { + margin-right: 3rem !important; + margin-left: 3rem !important; +} +.m-y-3 { + margin-top: 3rem !important; + margin-bottom: 3rem !important; +} +.p-a-0 { + padding: 0 !important; +} +.p-t-0 { + padding-top: 0 !important; +} +.p-r-0 { + padding-right: 0 !important; +} +.p-b-0 { + padding-bottom: 0 !important; +} +.p-l-0 { + padding-left: 0 !important; +} +.p-x-0 { + padding-right: 0 !important; + padding-left: 0 !important; +} +.p-y-0 { + padding-top: 0 !important; + padding-bottom: 0 !important; +} +.p-a-1 { + padding: 1rem !important; +} +.p-t-1 { + padding-top: 1rem !important; +} +.p-r-1 { + padding-right: 1rem !important; +} +.p-b-1 { + padding-bottom: 1rem !important; +} +.p-l-1 { + padding-left: 1rem !important; +} +.p-x-1 { + padding-right: 1rem !important; + padding-left: 1rem !important; +} +.p-y-1 { + padding-top: 1rem !important; + padding-bottom: 1rem !important; +} +.p-a-2 { + padding: 1.5rem !important; +} +.p-t-2 { + padding-top: 1.5rem !important; +} +.p-r-2 { + padding-right: 1.5rem !important; +} +.p-b-2 { + padding-bottom: 1.5rem !important; +} +.p-l-2 { + padding-left: 1.5rem !important; +} +.p-x-2 { + padding-right: 1.5rem !important; + padding-left: 1.5rem !important; +} +.p-y-2 { + padding-top: 1.5rem !important; + padding-bottom: 1.5rem !important; +} +.p-a-3 { + padding: 3rem !important; +} +.p-t-3 { + padding-top: 3rem !important; +} +.p-r-3 { + padding-right: 3rem !important; +} +.p-b-3 { + padding-bottom: 3rem !important; +} +.p-l-3 { + padding-left: 3rem !important; +} +.p-x-3 { + padding-right: 3rem !important; + padding-left: 3rem !important; +} +.p-y-3 { + padding-top: 3rem !important; + padding-bottom: 3rem !important; +} +.pos-f-t { + position: fixed; + top: 0; + right: 0; + left: 0; + z-index: 1030; +} +.text-justify { + text-align: justify !important; +} +.text-nowrap { + white-space: nowrap !important; +} +.text-truncate { + overflow: hidden; + text-overflow: ellipsis; + white-space: nowrap; +} +.text-xs-left { + text-align: left !important; +} +.text-xs-right { + text-align: right !important; +} +.text-xs-center { + text-align: center !important; +} +@media (min-width: 544px) { + .text-sm-left { + text-align: left !important; + } + .text-sm-right { + text-align: right !important; + } + .text-sm-center { + text-align: center !important; + } +} +@media (min-width: 768px) { + .text-md-left { + text-align: left !important; + } + .text-md-right { + text-align: right !important; + } + .text-md-center { + text-align: center !important; + } +} +@media (min-width: 992px) { + .text-lg-left { + text-align: left !important; + } + .text-lg-right { + text-align: right !important; + } + .text-lg-center { + text-align: center !important; + } +} +@media (min-width: 1200px) { + .text-xl-left { + text-align: left !important; + } + .text-xl-right { + text-align: right !important; + } + .text-xl-center { + text-align: center !important; + } +} +.text-lowercase { + text-transform: lowercase !important; +} +.text-uppercase { + text-transform: uppercase !important; +} +.text-capitalize { + text-transform: capitalize !important; +} +.font-weight-normal { + font-weight: 400; +} +.font-weight-bold { + font-weight: 700; +} +.font-italic { + font-style: italic; +} +.text-muted { + color: #818a91 !important; +} +a.text-muted:focus, +a.text-muted:hover { + color: #687077; +} +.text-primary { + color: #5cb85c !important; +} +a.text-primary:focus, +a.text-primary:hover { + color: #449d44; +} +.text-success { + color: #5cb85c !important; +} +a.text-success:focus, +a.text-success:hover { + color: #449d44; +} +.text-info { + color: #5bc0de !important; +} +a.text-info:focus, +a.text-info:hover { + color: #31b0d5; +} +.text-warning { + color: #f0ad4e !important; +} +a.text-warning:focus, +a.text-warning:hover { + color: #ec971f; +} +.text-danger { + color: #b85c5c !important; +} +a.text-danger:focus, +a.text-danger:hover { + color: #9d4444; +} +.text-hide { + font: 0/0 a; + color: transparent; + text-shadow: none; + background-color: transparent; + border: 0; +} +.invisible { + visibility: hidden !important; +} +.hidden-xs-up { + display: none !important; +} +@media (max-width: 543px) { + .hidden-xs-down { + display: none !important; + } +} +@media (min-width: 544px) { + .hidden-sm-up { + display: none !important; + } +} +@media (max-width: 767px) { + .hidden-sm-down { + display: none !important; + } +} +@media (min-width: 768px) { + .hidden-md-up { + display: none !important; + } +} +@media (max-width: 991px) { + .hidden-md-down { + display: none !important; + } +} +@media (min-width: 992px) { + .hidden-lg-up { + display: none !important; + } +} +@media (max-width: 1199px) { + .hidden-lg-down { + display: none !important; + } +} +@media (min-width: 1200px) { + .hidden-xl-up { + display: none !important; + } +} +.hidden-xl-down { + display: none !important; +} +.visible-print-block { + display: none !important; +} +@media print { + .visible-print-block { + display: block !important; + } +} +.visible-print-inline { + display: none !important; +} +@media print { + .visible-print-inline { + display: inline !important; + } +} +.visible-print-inline-block { + display: none !important; +} +@media print { + .visible-print-inline-block { + display: inline-block !important; + } +} +@media print { + .hidden-print { + display: none !important; + } +} +.flex-xs-first { + order: -1; +} +.flex-xs-last { + order: 1; +} +.flex-items-xs-top { + align-items: flex-start; +} +.flex-items-xs-middle { + align-items: center; +} +.flex-items-xs-bottom { + align-items: flex-end; +} +.flex-xs-top { + align-self: flex-start; +} +.flex-xs-middle { + align-self: center; +} +.flex-xs-bottom { + align-self: flex-end; +} +.flex-items-xs-left { + justify-content: flex-start; +} +.flex-items-xs-center { + justify-content: center; +} +.flex-items-xs-right { + justify-content: flex-end; +} +.flex-items-xs-around { + justify-content: space-around; +} +.flex-items-xs-between { + justify-content: space-between; +} +@media (min-width: 544px) { + .flex-sm-first { + order: -1; + } + .flex-sm-last { + order: 1; + } +} +@media (min-width: 544px) { + .flex-items-sm-top { + align-items: flex-start; + } + .flex-items-sm-middle { + align-items: center; + } + .flex-items-sm-bottom { + align-items: flex-end; + } +} +@media (min-width: 544px) { + .flex-sm-top { + align-self: flex-start; + } + .flex-sm-middle { + align-self: center; + } + .flex-sm-bottom { + align-self: flex-end; + } +} +@media (min-width: 544px) { + .flex-items-sm-left { + justify-content: flex-start; + } + .flex-items-sm-center { + justify-content: center; + } + .flex-items-sm-right { + justify-content: flex-end; + } + .flex-items-sm-around { + justify-content: space-around; + } + .flex-items-sm-between { + justify-content: space-between; + } +} +@media (min-width: 768px) { + .flex-md-first { + order: -1; + } + .flex-md-last { + order: 1; + } +} +@media (min-width: 768px) { + .flex-items-md-top { + align-items: flex-start; + } + .flex-items-md-middle { + align-items: center; + } + .flex-items-md-bottom { + align-items: flex-end; + } +} +@media (min-width: 768px) { + .flex-md-top { + align-self: flex-start; + } + .flex-md-middle { + align-self: center; + } + .flex-md-bottom { + align-self: flex-end; + } +} +@media (min-width: 768px) { + .flex-items-md-left { + justify-content: flex-start; + } + .flex-items-md-center { + justify-content: center; + } + .flex-items-md-right { + justify-content: flex-end; + } + .flex-items-md-around { + justify-content: space-around; + } + .flex-items-md-between { + justify-content: space-between; + } +} +@media (min-width: 992px) { + .flex-lg-first { + order: -1; + } + .flex-lg-last { + order: 1; + } +} +@media (min-width: 992px) { + .flex-items-lg-top { + align-items: flex-start; + } + .flex-items-lg-middle { + align-items: center; + } + .flex-items-lg-bottom { + align-items: flex-end; + } +} +@media (min-width: 992px) { + .flex-lg-top { + align-self: flex-start; + } + .flex-lg-middle { + align-self: center; + } + .flex-lg-bottom { + align-self: flex-end; + } +} +@media (min-width: 992px) { + .flex-items-lg-left { + justify-content: flex-start; + } + .flex-items-lg-center { + justify-content: center; + } + .flex-items-lg-right { + justify-content: flex-end; + } + .flex-items-lg-around { + justify-content: space-around; + } + .flex-items-lg-between { + justify-content: space-between; + } +} +@media (min-width: 1200px) { + .flex-xl-first { + order: -1; + } + .flex-xl-last { + order: 1; + } +} +@media (min-width: 1200px) { + .flex-items-xl-top { + align-items: flex-start; + } + .flex-items-xl-middle { + align-items: center; + } + .flex-items-xl-bottom { + align-items: flex-end; + } +} +@media (min-width: 1200px) { + .flex-xl-top { + align-self: flex-start; + } + .flex-xl-middle { + align-self: center; + } + .flex-xl-bottom { + align-self: flex-end; + } +} +@media (min-width: 1200px) { + .flex-items-xl-left { + justify-content: flex-start; + } + .flex-items-xl-center { + justify-content: center; + } + .flex-items-xl-right { + justify-content: flex-end; + } + .flex-items-xl-around { + justify-content: space-around; + } + .flex-items-xl-between { + justify-content: space-between; + } +} +.tag-default { + color: #fff !important; + font-size: 0.8rem; + padding-top: 0.1rem; + padding-bottom: 0.1rem; + white-space: nowrap; + margin-right: 3px; + margin-bottom: 0.2rem; + display: inline-block; +} +.tag-default:hover { + text-decoration: none; +} +.tag-default.tag-outline { + border: 1px solid #ddd; + color: #aaa !important; + background: 0 0 !important; +} +ul.tag-list { + padding-left: 0 !important; + display: inline-block; + list-style: none !important; +} +ul.tag-list li { + display: inline-block !important; +} +.navbar-brand { + font-family: titillium web, sans-serif; + font-size: 1.5rem !important; + padding-top: 0 !important; + margin-right: 2rem !important; + color: #5cb85c !important; +} +.nav-link .user-pic { + height: 26px; + border-radius: 50px; + float: left; + margin-right: 5px; +} +.nav-link:hover { + transition: 0.1s all; +} +.nav-pills.outline-active .nav-link { + border-radius: 0; + border: none; + border-bottom: 2px solid transparent; + background: 0 0; + color: #aaa; +} +.nav-pills.outline-active .nav-link:hover { + color: #555; +} +.nav-pills.outline-active .nav-link.active { + background: #fff !important; + border-bottom: 2px solid #5cb85c !important; + color: #5cb85c !important; +} +footer { + background: #f3f3f3; + margin-top: 3rem; + padding: 1rem 0; + position: absolute; + bottom: 0; + width: 100%; +} +footer .logo-font { + vertical-align: middle; +} +footer .attribution { + vertical-align: middle; + margin-left: 10px; + font-size: 0.8rem; + color: #bbb; + font-weight: 300; +} +.error-messages { + color: #b85c5c !important; + font-weight: 700; +} +.banner { + color: #fff; + background: #333; + padding: 2rem; + margin-bottom: 2rem; +} +.banner h1 { + text-shadow: 0 1px 3px rgba(0, 0, 0, 0.3); + margin-bottom: 0; +} +.container.page { + margin-top: 1.5rem; +} +.preview-link { + color: inherit !important; +} +.preview-link:hover { + text-decoration: inherit !important; +} +.article-meta { + display: block; + position: relative; + font-weight: 300; +} +.article-meta img { + display: inline-block; + vertical-align: middle; + height: 32px; + width: 32px; + border-radius: 30px; +} +.article-meta .info { + margin: 0 1.5rem 0 0.3rem; + display: inline-block; + vertical-align: middle; + line-height: 1rem; +} +.article-meta .info .author { + display: block; + font-weight: 500 !important; +} +.article-meta .info .date { + color: #bbb; + font-size: 0.8rem; + display: block; +} +.article-preview { + border-top: 1px solid rgba(0, 0, 0, 0.1); + padding: 1.5rem 0; +} +.article-preview .article-meta { + margin: 0 0 1rem; +} +.article-preview .preview-link h1 { + font-weight: 600 !important; + font-size: 1.5rem !important; + margin-bottom: 3px; +} +.article-preview .preview-link p { + font-weight: 300; + font-size: 24px; + color: #999; + margin-bottom: 15px; + font-size: 1rem; + line-height: 1.3rem; +} +.article-preview .preview-link span { + max-width: 30%; + font-size: 0.8rem; + font-weight: 300; + color: #bbb; + vertical-align: middle; +} +.article-preview .preview-link ul { + float: right; + max-width: 50%; + vertical-align: top; +} +.article-preview .preview-link ul li { + font-weight: 300; + font-size: 0.8rem !important; + padding-top: 0 !important; + padding-bottom: 0 !important; +} +.btn .counter { + font-size: 0.8rem !important; +} +.home-page .banner { + background: #5cb85c; + box-shadow: inset 0 8px 8px -8px rgba(0, 0, 0, 0.3), + inset 0 -8px 8px -8px rgba(0, 0, 0, 0.3); +} +.home-page .banner p { + color: #fff; + text-align: center; + font-size: 1.5rem; + font-weight: 300 !important; + margin-bottom: 0; +} +.home-page .banner h1 { + text-shadow: 0 1px 3px rgba(0, 0, 0, 0.3); + font-weight: 700 !important; + text-align: center; + font-size: 3.5rem; + padding-bottom: 0.5rem; +} +.home-page .feed-toggle { + margin-bottom: -1px; +} +.home-page .sidebar { + padding: 5px 10px 10px; + background: #f3f3f3; + border-radius: 4px; +} +.home-page .sidebar p { + margin-bottom: 0.2rem; +} +.article-page .banner { + padding: 2rem 0; +} +.article-page .banner h1 { + font-size: 2.8rem; + font-weight: 600; +} +.article-page .banner .btn { + opacity: 0.8; +} +.article-page .banner .btn:hover { + transition: 0.1s all; + opacity: 1; +} +.article-page .banner .article-meta { + margin: 2rem 0 0; +} +.article-page .banner .article-meta .author { + color: #fff; +} +.article-page .article-content p { + font-family: 'source serif pro', serif; + font-size: 1.2rem; + line-height: 1.8rem; + margin-bottom: 2rem; +} +.article-page .article-content h1, +.article-page .article-content h2, +.article-page .article-content h3, +.article-page .article-content h4, +.article-page .article-content h5, +.article-page .article-content h6 { + font-weight: 500 !important; + margin: 1.6rem 0 1rem; +} +.article-page .article-actions { + text-align: center; + margin: 1.5rem 0 3rem; +} +.article-page .article-actions .article-meta .info { + text-align: left; +} +.article-page .comment-form .card-block { + padding: 0; +} +.article-page .comment-form .card-block textarea { + border: 0; + padding: 1.25rem; +} +.article-page .comment-form .card-footer .btn { + font-weight: 700; + float: right; +} +.article-page .comment-form .card-footer .comment-author-img { + height: 30px; + width: 30px; +} +.article-page .card { + border: 1px solid #e5e5e5; + box-shadow: none !important; +} +.article-page .card .card-footer { + border-top: 1px solid #e5e5e5; + box-shadow: none !important; + font-size: 0.8rem; + font-weight: 300; +} +.article-page .card .comment-author-img { + display: inline-block; + vertical-align: middle; + height: 20px; + width: 20px; + border-radius: 30px; +} +.article-page .card .comment-author { + display: inline-block; + vertical-align: middle; +} +.article-page .card .date-posted { + display: inline-block; + vertical-align: middle; + margin-left: 5px; + color: #bbb; +} +.article-page .card .mod-options { + float: right; + color: #333; + font-size: 1rem; +} +.article-page .card .mod-options i { + margin-left: 5px; + opacity: 0.6; + cursor: pointer; +} +.article-page .card .mod-options i:hover { + opacity: 1; +} +.profile-page .user-info { + text-align: center; + background: #f3f3f3; + padding: 2rem 0 1rem; +} +.profile-page .user-info .user-img { + width: 100px; + height: 100px; + border-radius: 100px; + margin-bottom: 1rem; +} +.profile-page .user-info h4 { + font-weight: 700; +} +.profile-page .user-info p { + margin: 0 auto 0.5rem; + color: #aaa; + max-width: 450px; + font-weight: 300; +} +.profile-page .user-info .action-btn { + float: right; + color: #999; + border: 1px solid #999; +} +.profile-page .articles-toggle { + margin: 1.5rem 0 -1px; +} +.editor-page .tag-list i { + font-size: 0.6rem; + margin-right: 5px; + cursor: pointer; +} diff --git a/front.ts b/front.ts new file mode 100644 index 00000000..f68d307b --- /dev/null +++ b/front.ts @@ -0,0 +1,73 @@ +import UserAPI from 'front/api/user' +import { mutate } from 'swr' + +export const AUTH_COOKIE_NAME = 'auth' +export const AUTH_LOCAL_STORAGE_NAME = 'user' + +// https://stackoverflow.com/questions/4825683/how-do-i-create-and-read-a-value-from-cookie/38699214#38699214 +export function setCookie(name, value, days?: number, path = '/') { + let delta + if (days === undefined) { + delta = Number.MAX_SAFE_INTEGER + } else { + delta = days * 864e5 + } + const expires = new Date(Date.now() + delta).toUTCString() + document.cookie = `${name}=${encodeURIComponent( + value + )};expires=${expires};path=${path}` +} + +export function setCookies(cookieDict, days?: number, path = '/') { + for (const key in cookieDict) { + setCookie(key, cookieDict[key], days, path) + } +} + +export function getCookie(name) { + return getCookieFromString(document.cookie, name) +} + +export function getCookieFromReq(req, name) { + const cookie = req.headers.cookie + if (cookie) { + return getCookieFromString(cookie, name) + } else { + return null + } +} + +export function getCookieFromString(s, name) { + return getCookiesFromString(s)[name] +} + +// https://stackoverflow.com/questions/5047346/converting-strings-like-document-cookie-to-objects +export function getCookiesFromString(s) { + return s.split('; ').reduce((prev, current) => { + const [name, ...value] = current.split('=') + prev[name] = value.join('=') + return prev + }, {}) +} + +export function deleteCookie(name, path = '/') { + setCookie(name, '', -1, path) +} + +export async function setupUserLocalStorage(data, setErrors) { + // We fetch from /profiles/:username again because the return from /users/login above + // does not contain the image placeholder. + const { data: profileData, status: profileStatus } = await UserAPI.get( + data.user.username + ) + if (profileStatus !== 200) { + setErrors(profileData.errors) + } + data.user.effectiveImage = profileData.profile.image + window.localStorage.setItem( + AUTH_LOCAL_STORAGE_NAME, + JSON.stringify(data.user) + ) + setCookie(AUTH_COOKIE_NAME, data.user.token) + mutate(AUTH_LOCAL_STORAGE_NAME, data.user) +} diff --git a/front/ArticleActions.tsx b/front/ArticleActions.tsx new file mode 100644 index 00000000..edc1772f --- /dev/null +++ b/front/ArticleActions.tsx @@ -0,0 +1,63 @@ +import Router, { useRouter } from 'next/router' +import React from 'react' +import { trigger } from 'swr' + +import FavoriteArticleButton from 'front/FavoriteArticleButton' +import FollowUserButton from 'front/FollowUserButton' +import CustomLink from 'front/CustomLink' +import Maybe from 'front/Maybe' +import ArticleAPI from 'front/api/article' +import apiPath from 'front/config' +import useLoggedInUser from 'front/useLoggedInUser' +import routes from 'front/routes' + +const ArticleActions = ({ article }) => { + const loggedInUser = useLoggedInUser() + const router = useRouter() + const { + query: { pid }, + } = router + const handleDelete = async () => { + if (!loggedInUser) return + const result = window.confirm('Do you really want to delete it?') + if (!result) return + await ArticleAPI.delete(pid, loggedInUser?.token) + trigger(`${apiPath}/articles/${pid}`) + Router.push(`/`) + } + const canModify = + loggedInUser && loggedInUser?.username === article?.author?.username + return ( + <> + + + + + + + + + + Edit Article + + + + + + ) +} + +export default ArticleActions diff --git a/front/ArticleEditor.tsx b/front/ArticleEditor.tsx new file mode 100644 index 00000000..b8e42789 --- /dev/null +++ b/front/ArticleEditor.tsx @@ -0,0 +1,159 @@ +import Router, { useRouter } from 'next/router' +import React from 'react' + +import ListErrors from 'front/ListErrors' +import TagInput from 'front/TagInput' +import ArticleAPI from 'front/api/article' +import useLoggedInUser from 'front/useLoggedInUser' +import { useCtrlEnterSubmit } from 'front/ts' +import { AppContext } from 'front/ts' + +function editorReducer(state, action) { + switch (action.type) { + case 'SET_TITLE': + return { + ...state, + title: action.text, + } + case 'SET_DESCRIPTION': + return { + ...state, + description: action.text, + } + case 'SET_BODY': + return { + ...state, + body: action.text, + } + case 'ADD_TAG': + return { + ...state, + tagList: state.tagList.concat(action.tag), + } + case 'REMOVE_TAG': + return { + ...state, + tagList: state.tagList.filter((tag) => tag !== action.tag), + } + default: + throw new Error('Unhandled action') + } +} + +export default function ArticleEditorHoc(isnew = false) { + return function ArticleEditor({ article: initialArticle }) { + let initialState + if (initialArticle) { + initialState = { + title: initialArticle.title, + description: initialArticle.description, + body: initialArticle.body, + tagList: initialArticle.tagList, + } + } else { + initialState = { + title: '', + description: '', + body: '', + tagList: [], + } + } + const [isLoading, setLoading] = React.useState(false) + const [errors, setErrors] = React.useState([]) + const [posting, dispatch] = React.useReducer(editorReducer, initialState) + const loggedInUser = useLoggedInUser() + const router = useRouter() + const handleTitle = (e) => + dispatch({ type: 'SET_TITLE', text: e.target.value }) + const handleDescription = (e) => + dispatch({ type: 'SET_DESCRIPTION', text: e.target.value }) + const handleBody = (e) => + dispatch({ type: 'SET_BODY', text: e.target.value }) + const addTag = (tag) => dispatch({ type: 'ADD_TAG', tag: tag }) + const removeTag = (tag) => dispatch({ type: 'REMOVE_TAG', tag: tag }) + const handleSubmit = async (e) => { + e.preventDefault() + setLoading(true) + let data, status + if (isnew) { + ;({ data, status } = await ArticleAPI.create( + posting, + loggedInUser?.token + )) + } else { + ;({ data, status } = await ArticleAPI.update( + posting, + router.query.pid, + loggedInUser?.token + )) + } + setLoading(false) + if (status !== 200) { + setErrors(data.errors) + } + Router.push(`/article/${data.article.slug}`) + } + useCtrlEnterSubmit(handleSubmit) + const { setTitle } = React.useContext(AppContext) + React.useEffect(() => { + setTitle(isnew ? 'New article' : `Editing: ${initialArticle?.title}`) + }, [setTitle, initialArticle?.title]) + return ( + <> +
+
+
+
+ +
+
+
+ +
+
+ +
+
+