- Ruby 2.7.2
- Rails 6.0.3.4
- Gem bcrypt
- Gem rack-cors
- Gem redis
- Gem jwt-sessions
Shortcuts |
---|
Back-end Development (Ruby on Rails) |
Front-end Development (Vue.js) |
-
Create app in API mode
- run command:
rails new recordstore-back --api
- run command:
-
Add gems
- edit file
/Gemfile
- uncomment redis and bcrypt
- add rack-cors and jwt-sessions
- run command:
bundle install
- edit file
-
Create User model
- run command:
rails g model User email:string password_digest:string
- edit file:
/db/migrate/TIMESTAMP_create_users.rb
- add
, null: false
to email and password_digest lines:t.string :email, null: false
t.string :password_digest, null: false
- add
- run command:
rails db:migrate
- edit file:
/app/models/user.rb
- add
has_secure_password
betweenclass...
andend
- add
- run command:
-
Create Artist model
- run command:
rails g scaffold Artist name
- run command:
-
Create Record model
- run command:
$ rails g scaffold Record title year artist:references user:references
- run command:
-
Migate both models
- run command:
rails db:migrate
- run command:
-
Create
api
directory in/app/controllers
-
Create
v1
directory in/app/controllers/api
-
Move files into
/app/controllers/api/v1/
/app/controllers/artists_controller.rb
to/app/controllers/api/v1/artists_controller.rb
/app/controllers/records_controller.rb
to/app/controllers/api/v1/records_controller.rb
-
Edit both files:
/app/controllers/api/v1/artists_controller.rb
and/app/controllers/api/v1/records_controller.rb
- create 2 new lines at the very top
module Api
module V1
- create 2 new lines at the very bottom
end
end
- select everything in between, and indent twice so it all flows correctly
- example below:
- create 2 new lines at the very top
module Api
module V1
class SomethingController > ApplicationController
...
end
end
end
- edit file:
/app/models/artist.rb
class Artist < ApplicationRecord
has_many :records, dependent: :destroy
validates :name, presence: true
end
- edit file:
/app/models/record.rb
class Record < ApplicationRecord
belongs_to :user
validates :title, :year, presence: true
end
- edit file:
/app/models/user.rb
class User < ApplicationRecord
has_secure_password
has_many :records
end
-
create new file:
/app/controllers/home_controller.rb
-
edit file:
/app/controllers/home_controller.rb
class HomeController < ApplicationController
def index
end
end
- edit file:
/config/routers.rb
Rails.application.routes.draw do
namespace :api do
namespace :v1 do
resources :artists
resources :records
end
end
root to: "home#index"
end
- edit file:
/app/controllers/application_controller.rb
class ApplicationController < ActionController::API
include JWTSessions::RailsAuthorization
rescue_from JWTSessions::Errors::Unauthorized, with: :not_authorized
private
def current_user
@current_user ||= User.find(payload['user_id'])
end
def not_authorized
render json: { error: 'Not Authorized' }, status: :unauthorized
end
end
-
create new file:
/config/initializers/jwt_sessions.rb
-
edit file:
/config/initializers/jwt_sessions.rb
- add line:
JWTSessions.encryption_key = 'secret'
- key can be anything you want...
- add line:
-
edit file:
/config/initializers/cors.rb
- uncomment everything under "read more..."
- replace
example.com
to'http://localhost:8080'
- add line under
headers: :any,
credentials: true,
Rails.application.config.middleware.insert_before 0, Rack::Cors do
allow do
origins 'http://localhost:8080'
resource '*',
headers: :any,
credentials: true,
methods: [:get, :post, :put, :patch, :delete, :options, :head]
end
end
-
create file:
/app/controllers/signup_controller.rb
-
edit file:
/app/controllers/signup_controller.rb
class SignupController < ApplicationController
def create
// pass in a instance of a new user with user_params (method below)
user = User.new(user_params)
if user.save
// Assign the user_id as the payload
payload = { user_id: user.id }
// Create a new token-based session using the payload and JWTSessions
session = JWTSessions::Session.new(payload: payload, refresh_by_access_allowed: true)
tokens = session.login
// Set a cookie with JWTSession token [:access]
response.set_cookie(JWTSessions.access_cookie,
value: tokens[:access],
httponly: true,
secure: Rails.env.production?)
// render final JSON and CSRF tokens to avoid cross-origin request vulnerabilities
render json: { csrf: tokens[:csrf] }
else
// If none of that works, render the errors as JSON
render json: { error: user.errors.full_messages.join(' ') }, status: :unprocessable_entity
end
end
private
// // Create a new user with permitted parameters (email, password, password_confirmation)
def user_params
params.permit(:email, :password, :password_confirmation)
end
end
-
create file:
/app/controllers/signin_controller.rb
-
edit file:
/app/controllers/signin_controller.rb
class SigninController < ApplicationController
before_action :authorize_access_request!, only: [:destroy]
// Login
def create
user = User.find_by!(email: params[:email])
if user.authenticate(params[:password])
payload = { user_id: user.id }
session = JWTSessions::Session.new(payload: payload, refresh_by_access_allowed: true)
tokens = session.login
response.set_cookie(JWTSessions.access_cookie,
value: tokens[:access],
httponly: true,
secure: Rails.env.production?)
render json: { csrf: tokens[:csrf] }
else
not_authorized
end
end
// Logout
def destroy
session = JWTSessions::Session.new(payload: payload)
session.flush_by_access_payload
render json: :ok
end
private
// Bad credentials
def not_found
render json: { error: "Cannot find email/password combination" }, status: :not_found
end
end
-
Note: CSRF: Cross-Site Request Forgery
-
create file:
/app/controllers/refresh_controller.rb
-
edit file:
/app/controllers/refresh_controller.rb
class RefreshController < ApplicationController
before_action :authorize_refresh_by_access_request!
def create
session = JWTSessions::Session.new(payload: claimless_payload, refresh_by_access_allowed: true)
tokens = session.refresh_by_access_payload do
raise JWTSessions::Errors::Unauthorized, "Somethings not right here!"
end
response.set_cookie(JWTSessions.access_cookie,
value: tokens[:access],
httponly: true,
secure: Rails.env.production?)
render json: { csrf: tokens[:csrf] }
end
end
- edit file:
/config/routers.rb
- add lines for
post
anddelete
- add lines for
Rails.application.routes.draw do
namespace :api do
namespace :v1 do
resources :artists do
resources :records
end
end
end
post 'refresh', controller: :refresh, action: :create
post 'signin', controller: :signin, action: :create
post 'signup', controller: :signup, action: :create
delete 'signin', controller: :signin, action: :destroy
end
- edit file:
/app/controllers/api/v1/artists_controller.rb
- add line directly below:
class ArtistsController < ApplicationController
- add to line:
before_action :authorize_access_request!, except: [:show, :index]
- add line directly below:
module Api
module V1
class ArtistsController < ApplicationController
before_action :authorize_access_request!, except: [:show, :index]
before_action :set_record, only: [:show, :update, :destroy]
...
# POST /artists
#def create
# @artist = current_user.artists.build(artist_params)
#...
end
end
end
- edit file:
/app/controllers/api/v1/records_controller.rb
- add line directly below:
class RecordsController < ApplicationController
- add to line:
before_action :authorize_access_request!, except: [:show, :index]
- replace SOME lines from
Record
tocurrent_user.records
- delete
:user_id
parameter from last method
- add line directly below:
module Api
module V1
class RecordsController < ApplicationController
before_action :authorize_access_request!, except: [:show, :index]
before_action :set_record, only: [:show, :update, :destroy]
...
# GET /records
def index
@records = current_user.records.all
...
# POST /records
def create
@record = current_user.records.build(record_params)
...
# DELETE /records/1
...
def set_record
@record = current_user.records.find(params[:id])
...
#Only allow a trusted parameter "white list" through.
def records_params
params.require(:record).permit(:title, :year, :artist_id)
...
end
end
end
- edit file:
/app/controllers/home_controller.rb
- add lines in
index
method
- add lines in
class HomeController < ApplicationController
def index
@artists = Artists.all
render json: @artists
end
end
- starts rails server and verify it all works
- should have empty brackets (meaning no data to show)
- run command:
rails s
- browser URL:
localhost:3000
- browser URL:
-
enter sample data in rails console
- run command:
rails c
- enter data:
Artist.create!(name: "AC/DC")
- enter data:
Artist.create!(name: "Jimi Hendrix")
- enter data:
Artist.create!(name: "Alice in Chains")
- run command:
exit
- enter data:
- run command:
-
verify and view data
- run command:
rails s
- browser URL:
localhost:3000
- browser URL:
- run command:
-
Reference: Vue CLI 3
-
Install Vue CLI
- run command:
yarn global add @vue/cli
- run command:
yarn global add @vue/cli-init
- run command:
-
create project
- run command:
vue init webpack recordstore-front
- project name:
recordstore-front
<- default, just hit enter - project desc:
A Vue.js front-end app for a Ruby on Rails API backend app
- Author: default enter OR
Jason Campbell <jcryuu@gmail.com>
<- default, just hit enter - Vue build:
standalone
<- default, just hit enter - Install vue-router? (Y/n):
Y
- Use ESLint to lint your code? (Y/n):
Y
- Pick and ESLint preset:
Standard
<- default, just hit enter - Set up unit tests (Y/n):
Y
- Pick a test runner:
karma
- Setup e2e tests with Nightwatch? (Y/n):
n
- Should we run 'npm install' for you after the project has been created?
Yes, use Yarn
- project name:
- run command:
-
go into new Vue project
cd recordstore-front
- run command:
yarn dev
- application will run here:
http://localhost:8081
- application will run here:
- run command:
-
add and initialize tailwind css for front also
- run command:
yarn add tailwindcss -
- run command:
./node_modules/.bin/tailwind init
- run command:
-
edit file:
/recordstore-front/tailwind.config.js
module.exports = {
future: {
// removeDeprecatedGapUtilities: true,
// purgeLayersByDefault: true,
},
purge: [],
target: 'relaxed',
prefix: '',
important: false,
separator: ':',
presets: [],
theme: {
screens: {
sm: '640px',
md: '768px',
lg: '1024px',
xl: '1280px',
},
colors: {
transparent: 'transparent',
current: 'currentColor',
black: '#000',
white: '#fff',
gray: {
100: '#f7fafc',
200: '#edf2f7',
300: '#e2e8f0',
400: '#cbd5e0',
500: '#a0aec0',
600: '#718096',
700: '#4a5568',
800: '#2d3748',
900: '#1a202c',
},
red: {
100: '#fff5f5',
200: '#fed7d7',
300: '#feb2b2',
400: '#fc8181',
500: '#f56565',
600: '#e53e3e',
700: '#c53030',
800: '#9b2c2c',
900: '#742a2a',
},
orange: {
100: '#fffaf0',
200: '#feebc8',
300: '#fbd38d',
400: '#f6ad55',
500: '#ed8936',
600: '#dd6b20',
700: '#c05621',
800: '#9c4221',
900: '#7b341e',
},
yellow: {
100: '#fffff0',
200: '#fefcbf',
300: '#faf089',
400: '#f6e05e',
500: '#ecc94b',
600: '#d69e2e',
700: '#b7791f',
800: '#975a16',
900: '#744210',
},
green: {
100: '#f0fff4',
200: '#c6f6d5',
300: '#9ae6b4',
400: '#68d391',
500: '#48bb78',
600: '#38a169',
700: '#2f855a',
800: '#276749',
900: '#22543d',
},
teal: {
100: '#e6fffa',
200: '#b2f5ea',
300: '#81e6d9',
400: '#4fd1c5',
500: '#38b2ac',
600: '#319795',
700: '#2c7a7b',
800: '#285e61',
900: '#234e52',
},
blue: {
100: '#ebf8ff',
200: '#bee3f8',
300: '#90cdf4',
400: '#63b3ed',
500: '#4299e1',
600: '#3182ce',
700: '#2b6cb0',
800: '#2c5282',
900: '#2a4365',
},
indigo: {
100: '#ebf4ff',
200: '#c3dafe',
300: '#a3bffa',
400: '#7f9cf5',
500: '#667eea',
600: '#5a67d8',
700: '#4c51bf',
800: '#434190',
900: '#3c366b',
},
purple: {
100: '#faf5ff',
200: '#e9d8fd',
300: '#d6bcfa',
400: '#b794f4',
500: '#9f7aea',
600: '#805ad5',
700: '#6b46c1',
800: '#553c9a',
900: '#44337a',
},
pink: {
100: '#fff5f7',
200: '#fed7e2',
300: '#fbb6ce',
400: '#f687b3',
500: '#ed64a6',
600: '#d53f8c',
700: '#b83280',
800: '#97266d',
900: '#702459',
},
},
spacing: {
px: '1px',
'0': '0',
'1': '0.25rem',
'2': '0.5rem',
'3': '0.75rem',
'4': '1rem',
'5': '1.25rem',
'6': '1.5rem',
'8': '2rem',
'10': '2.5rem',
'12': '3rem',
'16': '4rem',
'20': '5rem',
'24': '6rem',
'32': '8rem',
'40': '10rem',
'48': '12rem',
'56': '14rem',
'64': '16rem',
},
backgroundColor: theme => theme('colors'),
backgroundImage: {
none: 'none',
'gradient-to-t': 'linear-gradient(to top, var(--gradient-color-stops))',
'gradient-to-tr': 'linear-gradient(to top right, var(--gradient-color-stops))',
'gradient-to-r': 'linear-gradient(to right, var(--gradient-color-stops))',
'gradient-to-br': 'linear-gradient(to bottom right, var(--gradient-color-stops))',
'gradient-to-b': 'linear-gradient(to bottom, var(--gradient-color-stops))',
'gradient-to-bl': 'linear-gradient(to bottom left, var(--gradient-color-stops))',
'gradient-to-l': 'linear-gradient(to left, var(--gradient-color-stops))',
'gradient-to-tl': 'linear-gradient(to top left, var(--gradient-color-stops))',
},
gradientColorStops: theme => theme('colors'),
backgroundOpacity: theme => theme('opacity'),
backgroundPosition: {
bottom: 'bottom',
center: 'center',
left: 'left',
'left-bottom': 'left bottom',
'left-top': 'left top',
right: 'right',
'right-bottom': 'right bottom',
'right-top': 'right top',
top: 'top',
},
backgroundSize: {
auto: 'auto',
cover: 'cover',
contain: 'contain',
},
borderColor: theme => ({
...theme('colors'),
default: theme('colors.gray.300', 'currentColor'),
}),
borderOpacity: theme => theme('opacity'),
borderRadius: {
none: '0',
sm: '0.125rem',
default: '0.25rem',
md: '0.375rem',
lg: '0.5rem',
xl: '0.75rem',
'2xl': '1rem',
'3xl': '1.5rem',
full: '9999px',
},
borderWidth: {
default: '1px',
'0': '0',
'2': '2px',
'4': '4px',
'8': '8px',
},
boxShadow: {
xs: '0 0 0 1px rgba(0, 0, 0, 0.05)',
sm: '0 1px 2px 0 rgba(0, 0, 0, 0.05)',
default: '0 1px 3px 0 rgba(0, 0, 0, 0.1), 0 1px 2px 0 rgba(0, 0, 0, 0.06)',
md: '0 4px 6px -1px rgba(0, 0, 0, 0.1), 0 2px 4px -1px rgba(0, 0, 0, 0.06)',
lg: '0 10px 15px -3px rgba(0, 0, 0, 0.1), 0 4px 6px -2px rgba(0, 0, 0, 0.05)',
xl: '0 20px 25px -5px rgba(0, 0, 0, 0.1), 0 10px 10px -5px rgba(0, 0, 0, 0.04)',
'2xl': '0 25px 50px -12px rgba(0, 0, 0, 0.25)',
inner: 'inset 0 2px 4px 0 rgba(0, 0, 0, 0.06)',
outline: '0 0 0 3px rgba(66, 153, 225, 0.5)',
none: 'none',
},
container: {},
cursor: {
auto: 'auto',
default: 'default',
pointer: 'pointer',
wait: 'wait',
text: 'text',
move: 'move',
'not-allowed': 'not-allowed',
},
divideColor: theme => theme('borderColor'),
divideOpacity: theme => theme('borderOpacity'),
divideWidth: theme => theme('borderWidth'),
fill: {
current: 'currentColor',
},
flex: {
'1': '1 1 0%',
auto: '1 1 auto',
initial: '0 1 auto',
none: 'none',
},
flexGrow: {
'0': '0',
default: '1',
},
flexShrink: {
'0': '0',
default: '1',
},
fontFamily: {
sans: [
'system-ui',
'-apple-system',
'BlinkMacSystemFont',
'"Segoe UI"',
'Roboto',
'"Helvetica Neue"',
'Arial',
'"Noto Sans"',
'sans-serif',
'"Apple Color Emoji"',
'"Segoe UI Emoji"',
'"Segoe UI Symbol"',
'"Noto Color Emoji"',
],
serif: ['Georgia', 'Cambria', '"Times New Roman"', 'Times', 'serif'],
mono: ['Menlo', 'Monaco', 'Consolas', '"Liberation Mono"', '"Courier New"', 'monospace'],
},
fontSize: {
xs: '0.75rem',
sm: '0.875rem',
base: '1rem',
lg: '1.125rem',
xl: '1.25rem',
'2xl': '1.5rem',
'3xl': '1.875rem',
'4xl': '2.25rem',
'5xl': '3rem',
'6xl': '4rem',
},
fontWeight: {
hairline: '100',
thin: '200',
light: '300',
normal: '400',
medium: '500',
semibold: '600',
bold: '700',
extrabold: '800',
black: '900',
},
height: theme => ({
auto: 'auto',
...theme('spacing'),
full: '100%',
screen: '100vh',
}),
inset: {
'0': '0',
auto: 'auto',
},
letterSpacing: {
tighter: '-0.05em',
tight: '-0.025em',
normal: '0',
wide: '0.025em',
wider: '0.05em',
widest: '0.1em',
},
lineHeight: {
none: '1',
tight: '1.25',
snug: '1.375',
normal: '1.5',
relaxed: '1.625',
loose: '2',
'3': '.75rem',
'4': '1rem',
'5': '1.25rem',
'6': '1.5rem',
'7': '1.75rem',
'8': '2rem',
'9': '2.25rem',
'10': '2.5rem',
},
listStyleType: {
none: 'none',
disc: 'disc',
decimal: 'decimal',
},
margin: (theme, { negative }) => ({
auto: 'auto',
...theme('spacing'),
...negative(theme('spacing')),
}),
maxHeight: {
full: '100%',
screen: '100vh',
},
maxWidth: (theme, { breakpoints }) => ({
none: 'none',
xs: '20rem',
sm: '24rem',
md: '28rem',
lg: '32rem',
xl: '36rem',
'2xl': '42rem',
'3xl': '48rem',
'4xl': '56rem',
'5xl': '64rem',
'6xl': '72rem',
full: '100%',
...breakpoints(theme('screens')),
}),
minHeight: {
'0': '0',
full: '100%',
screen: '100vh',
},
minWidth: {
'0': '0',
full: '100%',
},
objectPosition: {
bottom: 'bottom',
center: 'center',
left: 'left',
'left-bottom': 'left bottom',
'left-top': 'left top',
right: 'right',
'right-bottom': 'right bottom',
'right-top': 'right top',
top: 'top',
},
opacity: {
'0': '0',
'25': '0.25',
'50': '0.5',
'75': '0.75',
'100': '1',
},
order: {
first: '-9999',
last: '9999',
none: '0',
'1': '1',
'2': '2',
'3': '3',
'4': '4',
'5': '5',
'6': '6',
'7': '7',
'8': '8',
'9': '9',
'10': '10',
'11': '11',
'12': '12',
},
outline: {
none: ['2px solid transparent', '2px'],
white: ['2px dotted white', '2px'],
black: ['2px dotted black', '2px'],
},
padding: theme => theme('spacing'),
placeholderColor: theme => theme('colors'),
placeholderOpacity: theme => theme('opacity'),
space: (theme, { negative }) => ({
...theme('spacing'),
...negative(theme('spacing')),
}),
stroke: {
current: 'currentColor',
},
strokeWidth: {
'0': '0',
'1': '1',
'2': '2',
},
textColor: theme => theme('colors'),
textOpacity: theme => theme('opacity'),
width: theme => ({
auto: 'auto',
...theme('spacing'),
'1/2': '50%',
'1/3': '33.333333%',
'2/3': '66.666667%',
'1/4': '25%',
'2/4': '50%',
'3/4': '75%',
'1/5': '20%',
'2/5': '40%',
'3/5': '60%',
'4/5': '80%',
'1/6': '16.666667%',
'2/6': '33.333333%',
'3/6': '50%',
'4/6': '66.666667%',
'5/6': '83.333333%',
'1/12': '8.333333%',
'2/12': '16.666667%',
'3/12': '25%',
'4/12': '33.333333%',
'5/12': '41.666667%',
'6/12': '50%',
'7/12': '58.333333%',
'8/12': '66.666667%',
'9/12': '75%',
'10/12': '83.333333%',
'11/12': '91.666667%',
full: '100%',
screen: '100vw',
}),
zIndex: {
auto: 'auto',
'0': '0',
'10': '10',
'20': '20',
'30': '30',
'40': '40',
'50': '50',
},
gap: theme => theme('spacing'),
gridTemplateColumns: {
none: 'none',
'1': 'repeat(1, minmax(0, 1fr))',
'2': 'repeat(2, minmax(0, 1fr))',
'3': 'repeat(3, minmax(0, 1fr))',
'4': 'repeat(4, minmax(0, 1fr))',
'5': 'repeat(5, minmax(0, 1fr))',
'6': 'repeat(6, minmax(0, 1fr))',
'7': 'repeat(7, minmax(0, 1fr))',
'8': 'repeat(8, minmax(0, 1fr))',
'9': 'repeat(9, minmax(0, 1fr))',
'10': 'repeat(10, minmax(0, 1fr))',
'11': 'repeat(11, minmax(0, 1fr))',
'12': 'repeat(12, minmax(0, 1fr))',
},
gridAutoColumns: {
auto: 'auto',
min: 'min-content',
max: 'max-content',
fr: 'minmax(0, 1fr)',
},
gridColumn: {
auto: 'auto',
'span-1': 'span 1 / span 1',
'span-2': 'span 2 / span 2',
'span-3': 'span 3 / span 3',
'span-4': 'span 4 / span 4',
'span-5': 'span 5 / span 5',
'span-6': 'span 6 / span 6',
'span-7': 'span 7 / span 7',
'span-8': 'span 8 / span 8',
'span-9': 'span 9 / span 9',
'span-10': 'span 10 / span 10',
'span-11': 'span 11 / span 11',
'span-12': 'span 12 / span 12',
'span-full': '1 / -1',
},
gridColumnStart: {
auto: 'auto',
'1': '1',
'2': '2',
'3': '3',
'4': '4',
'5': '5',
'6': '6',
'7': '7',
'8': '8',
'9': '9',
'10': '10',
'11': '11',
'12': '12',
'13': '13',
},
gridColumnEnd: {
auto: 'auto',
'1': '1',
'2': '2',
'3': '3',
'4': '4',
'5': '5',
'6': '6',
'7': '7',
'8': '8',
'9': '9',
'10': '10',
'11': '11',
'12': '12',
'13': '13',
},
gridTemplateRows: {
none: 'none',
'1': 'repeat(1, minmax(0, 1fr))',
'2': 'repeat(2, minmax(0, 1fr))',
'3': 'repeat(3, minmax(0, 1fr))',
'4': 'repeat(4, minmax(0, 1fr))',
'5': 'repeat(5, minmax(0, 1fr))',
'6': 'repeat(6, minmax(0, 1fr))',
},
gridAutoRows: {
auto: 'auto',
min: 'min-content',
max: 'max-content',
fr: 'minmax(0, 1fr)',
},
gridRow: {
auto: 'auto',
'span-1': 'span 1 / span 1',
'span-2': 'span 2 / span 2',
'span-3': 'span 3 / span 3',
'span-4': 'span 4 / span 4',
'span-5': 'span 5 / span 5',
'span-6': 'span 6 / span 6',
'span-full': '1 / -1',
},
gridRowStart: {
auto: 'auto',
'1': '1',
'2': '2',
'3': '3',
'4': '4',
'5': '5',
'6': '6',
'7': '7',
},
gridRowEnd: {
auto: 'auto',
'1': '1',
'2': '2',
'3': '3',
'4': '4',
'5': '5',
'6': '6',
'7': '7',
},
transformOrigin: {
center: 'center',
top: 'top',
'top-right': 'top right',
right: 'right',
'bottom-right': 'bottom right',
bottom: 'bottom',
'bottom-left': 'bottom left',
left: 'left',
'top-left': 'top left',
},
scale: {
'0': '0',
'50': '.5',
'75': '.75',
'90': '.9',
'95': '.95',
'100': '1',
'105': '1.05',
'110': '1.1',
'125': '1.25',
'150': '1.5',
},
rotate: {
'-180': '-180deg',
'-90': '-90deg',
'-45': '-45deg',
'-12': '-12deg',
'-6': '-6deg',
'-3': '-3deg',
'-2': '-2deg',
'-1': '-1deg',
'0': '0',
'1': '1deg',
'2': '2deg',
'3': '3deg',
'6': '6deg',
'12': '12deg',
'45': '45deg',
'90': '90deg',
'180': '180deg',
},
translate: (theme, { negative }) => ({
...theme('spacing'),
...negative(theme('spacing')),
'-full': '-100%',
'-1/2': '-50%',
'1/2': '50%',
full: '100%',
}),
skew: {
'-12': '-12deg',
'-6': '-6deg',
'-3': '-3deg',
'-2': '-2deg',
'-1': '-1deg',
'0': '0',
'1': '1deg',
'2': '2deg',
'3': '3deg',
'6': '6deg',
'12': '12deg',
},
transitionProperty: {
none: 'none',
all: 'all',
default: 'background-color, border-color, color, fill, stroke, opacity, box-shadow, transform',
colors: 'background-color, border-color, color, fill, stroke',
opacity: 'opacity',
shadow: 'box-shadow',
transform: 'transform',
},
transitionTimingFunction: {
linear: 'linear',
in: 'cubic-bezier(0.4, 0, 1, 1)',
out: 'cubic-bezier(0, 0, 0.2, 1)',
'in-out': 'cubic-bezier(0.4, 0, 0.2, 1)',
},
transitionDuration: {
'75': '75ms',
'100': '100ms',
'150': '150ms',
'200': '200ms',
'300': '300ms',
'500': '500ms',
'700': '700ms',
'1000': '1000ms',
},
transitionDelay: {
'75': '75ms',
'100': '100ms',
'150': '150ms',
'200': '200ms',
'300': '300ms',
'500': '500ms',
'700': '700ms',
'1000': '1000ms',
},
animation: {
none: 'none',
spin: 'spin 1s linear infinite',
ping: 'ping 1s cubic-bezier(0, 0, 0.2, 1) infinite',
pulse: 'pulse 2s cubic-bezier(0.4, 0, 0.6, 1) infinite',
bounce: 'bounce 1s infinite',
},
keyframes: {
spin: {
to: { transform: 'rotate(360deg)' },
},
ping: {
'75%, 100%': { transform: 'scale(2)', opacity: '0' },
},
pulse: {
'50%': { opacity: '.5' },
},
bounce: {
'0%, 100%': {
transform: 'translateY(-25%)',
animationTimingFunction: 'cubic-bezier(0.8,0,1,1)',
},
'50%': {
transform: 'none',
animationTimingFunction: 'cubic-bezier(0,0,0.2,1)',
},
},
},
},
variants: {
accessibility: ['responsive', 'focus'],
alignContent: ['responsive'],
alignItems: ['responsive'],
alignSelf: ['responsive'],
appearance: ['responsive'],
backgroundAttachment: ['responsive'],
backgroundClip: ['responsive'],
backgroundColor: ['responsive', 'hover', 'focus'],
backgroundImage: ['responsive'],
gradientColorStops: ['responsive', 'hover', 'focus'],
backgroundOpacity: ['responsive', 'hover', 'focus'],
backgroundPosition: ['responsive'],
backgroundRepeat: ['responsive'],
backgroundSize: ['responsive'],
borderCollapse: ['responsive'],
borderColor: ['responsive', 'hover', 'focus'],
borderOpacity: ['responsive', 'hover', 'focus'],
borderRadius: ['responsive'],
borderStyle: ['responsive'],
borderWidth: ['responsive'],
boxShadow: ['responsive', 'hover', 'focus'],
boxSizing: ['responsive'],
container: ['responsive'],
cursor: ['responsive'],
display: ['responsive'],
divideColor: ['responsive'],
divideOpacity: ['responsive'],
divideStyle: ['responsive'],
divideWidth: ['responsive'],
fill: ['responsive'],
flex: ['responsive'],
flexDirection: ['responsive'],
flexGrow: ['responsive'],
flexShrink: ['responsive'],
flexWrap: ['responsive'],
float: ['responsive'],
clear: ['responsive'],
fontFamily: ['responsive'],
fontSize: ['responsive'],
fontSmoothing: ['responsive'],
fontVariantNumeric: ['responsive'],
fontStyle: ['responsive'],
fontWeight: ['responsive', 'hover', 'focus'],
height: ['responsive'],
inset: ['responsive'],
justifyContent: ['responsive'],
justifyItems: ['responsive'],
justifySelf: ['responsive'],
letterSpacing: ['responsive'],
lineHeight: ['responsive'],
listStylePosition: ['responsive'],
listStyleType: ['responsive'],
margin: ['responsive'],
maxHeight: ['responsive'],
maxWidth: ['responsive'],
minHeight: ['responsive'],
minWidth: ['responsive'],
objectFit: ['responsive'],
objectPosition: ['responsive'],
opacity: ['responsive', 'hover', 'focus'],
order: ['responsive'],
outline: ['responsive', 'focus'],
overflow: ['responsive'],
overscrollBehavior: ['responsive'],
padding: ['responsive'],
placeContent: ['responsive'],
placeItems: ['responsive'],
placeSelf: ['responsive'],
placeholderColor: ['responsive', 'focus'],
placeholderOpacity: ['responsive', 'focus'],
pointerEvents: ['responsive'],
position: ['responsive'],
resize: ['responsive'],
space: ['responsive'],
stroke: ['responsive'],
strokeWidth: ['responsive'],
tableLayout: ['responsive'],
textAlign: ['responsive'],
textColor: ['responsive', 'hover', 'focus'],
textOpacity: ['responsive', 'hover', 'focus'],
textDecoration: ['responsive', 'hover', 'focus'],
textTransform: ['responsive'],
userSelect: ['responsive'],
verticalAlign: ['responsive'],
visibility: ['responsive'],
whitespace: ['responsive'],
width: ['responsive'],
wordBreak: ['responsive'],
zIndex: ['responsive'],
gap: ['responsive'],
gridAutoFlow: ['responsive'],
gridTemplateColumns: ['responsive'],
gridAutoColumns: ['responsive'],
gridColumn: ['responsive'],
gridColumnStart: ['responsive'],
gridColumnEnd: ['responsive'],
gridTemplateRows: ['responsive'],
gridAutoRows: ['responsive'],
gridRow: ['responsive'],
gridRowStart: ['responsive'],
gridRowEnd: ['responsive'],
transform: ['responsive'],
transformOrigin: ['responsive'],
scale: ['responsive', 'hover', 'focus'],
rotate: ['responsive', 'hover', 'focus'],
translate: ['responsive', 'hover', 'focus'],
skew: ['responsive', 'hover', 'focus'],
transitionProperty: ['responsive'],
transitionTimingFunction: ['responsive'],
transitionDuration: ['responsive'],
transitionDelay: ['responsive'],
animation: ['responsive'],
},
corePlugins: {},
plugins: [],
}
-
add file:
/recordstore-front/src/main.css
-
edit file:
/recordstore-front/src/main.css
/**
* This injects Tailwind's base styles, which is a combination of
* Normalize.css and some additional base styles.
*
* You can see the styles here:
* https://github.com/tailwindcss/tailwindcss/blob/master/css/preflight.css
*
* If using `postcss-import`, use this import instead:
*
* @import "tailwindcss/preflight";
*/
@tailwind preflight;
/**
* This injects any component classes registered by plugins.
*
* If using `postcss-import`, use this import instead:
*
* @import "tailwindcss/components";
*/
@tailwind components;
/**
* Here you would add any of your custom component classes; stuff that you'd
* want loaded *before* the utilities so that the utilities could still
* override them.
*
* Example:
*
* .btn { ... }
* .form-input { ... }
*
* Or if using a preprocessor or `postcss-import`:
*
* @import "components/buttons";
* @import "components/forms";
*/
.input {
@apply appearance-none block w-full bg-grey-lighter text-grey-darker border border-grey-lighter rounded py-3 px-4 leading-tight;
}
.input:focus {
@apply outline-none bg-white border-grey;
}
.label {
@apply block uppercase tracking-wide text-grey-darker text-xs font-bold mb-2;
}
.select {
@apply appearance-none py-3 px-4 pr-8 block w-full bg-grey-lighter border border-grey-lighter text-grey-darker
rounded leading-tight;
-webkit-appearance: none;
}
.select:focus {
@apply outline-none bg-white border-grey;
}
.link {
@apply no-underline;
}
.link-grey {
@apply text-grey-dark;
}
.link-grey:hover {
@apply text-grey-darker;
}
/**
* This injects all of Tailwind's utility classes, generated based on your
* config file.
*
* If using `postcss-import`, use this import instead:
*
* @import "tailwindcss/utilities";
*/
@tailwind utilities;
/**
* Here you would add any custom utilities you need that don't come out of the
* box with Tailwind.
*
* Example :
*
* .bg-pattern-graph-paper { ... }
* .skew-45 { ... }
*
* Or if using a preprocessor or `postcss-import`:
*
* @import "utilities/background-patterns";
* @import "utilities/skew-transforms";
*/
-
edit file:
/recordstore-front/src/main.js
- add import:
import './main.css'
- add import:
-
edit file:
/recordstore-front/.postcssrc.js
- add line:
"tailwindcss": "./tailwind.js",
- add line:
module.exports = {
"plugins": {
"postcss-import": {},
"tailwindcss": "./tailwind.js",
"autoprefixer": {}
}
}
-
add axios to library
- run command:
yarn add axios vue-axios
- run command:
-
create folder:
/recordstore-front/src/backend
- create folder:
/recordstore-front/src/backend/axios
- create file:
/recordstore-front/src/backend/axios/index.js
- create file:
- create folder:
import axios from 'axios'
const API_URL = 'http://localhost:3000'
const securedAxiosInstance = axios.create({
baseURL: API_URL,
withCredentials: true,
headers: {
'Content-Type': 'application/json'
}
})
const plainAxiosInstance = axios.create({
baseURL: API_URL,
withCredentials: true,
headers: {
'Content-Type': 'application/json'
}
})
securedAxiosInstance.interceptors.request.use(config => {
const method = config.method.toUpperCase()
if (method !== 'OPTIONS' && method !== 'GET') {
config.headers = {
...config.headers,
'X-CSRF-TOKEN': localStorage.csrf
}
}
return config
})
securedAxiosInstance.interceptors.response.use(null, error => {
if (error.response && error.response.config && error.response.status === 401) {
// If 401 by expired access cookie, we do a refresh request
return plainAxiosInstance.post('/refresh', {}, { headers: { 'X-CSRF-TOKEN': localStorage.csrf } })
.then(response => {
localStorage.csrf = response.data.csrf
localStorage.signedIn = true
// After another successfull refresh - repeat original request
let retryConfig = error.response.config
retryConfig.headers['X-CSRF-TOKEN'] = localStorage.csrf
return plainAxiosInstance.request(retryConfig)
}).catch(error => {
delete localStorage.csrf
delete localStorage.signedIn
// redirect to signin if refresh fails
location.replace('/')
return Promise.reject(error)
})
} else {
return Promise.reject(error)
}
})
export { securedAxiosInstance, plainAxiosInstance }
edit file: /recordstore-front/src/main.js
- add line: import VueAxios from 'vue-axios'
- add line: import { securedAxiosInstance, plainAxiosInstance } from './backend/axios'
- add lines: Vue.use(VueAxios, { secured: securedAxiosInstance, plain: plainAxiosInstance })
- add line in new Vue: securedAxiosInstance,plainAxiosInstance,
import Vue from 'vue'
import App from './App'
import router from './router'
import VueAxios from 'vue-axios'
import { securedAxiosInstance, plainAxiosInstance } from './backend/axios'
import './main.css' // tailwind
Vue.config.productionTip = false
Vue.use(VueAxios, {
secured: securedAxiosInstance,
plain: plainAxiosInstance
})
/* eslint-disable no-new */
new Vue({
el: '#app',
router,
securedAxiosInstance,
plainAxiosInstance,
components: { App },
template: '<App/>'
})
-
edit file:
/recordstore-front/src/App.vue
- remove line:
<img src="./assets/logo.png">
- remove style tag and properties: `<style>...</style>
- remove line:
-
edit file:
/recordstore-front/router/index.js
- remove line:
import HelloWorld from '@/components/HelloWorld
- add line:
import Signin from '@/components/Signin'
- replace line:
name: 'HelloWorld',
withname: 'Signin',
- replace line:
component: HelloWorld
withcomponent: Signin
- remove line:
import Vue from 'vue'
import Router from 'vue-router'
import Signin from '@/components/Signin'
Vue.use(Router)
export default new Router({
routes: [
{
path: '/',
name: 'Signin',
component: Signin
}
]
})
-
delete file:
/recordstore-front/src/components/HelloWorld.vue
-
create file:
/recordstore-front/src/components/Signin.vue
-
edit file:
/recordstore-front/src/components/Signin.vue
<template>
<div class="max-w-sm m-auto my-8">
<div class="border p-10 border-grey-light shadow rounded">
<h3 class="text-2xl mb-6 text-grey-darkest">Sign In</h3>
<form @submit.prevent="signin">
<div class="text-red" v-if="error">{{ error }}</div>
<div class="mb-6">
<label for="email" class="label">E-mail Address</label>
<input type="email" v-model="email" class="input" id="email" placeholder="andy@web-crunch.com">
</div>
<div class="mb-6">
<label for="password" class="label">Password</label>
<input type="password" v-model="password" class="input" id="password" placeholder="Password">
</div>
<button type="submit" class="font-sans font-bold px-4 rounded cursor-pointer no-underline bg-green hover:bg-green-dark block w-full py-4 text-white items-center justify-center">Sign In</button>
<div class="my-4"><router-link to="/signup" class="link-grey">Sign up</router-link></div>
</form>
</div>
</div>
</template>
<script>
export default {
name: 'Signin',
data () {
return {
email: '',
password: '',
error: ''
}
},
created () {
this.checkSignedIn()
},
updated () {
this.checkSignedIn()
},
methods: {
signin () {
this.$http.plain.post('/signin', { email: this.email, password: this.password })
.then(response => this.signinSuccessful(response))
.catch(error => this.signinFailed(error))
},
signinSuccessful (response) {
if (!response.data.csrf) {
this.signinFailed(response)
return
}
localStorage.csrf = response.data.csrf
localStorage.signedIn = true
this.error = ''
this.$router.replace('/records')
},
signinFailed (error) {
this.error = (error.response && error.response.data && error.response.data.error) || ''
delete localStorage.csrf
delete localStorage.signedIn
},
checkSignedIn () {
if (localStorage.signedIn) {
this.$router.replace('/records')
}
}
}
}
</script>
-
create file:
/recordstore-front/src/components/Signup.vue
-
edit file:
/recordstore-front/src/components/Signup.vue
<template>
<div class="max-w-sm m-auto my-8">
<div class="border p-10 border-grey-light shadow rounded">
<h3 class="text-2xl mb-6 text-grey-darkest">Sign Up</h3>
<form @submit.prevent="signup">
<div class="text-red" v-if="error">{{ error }}</div>
<div class="mb-6">
<label for="email" class="label">E-mail Address</label>
<input type="email" v-model="email" class="input" id="email" placeholder="andy@web-crunch.com">
</div>
<div class="mb-6">
<label for="password" class="label">Password</label>
<input type="password" v-model="password" class="input" id="password" placeholder="Password">
</div>
<div class="mb-6">
<label for="password_confirmation" class="label">Password Confirmation</label>
<input type="password" v-model="password_confirmation" class="input" id="password_confirmation" placeholder="Password Confirmation">
</div>
<button type="submit" class="font-sans font-bold px-4 rounded cursor-pointer no-underline bg-green hover:bg-green-dark block w-full py-4 text-white items-center justify-center">Sign Up</button>
<div class="my-4"><router-link to="/" class="link-grey">Sign In</router-link></div>
</form>
</div>
</div>
</template>
<script>
export default {
name: 'Signup',
data () {
return {
email: '',
password: '',
password_confirmation: '',
error: ''
}
},
created () {
this.checkedSignedIn()
},
updated () {
this.checkedSignedIn()
},
methods: {
signup () {
this.$http.plain.post('/signup', { email: this.email, password: this.password, password_confirmation: this.password_confirmation })
.then(response => this.signupSuccessful(response))
.catch(error => this.signupFailed(error))
},
signupSuccessful (response) {
if (!response.data.csrf) {
this.signupFailed(response)
return
}
localStorage.csrf = response.data.csrf
localStorage.signedIn = true
this.error = ''
this.$router.replace('/records')
},
signupFailed (error) {
this.error = (error.response && error.response.data && error.response.data.error) || 'Something went wrong'
delete localStorage.csrf
delete localStorage.signedIn
},
checkedSignedIn () {
if (localStorage.signedIn) {
this.$router.replace('/records')
}
}
}
}
</script>
-
create file:
/recordstore-front/src/components/Header.vue
-
edit file:
/recordstore-front/src/components/Header.vue
<template>
<header class="bg-grey-lighter py-4">
<div class="container m-auto flex flex-wrap items-center justify-end">
<div class="flex-1 flex items-center">
<svg class="fill-current text-indigo" viewBox="0 0 24 24" width="24" height="24"><title>record vinyl</title><path d="M23.938 10.773a11.915 11.915 0 0 0-2.333-5.944 12.118 12.118 0 0 0-1.12-1.314A11.962 11.962 0 0 0 12 0C5.373 0 0 5.373 0 12s5.373 12 12 12 12-5.373 12-12c0-.414-.021-.823-.062-1.227zM12 16a4 4 0 1 1 0-8 4 4 0 0 1 0 8zm0-5a1 1 0 1 0 0 2 1 1 0 0 0 0-2z"></path></svg>
<a href="/" class="uppercase text-sm font-mono pl-4 font-semibold no-underline text-indigo-dark hover:text-indigo-darker">Record Store</a>
</div>
<div>
<router-link to="/" class="link-grey px-2 no-underline" v-if="!signedIn()">Sign in</router-link>
<router-link to="/signup" class="link-grey px-2 no-underline" v-if="!signedIn()">Sign Up</router-link>
<router-link to="/records" class="link-grey px-2 no-underline" v-if="signedIn()">Records</router-link>
<router-link to="/artists" class="link-grey px-2 no-underline" v-if="signedIn()">Artists</router-link>
<a href="#" @click.prevent="signOut" class="link-grey px-2 no-underline" v-if="signedIn()">Sign out</a>
</div>
</div>
</header>
</template>
<script>
export default {
name: 'Header',
created () {
this.signedIn()
},
methods: {
setError (error, text) {
this.error = (error.response && error.response.data && error.response.data.error) || text
},
signedIn () {
return localStorage.signedIn
},
signOut () {
this.$http.secured.delete('/signin')
.then(response => {
delete localStorage.csrf
delete localStorage.signedIn
this.$router.replace('/')
})
.catch(error => this.setError(error, 'Cannot sign out'))
}
}
}
</script>
- edit file:
/recordstore-front/src/App.vue
- add line in script tags:
import Header from './components/Header.vue'
- add line in script tags:
<template>
<div id="app">
<Header/>
<router-view></router-view>
</div>
</template>
<script>
import Header from './components/Header.vue'
export default {
name: 'App',
components: {
Header
}
}
</script>
-
create file:
/recordstore-front/src/components/artists/Artists.vue
-
edit file:
/recordstore-front/src/components/artists/Artists.vue
<template>
<div class="max-w-md m-auto py-10">
<div class="text-red" v-if="error">{{ error }}</div>
<h3 class="font-mono font-regular text-3xl mb-4">Add a new artist</h3>
<form action="" @submit.prevent="addArtist">
<div class="mb-6">
<input class="input"
autofocus autocomplete="off"
placeholder="Type an arist name"
v-model="newArtist.name" />
</div>
<input type="submit" value="Add Artist" class="font-sans font-bold px-4 rounded cursor-pointer no-underline bg-green hover:bg-green-dark block w-full py-4 text-white items-center justify-center" />
</form>
<hr class="border border-grey-light my-6" />
<ul class="list-reset mt-4">
<li class="py-4" v-for="artist in artists" :key="artist.id" :artist="artist">
<div class="flex items-center justify-between flex-wrap">
<p class="block flex-1 font-mono font-semibold flex items-center ">
<svg class="fill-current text-indigo w-6 h-6 mr-2" viewBox="0 0 20 20" width="20" height="20"><title>music artist</title><path d="M15.75 8l-3.74-3.75a3.99 3.99 0 0 1 6.82-3.08A4 4 0 0 1 15.75 8zm-13.9 7.3l9.2-9.19 2.83 2.83-9.2 9.2-2.82-2.84zm-1.4 2.83l2.11-2.12 1.42 1.42-2.12 2.12-1.42-1.42zM10 15l2-2v7h-2v-5z"></path></svg>
{{ artist.name }}
</p>
<button class="bg-tranparent text-sm hover:bg-blue hover:text-white text-blue border border-blue no-underline font-bold py-2 px-4 mr-2 rounded"
@click.prevent="editArtist(artist)">Edit</button>
<button class="bg-transprent text-sm hover:bg-red text-red hover:text-white no-underline font-bold py-2 px-4 rounded border border-red"
@click.prevent="removeArtist(artist)">Delete</button>
</div>
<div v-if="artist == editedArtist">
<form action="" @submit.prevent="updateArtist(artist)">
<div class="mb-6 p-4 bg-white rounded border border-grey-light mt-4">
<input class="input" v-model="artist.name" />
<input type="submit" value="Update" class=" my-2 bg-transparent text-sm hover:bg-blue hover:text-white text-blue border border-blue no-underline font-bold py-2 px-4 rounded cursor-pointer">
</div>
</form>
</div>
</li>
</ul>
</div>
</template>
<script>
export default {
name: 'Artists',
data () {
return {
artists: [],
newArtist: [],
error: '',
editedArtist: ''
}
},
created () {
if (!localStorage.signedIn) {
this.$router.replace('/')
} else {
this.$http.secured.get('/api/v1/artists')
.then(response => { this.artists = response.data })
.catch(error => this.setError(error, 'Something went wrong'))
}
},
methods: {
setError (error, text) {
this.error = (error.response && error.response.data && error.response.data.error) || text
},
addArtist () {
const value = this.newArtist
if (!value) {
return
}
this.$http.secured.post('/api/v1/artists/', { artist: { name: this.newArtist.name } })
.then(response => {
this.artists.push(response.data)
this.newArtist = ''
})
.catch(error => this.setError(error, 'Cannot create artist'))
},
removeArtist (artist) {
this.$http.secured.delete(`/api/v1/artists/${artist.id}`)
.then(response => {
this.artists.splice(this.artists.indexOf(artist), 1)
})
.catch(error => this.setError(error, 'Cannot delete artist'))
},
editArtist (artist) {
this.editedArtist = artist
},
updateArtist (artist) {
this.editedArtist = ''
this.$http.secured.patch(`/api/v1/artists/${artist.id}`, { artist: { title: artist.name } })
.catch(error => this.setError(error, 'Cannot update artist'))
}
}
}
</script>
-
create file:
/recordstore-front/src/components/records/Records.vue
-
edit file:
/recordstore-front/src/components/records/Records.vue
- from Web-Crunch