├── log
├── .keep
└── development.log
├── tmp
└── .keep
├── lib
├── assets
│ └── .keep
└── tasks
│ └── .keep
├── public
├── favicon.ico
├── apple-touch-icon.png
├── apple-touch-icon-precomposed.png
├── robots.txt
├── 500.html
├── 422.html
└── 404.html
├── test
├── helpers
│ └── .keep
├── mailers
│ └── .keep
├── models
│ └── .keep
├── controllers
│ └── .keep
├── fixtures
│ ├── .keep
│ └── files
│ │ └── .keep
├── integration
│ └── .keep
└── test_helper.rb
├── app
├── assets
│ ├── images
│ │ ├── .keep
│ │ ├── logo.png
│ │ ├── .DS_Store
│ │ ├── favicon.ico
│ │ └── artists
│ │ │ └── black_flag.jpg
│ ├── javascripts
│ │ ├── channels
│ │ │ └── .keep
│ │ ├── cable.js
│ │ └── application.js
│ ├── config
│ │ └── manifest.js
│ └── stylesheets
│ │ ├── application.css
│ │ ├── browse-css.css
│ │ ├── css-reset.css
│ │ ├── artist-detail.css
│ │ ├── user-detail.css
│ │ ├── auth-css.css
│ │ ├── search-css.css
│ │ ├── playlist-detail.css
│ │ ├── playbar-css.css
│ │ ├── main-css.css
│ │ └── album-detail.css
├── models
│ ├── concerns
│ │ └── .keep
│ ├── .DS_Store
│ ├── application_record.rb
│ ├── playlist_track.rb
│ ├── album.rb
│ ├── playlist.rb
│ ├── artist.rb
│ ├── playlist_follow.rb
│ ├── user_follow.rb
│ ├── track.rb
│ └── user.rb
├── controllers
│ ├── concerns
│ │ └── .keep
│ ├── static_pages_controller.rb
│ ├── api
│ │ ├── albums_controller.rb
│ │ ├── artists_controller.rb
│ │ ├── users_controller.rb
│ │ ├── search_controller.rb
│ │ ├── sessions_controller.rb
│ │ ├── user_follows_controller.rb
│ │ ├── playlist_follows_controller.rb
│ │ ├── playlists_controller.rb
│ │ └── playlist_tracks_controller.rb
│ └── application_controller.rb
├── views
│ ├── layouts
│ │ ├── mailer.text.erb
│ │ ├── mailer.html.erb
│ │ └── application.html.erb
│ ├── api
│ │ ├── session
│ │ │ ├── _session.json.jbuilder
│ │ │ └── show.json.jbuilder
│ │ ├── artists
│ │ │ ├── index.json.jbuilder
│ │ │ └── show.json.jbuilder
│ │ ├── albums
│ │ │ ├── index.json.jbuilder
│ │ │ └── show.json.jbuilder
│ │ ├── users
│ │ │ └── show.json.jbuilder
│ │ ├── playlists
│ │ │ └── show.json.jbuilder
│ │ └── search
│ │ │ └── index.json.jbuilder
│ └── static_pages
│ │ └── root.html.erb
├── helpers
│ └── application_helper.rb
├── .DS_Store
├── jobs
│ └── application_job.rb
├── channels
│ └── application_cable
│ │ ├── channel.rb
│ │ └── connection.rb
└── mailers
│ └── application_mailer.rb
├── CNAME
├── frontend
├── CNAME
├── util
│ ├── album_api_util.js
│ ├── search_api_util.js
│ ├── user_api_util.js
│ ├── artist_api_util.js
│ ├── browse_api_util.js
│ ├── session_api_util.js
│ ├── follow_api_util.js
│ └── playlist_api_util.js
├── actions
│ ├── now_playing_actions.js
│ ├── album_actions.js
│ ├── search_actions.js
│ ├── artist_actions.js
│ ├── browse_actions.js
│ ├── user_actions.js
│ ├── follow_actions.js
│ ├── session_actions.js
│ └── playlist_actions.js
├── reducers
│ ├── user_reducer.js
│ ├── now_playing_reducer.js
│ ├── artist_reducer.js
│ ├── search_reducer.js
│ ├── current_user_detail_reducer.js
│ ├── album_reducer.js
│ ├── browse_reducer.js
│ ├── playlist_reducer.js
│ ├── session_reducer.js
│ └── root_reducer.js
├── store
│ └── store.js
├── components
│ ├── browse
│ │ ├── browse_container.js
│ │ ├── browse_albums_container.js
│ │ ├── browse_artists_container.js
│ │ ├── browse.jsx
│ │ ├── browse_artists.jsx
│ │ └── browse_albums.jsx
│ ├── main
│ │ ├── main_container.js
│ │ └── main.jsx
│ ├── playbar
│ │ ├── playbar_container.js
│ │ ├── scroll_bar.jsx
│ │ ├── play_controls.jsx
│ │ └── playbar.jsx
│ ├── user_detail
│ │ ├── user_detail_playlists_container.js
│ │ ├── user_detail_followed_users_container.js
│ │ ├── user_detail_followed_playlists_container.js
│ │ ├── user_detail_container.js
│ │ ├── user_detail_playlists.jsx
│ │ ├── user_detail_followed_users.jsx
│ │ ├── user_detail_followed_playlists.jsx
│ │ └── user_detail.jsx
│ ├── search
│ │ ├── search_container.js
│ │ ├── artist_results.jsx
│ │ ├── user_results.jsx
│ │ ├── playlist_results.jsx
│ │ ├── album_results.jsx
│ │ ├── search.jsx
│ │ └── track_results.jsx
│ ├── artist_detail
│ │ ├── artist_detail_container.js
│ │ └── artist_detail.jsx
│ ├── session_form
│ │ ├── session_form_container.js
│ │ └── session_form.jsx
│ ├── sidebar
│ │ ├── sidebar_container.js
│ │ └── sidebar.jsx
│ ├── album_detail
│ │ ├── album_detail_container.js
│ │ ├── track_list.jsx
│ │ └── album_detail.jsx
│ ├── playlist_detail
│ │ ├── playlist_detail_container.js
│ │ ├── playlist_tracks.jsx
│ │ └── playlist_detail.jsx
│ └── root.jsx
└── sstify.jsx
├── vendor
└── assets
│ ├── javascripts
│ └── .keep
│ └── stylesheets
│ └── .keep
├── docs
├── wireframes
│ ├── Browse.png
│ ├── AuthForm.png
│ ├── UserView.png
│ ├── AlbumDetail.png
│ └── PlaylistView.png
├── screenshots
│ ├── playbar.png
│ ├── search.png
│ └── playlist.png
├── api-endpoints.md
├── components.md
├── README.md
├── schema.md
└── state.md
├── bin
├── bundle
├── rake
├── rails
├── spring
├── update
└── setup
├── config
├── spring.rb
├── boot.rb
├── environment.rb
├── cable.yml
├── initializers
│ ├── session_store.rb
│ ├── mime_types.rb
│ ├── application_controller_renderer.rb
│ ├── filter_parameter_logging.rb
│ ├── cookies_serializer.rb
│ ├── backtrace_silencers.rb
│ ├── assets.rb
│ ├── wrap_parameters.rb
│ ├── inflections.rb
│ └── new_framework_defaults.rb
├── routes.rb
├── locales
│ └── en.yml
├── application.rb
├── secrets.yml
├── environments
│ ├── test.rb
│ ├── development.rb
│ └── production.rb
├── puma.rb
└── database.yml
├── config.ru
├── db
├── migrate
│ ├── 20170421192819_remove_bio_from_artists.rb
│ ├── 20170418180745_remove_email_from_users.rb
│ ├── 20170420190435_add_length_to_tracks.rb
│ ├── 20170420150759_create_artists.rb
│ ├── 20170420192822_change_length_column_type.rb
│ ├── 20170420161111_change_artists.rb
│ ├── 20170426004840_create_user_follows.rb
│ ├── 20170424130029_create_playlists.rb
│ ├── 20170426004831_create_playlist_follows.rb
│ ├── 20170420161802_create_albums.rb
│ ├── 20170420161817_create_tracks.rb
│ ├── 20170420162523_add_attachment_image_to_albums.rb
│ ├── 20170420162546_add_attachment_audio_to_tracks.rb
│ ├── 20170420151247_add_attachment_image_to_artists.rb
│ ├── 20170424143643_create_playlist_tracks.rb
│ └── 20170418153444_create_users.rb
└── schema.rb
├── .gitignore
├── Rakefile
├── webpack.config.js
├── package.json
├── Gemfile
├── README.md
└── Gemfile.lock
/log/.keep:
--------------------------------------------------------------------------------
1 |
--------------------------------------------------------------------------------
/tmp/.keep:
--------------------------------------------------------------------------------
1 |
--------------------------------------------------------------------------------
/lib/assets/.keep:
--------------------------------------------------------------------------------
1 |
--------------------------------------------------------------------------------
/lib/tasks/.keep:
--------------------------------------------------------------------------------
1 |
--------------------------------------------------------------------------------
/public/favicon.ico:
--------------------------------------------------------------------------------
1 |
--------------------------------------------------------------------------------
/test/helpers/.keep:
--------------------------------------------------------------------------------
1 |
--------------------------------------------------------------------------------
/test/mailers/.keep:
--------------------------------------------------------------------------------
1 |
--------------------------------------------------------------------------------
/test/models/.keep:
--------------------------------------------------------------------------------
1 |
--------------------------------------------------------------------------------
/app/assets/images/.keep:
--------------------------------------------------------------------------------
1 |
--------------------------------------------------------------------------------
/log/development.log:
--------------------------------------------------------------------------------
1 |
--------------------------------------------------------------------------------
/test/controllers/.keep:
--------------------------------------------------------------------------------
1 |
--------------------------------------------------------------------------------
/test/fixtures/.keep:
--------------------------------------------------------------------------------
1 |
--------------------------------------------------------------------------------
/test/integration/.keep:
--------------------------------------------------------------------------------
1 |
--------------------------------------------------------------------------------
/CNAME:
--------------------------------------------------------------------------------
1 | www.sstify.com
2 |
--------------------------------------------------------------------------------
/app/models/concerns/.keep:
--------------------------------------------------------------------------------
1 |
--------------------------------------------------------------------------------
/public/apple-touch-icon.png:
--------------------------------------------------------------------------------
1 |
--------------------------------------------------------------------------------
/test/fixtures/files/.keep:
--------------------------------------------------------------------------------
1 |
--------------------------------------------------------------------------------
/app/controllers/concerns/.keep:
--------------------------------------------------------------------------------
1 |
--------------------------------------------------------------------------------
/frontend/CNAME:
--------------------------------------------------------------------------------
1 | www.sstify.com
2 |
--------------------------------------------------------------------------------
/vendor/assets/javascripts/.keep:
--------------------------------------------------------------------------------
1 |
--------------------------------------------------------------------------------
/vendor/assets/stylesheets/.keep:
--------------------------------------------------------------------------------
1 |
--------------------------------------------------------------------------------
/app/assets/javascripts/channels/.keep:
--------------------------------------------------------------------------------
1 |
--------------------------------------------------------------------------------
/public/apple-touch-icon-precomposed.png:
--------------------------------------------------------------------------------
1 |
--------------------------------------------------------------------------------
/app/views/layouts/mailer.text.erb:
--------------------------------------------------------------------------------
1 | <%= yield %>
2 |
--------------------------------------------------------------------------------
/app/helpers/application_helper.rb:
--------------------------------------------------------------------------------
1 | module ApplicationHelper
2 | end
3 |
--------------------------------------------------------------------------------
/app/.DS_Store:
--------------------------------------------------------------------------------
https://raw.githubusercontent.com/jtbrubak/sstify/HEAD/app/.DS_Store
--------------------------------------------------------------------------------
/app/jobs/application_job.rb:
--------------------------------------------------------------------------------
1 | class ApplicationJob < ActiveJob::Base
2 | end
3 |
--------------------------------------------------------------------------------
/app/views/api/session/_session.json.jbuilder:
--------------------------------------------------------------------------------
1 | json.extract! user, :id, :username
2 |
--------------------------------------------------------------------------------
/app/views/api/session/show.json.jbuilder:
--------------------------------------------------------------------------------
1 | json.extract! @user, :id, :username
2 |
--------------------------------------------------------------------------------
/app/models/.DS_Store:
--------------------------------------------------------------------------------
https://raw.githubusercontent.com/jtbrubak/sstify/HEAD/app/models/.DS_Store
--------------------------------------------------------------------------------
/app/assets/images/logo.png:
--------------------------------------------------------------------------------
https://raw.githubusercontent.com/jtbrubak/sstify/HEAD/app/assets/images/logo.png
--------------------------------------------------------------------------------
/docs/wireframes/Browse.png:
--------------------------------------------------------------------------------
https://raw.githubusercontent.com/jtbrubak/sstify/HEAD/docs/wireframes/Browse.png
--------------------------------------------------------------------------------
/app/assets/images/.DS_Store:
--------------------------------------------------------------------------------
https://raw.githubusercontent.com/jtbrubak/sstify/HEAD/app/assets/images/.DS_Store
--------------------------------------------------------------------------------
/docs/screenshots/playbar.png:
--------------------------------------------------------------------------------
https://raw.githubusercontent.com/jtbrubak/sstify/HEAD/docs/screenshots/playbar.png
--------------------------------------------------------------------------------
/docs/screenshots/search.png:
--------------------------------------------------------------------------------
https://raw.githubusercontent.com/jtbrubak/sstify/HEAD/docs/screenshots/search.png
--------------------------------------------------------------------------------
/docs/wireframes/AuthForm.png:
--------------------------------------------------------------------------------
https://raw.githubusercontent.com/jtbrubak/sstify/HEAD/docs/wireframes/AuthForm.png
--------------------------------------------------------------------------------
/docs/wireframes/UserView.png:
--------------------------------------------------------------------------------
https://raw.githubusercontent.com/jtbrubak/sstify/HEAD/docs/wireframes/UserView.png
--------------------------------------------------------------------------------
/app/assets/images/favicon.ico:
--------------------------------------------------------------------------------
https://raw.githubusercontent.com/jtbrubak/sstify/HEAD/app/assets/images/favicon.ico
--------------------------------------------------------------------------------
/docs/screenshots/playlist.png:
--------------------------------------------------------------------------------
https://raw.githubusercontent.com/jtbrubak/sstify/HEAD/docs/screenshots/playlist.png
--------------------------------------------------------------------------------
/docs/wireframes/AlbumDetail.png:
--------------------------------------------------------------------------------
https://raw.githubusercontent.com/jtbrubak/sstify/HEAD/docs/wireframes/AlbumDetail.png
--------------------------------------------------------------------------------
/docs/wireframes/PlaylistView.png:
--------------------------------------------------------------------------------
https://raw.githubusercontent.com/jtbrubak/sstify/HEAD/docs/wireframes/PlaylistView.png
--------------------------------------------------------------------------------
/app/models/application_record.rb:
--------------------------------------------------------------------------------
1 | class ApplicationRecord < ActiveRecord::Base
2 | self.abstract_class = true
3 | end
4 |
--------------------------------------------------------------------------------
/app/assets/images/artists/black_flag.jpg:
--------------------------------------------------------------------------------
https://raw.githubusercontent.com/jtbrubak/sstify/HEAD/app/assets/images/artists/black_flag.jpg
--------------------------------------------------------------------------------
/app/controllers/static_pages_controller.rb:
--------------------------------------------------------------------------------
1 | class StaticPagesController < ApplicationController
2 | def root
3 | end
4 | end
5 |
--------------------------------------------------------------------------------
/app/channels/application_cable/channel.rb:
--------------------------------------------------------------------------------
1 | module ApplicationCable
2 | class Channel < ActionCable::Channel::Base
3 | end
4 | end
5 |
--------------------------------------------------------------------------------
/app/assets/config/manifest.js:
--------------------------------------------------------------------------------
1 | //= link_tree ../images
2 | //= link_directory ../javascripts .js
3 | //= link_directory ../stylesheets .css
4 |
--------------------------------------------------------------------------------
/app/channels/application_cable/connection.rb:
--------------------------------------------------------------------------------
1 | module ApplicationCable
2 | class Connection < ActionCable::Connection::Base
3 | end
4 | end
5 |
--------------------------------------------------------------------------------
/app/mailers/application_mailer.rb:
--------------------------------------------------------------------------------
1 | class ApplicationMailer < ActionMailer::Base
2 | default from: 'from@example.com'
3 | layout 'mailer'
4 | end
5 |
--------------------------------------------------------------------------------
/bin/bundle:
--------------------------------------------------------------------------------
1 | #!/usr/bin/env ruby
2 | ENV['BUNDLE_GEMFILE'] ||= File.expand_path('../../Gemfile', __FILE__)
3 | load Gem.bin_path('bundler', 'bundle')
4 |
--------------------------------------------------------------------------------
/config/spring.rb:
--------------------------------------------------------------------------------
1 | %w(
2 | .ruby-version
3 | .rbenv-vars
4 | tmp/restart.txt
5 | tmp/caching-dev.txt
6 | ).each { |path| Spring.watch(path) }
7 |
--------------------------------------------------------------------------------
/config/boot.rb:
--------------------------------------------------------------------------------
1 | ENV['BUNDLE_GEMFILE'] ||= File.expand_path('../Gemfile', __dir__)
2 |
3 | require 'bundler/setup' # Set up gems listed in the Gemfile.
4 |
--------------------------------------------------------------------------------
/config.ru:
--------------------------------------------------------------------------------
1 | # This file is used by Rack-based servers to start the application.
2 |
3 | require_relative 'config/environment'
4 |
5 | run Rails.application
6 |
--------------------------------------------------------------------------------
/app/views/api/artists/index.json.jbuilder:
--------------------------------------------------------------------------------
1 | json.array!(@artists) do |artist|
2 | json.extract! artist, :id, :name
3 | json.image_url artist.image.url(:thumb)
4 | end
5 |
--------------------------------------------------------------------------------
/config/environment.rb:
--------------------------------------------------------------------------------
1 | # Load the Rails application.
2 | require_relative 'application'
3 |
4 | # Initialize the Rails application.
5 | Rails.application.initialize!
6 |
--------------------------------------------------------------------------------
/frontend/util/album_api_util.js:
--------------------------------------------------------------------------------
1 | export const fetchAlbumDetail = (id) => {
2 | return $.ajax({
3 | method: 'get',
4 | url: `/api/albums/${id}`
5 | });
6 | };
7 |
--------------------------------------------------------------------------------
/frontend/util/search_api_util.js:
--------------------------------------------------------------------------------
1 | export const search = (data) => {
2 | return $.ajax({
3 | method: 'GET',
4 | url: `/api/search`,
5 | data
6 | });
7 | };
8 |
--------------------------------------------------------------------------------
/frontend/util/user_api_util.js:
--------------------------------------------------------------------------------
1 | export const fetchUserDetail = (id) => {
2 | return $.ajax({
3 | method: 'GET',
4 | url: `/api/users/${id}`
5 | });
6 | };
7 |
--------------------------------------------------------------------------------
/config/cable.yml:
--------------------------------------------------------------------------------
1 | development:
2 | adapter: async
3 |
4 | test:
5 | adapter: async
6 |
7 | production:
8 | adapter: redis
9 | url: redis://localhost:6379/1
10 |
--------------------------------------------------------------------------------
/frontend/util/artist_api_util.js:
--------------------------------------------------------------------------------
1 | export const fetchArtistDetail = (id) => {
2 | return $.ajax({
3 | method: 'get',
4 | url: `/api/artists/${id}`
5 | });
6 | };
7 |
--------------------------------------------------------------------------------
/config/initializers/session_store.rb:
--------------------------------------------------------------------------------
1 | # Be sure to restart your server when you modify this file.
2 |
3 | Rails.application.config.session_store :cookie_store, key: '_SSTify_session'
4 |
--------------------------------------------------------------------------------
/db/migrate/20170421192819_remove_bio_from_artists.rb:
--------------------------------------------------------------------------------
1 | class RemoveBioFromArtists < ActiveRecord::Migration[5.0]
2 | def change
3 | remove_column :artists, :bio
4 | end
5 | end
6 |
--------------------------------------------------------------------------------
/db/migrate/20170418180745_remove_email_from_users.rb:
--------------------------------------------------------------------------------
1 | class RemoveEmailFromUsers < ActiveRecord::Migration[5.0]
2 | def change
3 | remove_column :users, :email, :string
4 | end
5 | end
6 |
--------------------------------------------------------------------------------
/db/migrate/20170420190435_add_length_to_tracks.rb:
--------------------------------------------------------------------------------
1 | class AddLengthToTracks < ActiveRecord::Migration[5.0]
2 | def change
3 | add_column :tracks, :length, :string, null: false
4 | end
5 | end
6 |
--------------------------------------------------------------------------------
/.gitignore:
--------------------------------------------------------------------------------
1 | node_modules/
2 | bundle.js
3 | bundle.js.map
4 | tmp/
5 | .byebug_history
6 | .DS_Store
7 | npm-debug.log
8 |
9 | # Ignore application configuration
10 | /config/application.yml
11 |
--------------------------------------------------------------------------------
/app/views/api/albums/index.json.jbuilder:
--------------------------------------------------------------------------------
1 | json.array!(@albums) do |album|
2 | json.extract! album, :id, :title, :year
3 | json.artist album.artist
4 | json.image_url album.image.url(:thumb)
5 | end
6 |
--------------------------------------------------------------------------------
/config/initializers/mime_types.rb:
--------------------------------------------------------------------------------
1 | # Be sure to restart your server when you modify this file.
2 |
3 | # Add new mime types for use in respond_to blocks:
4 | # Mime::Type.register "text/richtext", :rtf
5 |
--------------------------------------------------------------------------------
/frontend/actions/now_playing_actions.js:
--------------------------------------------------------------------------------
1 | export const UPDATE_NOW_PLAYING = "UPDATE_NOW_PLAYING";
2 |
3 | export const updateNowPlaying = tracks => ({
4 | type: UPDATE_NOW_PLAYING,
5 | tracks
6 | });
7 |
--------------------------------------------------------------------------------
/db/migrate/20170420150759_create_artists.rb:
--------------------------------------------------------------------------------
1 | class CreateArtists < ActiveRecord::Migration[5.0]
2 | def change
3 | create_table :artists do |t|
4 | t.string :name
5 | t.text :bio
6 | end
7 | end
8 | end
9 |
--------------------------------------------------------------------------------
/config/initializers/application_controller_renderer.rb:
--------------------------------------------------------------------------------
1 | # Be sure to restart your server when you modify this file.
2 |
3 | # ApplicationController.renderer.defaults.merge!(
4 | # http_host: 'example.org',
5 | # https: false
6 | # )
7 |
--------------------------------------------------------------------------------
/public/robots.txt:
--------------------------------------------------------------------------------
1 | # See http://www.robotstxt.org/robotstxt.html for documentation on how to use the robots.txt file
2 | #
3 | # To ban all spiders from the entire site uncomment the next two lines:
4 | # User-agent: *
5 | # Disallow: /
6 |
--------------------------------------------------------------------------------
/app/views/api/users/show.json.jbuilder:
--------------------------------------------------------------------------------
1 | json.extract! @user, :username, :id, :playlists, :followed_playlists, :followed_users
2 | json.followed_users @user.followed_users do |user|
3 | json.id user.id
4 | json.username user.username
5 | end
6 |
--------------------------------------------------------------------------------
/db/migrate/20170420192822_change_length_column_type.rb:
--------------------------------------------------------------------------------
1 | class ChangeLengthColumnType < ActiveRecord::Migration[5.0]
2 | def change
3 | remove_column :tracks, :length
4 | add_column :tracks, :length, :integer, null: false
5 | end
6 | end
7 |
--------------------------------------------------------------------------------
/config/initializers/filter_parameter_logging.rb:
--------------------------------------------------------------------------------
1 | # Be sure to restart your server when you modify this file.
2 |
3 | # Configure sensitive parameters which will be filtered from the log file.
4 | Rails.application.config.filter_parameters += [:password]
5 |
--------------------------------------------------------------------------------
/db/migrate/20170420161111_change_artists.rb:
--------------------------------------------------------------------------------
1 | class ChangeArtists < ActiveRecord::Migration[5.0]
2 | def change
3 | change_column :artists, :name, :string, :null => false
4 | change_column :artists, :bio, :text, :null => false
5 | end
6 | end
7 |
--------------------------------------------------------------------------------
/Rakefile:
--------------------------------------------------------------------------------
1 | # Add your own tasks in files placed in lib/tasks ending in .rake,
2 | # for example lib/tasks/capistrano.rake, and they will automatically be available to Rake.
3 |
4 | require_relative 'config/application'
5 |
6 | Rails.application.load_tasks
7 |
--------------------------------------------------------------------------------
/db/migrate/20170426004840_create_user_follows.rb:
--------------------------------------------------------------------------------
1 | class CreateUserFollows < ActiveRecord::Migration[5.0]
2 | def change
3 | create_table :user_follows do |t|
4 | t.integer :follower_id
5 | t.integer :followed_id
6 | end
7 | end
8 | end
9 |
--------------------------------------------------------------------------------
/app/models/playlist_track.rb:
--------------------------------------------------------------------------------
1 | class PlaylistTrack < ActiveRecord::Base
2 | validates :playlist_id, :track_id, :playlist_ord, presence: true
3 | validates_uniqueness_of :playlist_id, scope: :playlist_ord
4 | belongs_to :playlist
5 | belongs_to :track
6 | end
7 |
--------------------------------------------------------------------------------
/bin/rake:
--------------------------------------------------------------------------------
1 | #!/usr/bin/env ruby
2 | begin
3 | load File.expand_path('../spring', __FILE__)
4 | rescue LoadError => e
5 | raise unless e.message.include?('spring')
6 | end
7 | require_relative '../config/boot'
8 | require 'rake'
9 | Rake.application.run
10 |
--------------------------------------------------------------------------------
/db/migrate/20170424130029_create_playlists.rb:
--------------------------------------------------------------------------------
1 | class CreatePlaylists < ActiveRecord::Migration[5.0]
2 | def change
3 | create_table :playlists do |t|
4 | t.integer :user_id, null: false
5 | t.string :title, null: false
6 | end
7 | end
8 | end
9 |
--------------------------------------------------------------------------------
/db/migrate/20170426004831_create_playlist_follows.rb:
--------------------------------------------------------------------------------
1 | class CreatePlaylistFollows < ActiveRecord::Migration[5.0]
2 | def change
3 | create_table :playlist_follows do |t|
4 | t.integer :user_id, null: false
5 | t.integer :playlist_id, null: false
6 | end
7 | end
8 | end
9 |
--------------------------------------------------------------------------------
/config/initializers/cookies_serializer.rb:
--------------------------------------------------------------------------------
1 | # Be sure to restart your server when you modify this file.
2 |
3 | # Specify a serializer for the signed and encrypted cookie jars.
4 | # Valid options are :json, :marshal, and :hybrid.
5 | Rails.application.config.action_dispatch.cookies_serializer = :json
6 |
--------------------------------------------------------------------------------
/app/models/album.rb:
--------------------------------------------------------------------------------
1 | class Album < ActiveRecord::Base
2 | validates :title, :artist_id, :year, presence: true
3 | has_attached_file :image, styles: { thumb: "300x300#" }
4 | validates_attachment_content_type :image, content_type: /\Aimage\/.*\z/
5 | belongs_to :artist
6 | has_many :tracks
7 | end
8 |
--------------------------------------------------------------------------------
/db/migrate/20170420161802_create_albums.rb:
--------------------------------------------------------------------------------
1 | class CreateAlbums < ActiveRecord::Migration[5.0]
2 | def change
3 | create_table :albums do |t|
4 | t.integer :artist_id, null: false
5 | t.string :title, null: false
6 | t.integer :year, null: false
7 | end
8 | end
9 | end
10 |
--------------------------------------------------------------------------------
/db/migrate/20170420161817_create_tracks.rb:
--------------------------------------------------------------------------------
1 | class CreateTracks < ActiveRecord::Migration[5.0]
2 | def change
3 | create_table :tracks do |t|
4 | t.integer :album_id, null: false
5 | t.string :title, null: false
6 | t.integer :album_ord, null: false
7 | end
8 | end
9 | end
10 |
--------------------------------------------------------------------------------
/frontend/util/browse_api_util.js:
--------------------------------------------------------------------------------
1 | export const fetchAllArtists = () => {
2 | return $.ajax({
3 | method: 'get',
4 | url: `/api/artists`
5 | });
6 | };
7 |
8 | export const fetchAllAlbums = () => {
9 | return $.ajax({
10 | method: 'get',
11 | url: `/api/albums`
12 | });
13 | };
14 |
--------------------------------------------------------------------------------
/bin/rails:
--------------------------------------------------------------------------------
1 | #!/usr/bin/env ruby
2 | begin
3 | load File.expand_path('../spring', __FILE__)
4 | rescue LoadError => e
5 | raise unless e.message.include?('spring')
6 | end
7 | APP_PATH = File.expand_path('../config/application', __dir__)
8 | require_relative '../config/boot'
9 | require 'rails/commands'
10 |
--------------------------------------------------------------------------------
/app/views/layouts/mailer.html.erb:
--------------------------------------------------------------------------------
1 |
2 |
3 |
4 |
5 |
8 |
9 |
10 |
11 | <%= yield %>
12 |
13 |
14 |
--------------------------------------------------------------------------------
/db/migrate/20170420162523_add_attachment_image_to_albums.rb:
--------------------------------------------------------------------------------
1 | class AddAttachmentImageToAlbums < ActiveRecord::Migration
2 | def self.up
3 | change_table :albums do |t|
4 | t.attachment :image
5 | end
6 | end
7 |
8 | def self.down
9 | remove_attachment :albums, :image
10 | end
11 | end
12 |
--------------------------------------------------------------------------------
/db/migrate/20170420162546_add_attachment_audio_to_tracks.rb:
--------------------------------------------------------------------------------
1 | class AddAttachmentAudioToTracks < ActiveRecord::Migration
2 | def self.up
3 | change_table :tracks do |t|
4 | t.attachment :audio
5 | end
6 | end
7 |
8 | def self.down
9 | remove_attachment :tracks, :audio
10 | end
11 | end
12 |
--------------------------------------------------------------------------------
/app/models/playlist.rb:
--------------------------------------------------------------------------------
1 | class Playlist < ActiveRecord::Base
2 | validates :title, :user_id, presence: true
3 | belongs_to :user
4 | has_many :playlist_tracks, dependent: :destroy
5 | has_many :tracks, through: :playlist_tracks
6 | has_many :playlist_follows
7 | has_many :followers, through: :playlist_follows
8 | end
9 |
--------------------------------------------------------------------------------
/db/migrate/20170420151247_add_attachment_image_to_artists.rb:
--------------------------------------------------------------------------------
1 | class AddAttachmentImageToArtists < ActiveRecord::Migration
2 | def self.up
3 | change_table :artists do |t|
4 | t.attachment :image
5 | end
6 | end
7 |
8 | def self.down
9 | remove_attachment :artists, :image
10 | end
11 | end
12 |
--------------------------------------------------------------------------------
/db/migrate/20170424143643_create_playlist_tracks.rb:
--------------------------------------------------------------------------------
1 | class CreatePlaylistTracks < ActiveRecord::Migration[5.0]
2 | def change
3 | create_table :playlist_tracks do |t|
4 | t.integer :track_id, null: false
5 | t.integer :playlist_id, null: false
6 | t.integer :playlist_ord, null: false
7 | end
8 | end
9 | end
10 |
--------------------------------------------------------------------------------
/db/migrate/20170418153444_create_users.rb:
--------------------------------------------------------------------------------
1 | class CreateUsers < ActiveRecord::Migration[5.0]
2 | def change
3 | create_table :users do |t|
4 | t.string :username, null: false
5 | t.string :email, null: false
6 | t.string :password_digest, null: false
7 | t.string :session_token, null: false
8 | end
9 | end
10 | end
11 |
--------------------------------------------------------------------------------
/frontend/reducers/user_reducer.js:
--------------------------------------------------------------------------------
1 | import { RECEIVE_USER_DETAIL } from '../actions/user_actions';
2 |
3 | const UserReducer = (state = {}, action) => {
4 | Object.freeze(state);
5 | switch(action.type) {
6 | case RECEIVE_USER_DETAIL:
7 | return action.user;
8 | default:
9 | return state;
10 | }
11 | };
12 |
13 | export default UserReducer;
14 |
--------------------------------------------------------------------------------
/test/test_helper.rb:
--------------------------------------------------------------------------------
1 | ENV['RAILS_ENV'] ||= 'test'
2 | require File.expand_path('../../config/environment', __FILE__)
3 | require 'rails/test_help'
4 |
5 | class ActiveSupport::TestCase
6 | # Setup all fixtures in test/fixtures/*.yml for all tests in alphabetical order.
7 | fixtures :all
8 |
9 | # Add more helper methods to be used by all tests here...
10 | end
11 |
--------------------------------------------------------------------------------
/frontend/store/store.js:
--------------------------------------------------------------------------------
1 |
2 | import { createStore, applyMiddleware } from 'redux';
3 | import RootReducer from '../reducers/root_reducer';
4 | import thunk from 'redux-thunk';
5 |
6 | const configureStore = (preloadedState = {}) => (
7 | createStore(
8 | RootReducer,
9 | preloadedState,
10 | applyMiddleware(thunk)
11 | )
12 | )
13 |
14 | export default configureStore;
15 |
--------------------------------------------------------------------------------
/app/views/layouts/application.html.erb:
--------------------------------------------------------------------------------
1 |
2 |
3 |
4 | SSTify
5 | <%= csrf_meta_tags %>
6 |
7 | <%= stylesheet_link_tag 'application', media: 'all' %>
8 | <%= javascript_include_tag 'application' %>
9 | <%= favicon_link_tag 'favicon.ico' %>
10 |
11 |
12 |
13 | <%= yield %>
14 |
15 |
16 |
--------------------------------------------------------------------------------
/app/models/artist.rb:
--------------------------------------------------------------------------------
1 | class Artist < ActiveRecord::Base
2 | validates :name, presence: true
3 | has_attached_file :image, styles: { thumb: "300x300#", banner: "" },
4 | convert_options: {
5 | :banner => '-crop "1600x350+0+0" -gravity North'
6 | }
7 | validates_attachment_content_type :image, content_type: /\Aimage\/.*\z/
8 | has_many :albums
9 | has_many :tracks, through: :tracks
10 | end
11 |
--------------------------------------------------------------------------------
/app/controllers/api/albums_controller.rb:
--------------------------------------------------------------------------------
1 | class Api::AlbumsController < ApplicationController
2 |
3 | def show
4 | @album = Album.find(params[:id])
5 | if @album
6 | render 'api/albums/show'
7 | else
8 | render json: ['Not found'], status: 404
9 | end
10 | end
11 |
12 | def index
13 | @albums = Album.all.order('title')
14 | render 'api/albums/index'
15 | end
16 |
17 | end
18 |
--------------------------------------------------------------------------------
/app/controllers/api/artists_controller.rb:
--------------------------------------------------------------------------------
1 | class Api::ArtistsController < ApplicationController
2 |
3 | def show
4 | @artist = Artist.find(params[:id])
5 | if @artist
6 | render 'api/artists/show'
7 | else
8 | render json: ['Not found'], status: 404
9 | end
10 | end
11 |
12 | def index
13 | @artists = Artist.all.order('name')
14 | render 'api/artists/index'
15 | end
16 |
17 | end
18 |
--------------------------------------------------------------------------------
/frontend/actions/album_actions.js:
--------------------------------------------------------------------------------
1 | import * as APIUtil from '../util/album_api_util';
2 |
3 | export const RECEIVE_ALBUM_DETAIL = "RECEIVE_ALBUM_DETAIL";
4 |
5 | export const fetchAlbumDetail = (id) => dispatch => (
6 | APIUtil.fetchAlbumDetail(id)
7 | .then(album => dispatch(receiveAlbumDetail(album)))
8 | );
9 |
10 | export const receiveAlbumDetail = album => ({
11 | type: RECEIVE_ALBUM_DETAIL,
12 | album
13 | });
14 |
--------------------------------------------------------------------------------
/frontend/actions/search_actions.js:
--------------------------------------------------------------------------------
1 | import * as APIUtil from '../util/search_api_util';
2 |
3 | export const RECEIVE_SEARCH_RESULTS = "RECEIVE_SEARCH_RESULTS";
4 |
5 | export const search = (data) => dispatch => (
6 | APIUtil.search(data).then(results => dispatch(receiveSearchResults(results)))
7 | );
8 |
9 | export const receiveSearchResults = results => ({
10 | type: RECEIVE_SEARCH_RESULTS,
11 | results
12 | });
13 |
--------------------------------------------------------------------------------
/app/models/playlist_follow.rb:
--------------------------------------------------------------------------------
1 | class PlaylistFollow < ActiveRecord::Base
2 | validates :playlist_id, :user_id, presence: true
3 | validates :playlist_id, uniqueness: { scope: :user_id }
4 |
5 | belongs_to :follower,
6 | class_name: 'User',
7 | primary_key: :id,
8 | foreign_key: :user_id
9 | belongs_to :playlist,
10 | class_name: 'Playlist',
11 | primary_key: :id,
12 | foreign_key: :playlist_id
13 | end
14 |
--------------------------------------------------------------------------------
/app/models/user_follow.rb:
--------------------------------------------------------------------------------
1 | class UserFollow < ActiveRecord::Base
2 | validates :follower_id, :followed_id, presence: true
3 | validates :follower_id, uniqueness: { scope: :followed_id }
4 |
5 | belongs_to :follower,
6 | class_name: 'User',
7 | primary_key: :id,
8 | foreign_key: :follower_id
9 | belongs_to :followed,
10 | class_name: 'User',
11 | primary_key: :id,
12 | foreign_key: :followed_id
13 | end
14 |
--------------------------------------------------------------------------------
/app/assets/javascripts/cable.js:
--------------------------------------------------------------------------------
1 | // Action Cable provides the framework to deal with WebSockets in Rails.
2 | // You can generate new channels where WebSocket features live using the rails generate channel command.
3 | //
4 | //= require action_cable
5 | //= require_self
6 | //= require_tree ./channels
7 |
8 | (function() {
9 | this.App || (this.App = {});
10 |
11 | App.cable = ActionCable.createConsumer();
12 |
13 | }).call(this);
14 |
--------------------------------------------------------------------------------
/frontend/actions/artist_actions.js:
--------------------------------------------------------------------------------
1 | import * as APIUtil from '../util/artist_api_util';
2 |
3 | export const RECEIVE_ARTIST_DETAIL = "RECEIVE_ARTIST_DETAIL";
4 |
5 | export const fetchArtistDetail = (id) => dispatch => (
6 | APIUtil.fetchArtistDetail(id)
7 | .then(artist => dispatch(receiveArtistDetail(artist)))
8 | );
9 |
10 | export const receiveArtistDetail = artist => ({
11 | type: RECEIVE_ARTIST_DETAIL,
12 | artist
13 | });
14 |
--------------------------------------------------------------------------------
/frontend/components/browse/browse_container.js:
--------------------------------------------------------------------------------
1 | import { connect } from 'react-redux';
2 | import { logout } from '../../actions/browse_actions';
3 | import Browse from './browse';
4 |
5 | const mapStateToProps = (state, ownProps) => ({
6 | path: ownProps.route.path
7 | });
8 |
9 | const mapDispatchToProps = (dispatch) => ({
10 |
11 | });
12 |
13 | export default connect(
14 | mapStateToProps,
15 | mapDispatchToProps
16 | )(Browse);
17 |
--------------------------------------------------------------------------------
/config/initializers/backtrace_silencers.rb:
--------------------------------------------------------------------------------
1 | # Be sure to restart your server when you modify this file.
2 |
3 | # You can add backtrace silencers for libraries that you're using but don't wish to see in your backtraces.
4 | # Rails.backtrace_cleaner.add_silencer { |line| line =~ /my_noisy_library/ }
5 |
6 | # You can also remove all the silencers if you're trying to debug a problem that might stem from framework code.
7 | # Rails.backtrace_cleaner.remove_silencers!
8 |
--------------------------------------------------------------------------------
/app/views/api/albums/show.json.jbuilder:
--------------------------------------------------------------------------------
1 | json.extract! @album, :id, :title, :year
2 | json.artist @album.artist
3 | json.image_url @album.image.url(:thumb)
4 | json.tracks @album.tracks.order('album_ord') do |track|
5 | json.extract! track, :title, :album_ord, :length, :id
6 | json.url track.audio.url
7 | json.artist track.album.artist
8 | json.artist_id track.album.artist.id
9 | json.album_id track.album.id
10 | json.image_url track.album.image.url
11 | end
12 |
--------------------------------------------------------------------------------
/frontend/components/main/main_container.js:
--------------------------------------------------------------------------------
1 | import { connect } from 'react-redux';
2 | import { logout } from '../../actions/session_actions';
3 | import Main from './main';
4 |
5 | const mapStateToProps = (state) => ({
6 | currentUser: state.session.currentUser
7 | });
8 |
9 | const mapDispatchToProps = (dispatch) => ({
10 | logout: () => dispatch(logout())
11 | });
12 |
13 | export default connect(
14 | mapStateToProps,
15 | mapDispatchToProps
16 | )(Main);
17 |
--------------------------------------------------------------------------------
/frontend/reducers/now_playing_reducer.js:
--------------------------------------------------------------------------------
1 | import { UPDATE_NOW_PLAYING } from '../actions/now_playing_actions';
2 |
3 | const nowPlayingDefault = {
4 | played: [],
5 | queue: []
6 | }
7 |
8 | const NowPlayingReducer = (state = nowPlayingDefault, action) => {
9 | Object.freeze(state);
10 | switch(action.type) {
11 | case UPDATE_NOW_PLAYING:
12 | return action.tracks;
13 | default:
14 | return state;
15 | }
16 | };
17 |
18 | export default NowPlayingReducer;
19 |
--------------------------------------------------------------------------------
/frontend/reducers/artist_reducer.js:
--------------------------------------------------------------------------------
1 | import { RECEIVE_ARTIST_DETAIL } from '../actions/artist_actions';
2 |
3 | const artistDefault = {
4 | name: undefined,
5 | image_url: undefined,
6 | albums: []
7 | }
8 |
9 | const ArtistReducer = (state = artistDefault, action) => {
10 | Object.freeze(state);
11 | switch(action.type) {
12 | case RECEIVE_ARTIST_DETAIL:
13 | return action.artist;
14 | default:
15 | return state;
16 | }
17 | };
18 |
19 | export default ArtistReducer;
20 |
--------------------------------------------------------------------------------
/frontend/components/browse/browse_albums_container.js:
--------------------------------------------------------------------------------
1 | import { connect } from 'react-redux';
2 | import { fetchAllAlbums } from '../../actions/browse_actions';
3 | import BrowseAlbums from './browse_albums';
4 |
5 | const mapStateToProps = (state) => ({
6 | allAlbums: state.browse.allAlbums
7 | });
8 |
9 | const mapDispatchToProps = (dispatch) => ({
10 | fetchAllAlbums: () => dispatch(fetchAllAlbums())
11 | });
12 |
13 | export default connect(
14 | mapStateToProps,
15 | mapDispatchToProps
16 | )(BrowseAlbums);
17 |
--------------------------------------------------------------------------------
/frontend/components/playbar/playbar_container.js:
--------------------------------------------------------------------------------
1 | import { connect } from 'react-redux';
2 | import { logout } from '../../actions/session_actions';
3 | import Playbar from './playbar';
4 |
5 | const mapStateToProps = (state) => ({
6 | currentUser: state.session.currentUser,
7 | nowPlaying: state.nowPlaying
8 | });
9 |
10 | const mapDispatchToProps = (dispatch) => ({
11 | logout: () => dispatch(logout())
12 | });
13 |
14 | export default connect(
15 | mapStateToProps,
16 | mapDispatchToProps
17 | )(Playbar);
18 |
--------------------------------------------------------------------------------
/frontend/components/browse/browse_artists_container.js:
--------------------------------------------------------------------------------
1 | import { connect } from 'react-redux';
2 | import { fetchAllArtists } from '../../actions/browse_actions';
3 | import BrowseArtists from './browse_artists';
4 |
5 | const mapStateToProps = (state) => ({
6 | allArtists: state.browse.allArtists
7 | });
8 |
9 | const mapDispatchToProps = (dispatch) => ({
10 | fetchAllArtists: () => dispatch(fetchAllArtists())
11 | });
12 |
13 | export default connect(
14 | mapStateToProps,
15 | mapDispatchToProps
16 | )(BrowseArtists);
17 |
--------------------------------------------------------------------------------
/frontend/reducers/search_reducer.js:
--------------------------------------------------------------------------------
1 | import { RECEIVE_SEARCH_RESULTS } from '../actions/search_actions';
2 |
3 | const searchDefault = {
4 | artists: [],
5 | albums: [],
6 | playlists: [],
7 | users: [],
8 | tracks: []
9 | }
10 |
11 | const SearchReducer = (state = searchDefault, action) => {
12 | Object.freeze(state);
13 | switch(action.type) {
14 | case RECEIVE_SEARCH_RESULTS:
15 | return action.results;
16 | default:
17 | return state;
18 | }
19 | };
20 |
21 | export default SearchReducer;
22 |
--------------------------------------------------------------------------------
/config/initializers/assets.rb:
--------------------------------------------------------------------------------
1 | # Be sure to restart your server when you modify this file.
2 |
3 | # Version of your assets, change this if you want to expire all your assets.
4 | Rails.application.config.assets.version = '1.0'
5 |
6 | # Add additional assets to the asset load path
7 | # Rails.application.config.assets.paths << Emoji.images_path
8 |
9 | # Precompile additional assets.
10 | # application.js, application.css, and all non-JS/CSS in app/assets folder are already added.
11 | # Rails.application.config.assets.precompile += %w( search.js )
12 |
--------------------------------------------------------------------------------
/frontend/reducers/current_user_detail_reducer.js:
--------------------------------------------------------------------------------
1 | import { RECEIVE_CURRENT_USER_DETAIL } from '../actions/user_actions';
2 |
3 | const currentUserDefault = {
4 | playlists: [],
5 | followed_playlists: [],
6 | followed_users: []
7 | }
8 |
9 | const CurrentUserDetailReducer = (state = currentUserDefault, action) => {
10 | Object.freeze(state);
11 | switch(action.type) {
12 | case RECEIVE_CURRENT_USER_DETAIL:
13 | return action.user;
14 | default:
15 | return state;
16 | }
17 | };
18 |
19 | export default CurrentUserDetailReducer;
20 |
--------------------------------------------------------------------------------
/frontend/util/session_api_util.js:
--------------------------------------------------------------------------------
1 | import { receiveCurrentUser, receiveErrors } from '../actions/session_actions';
2 |
3 | export const login = (user) => {
4 | return $.ajax({
5 | method: 'POST',
6 | url: '/api/session',
7 | data: user
8 | });
9 | };
10 |
11 | export const signup = (user) => {
12 | return $.ajax({
13 | method: 'POST',
14 | url: '/api/users',
15 | data: user
16 | });
17 | };
18 |
19 | export const logout = () => {
20 | return $.ajax({
21 | method: 'delete',
22 | url: '/api/session'
23 | });
24 | };
25 |
--------------------------------------------------------------------------------
/config/initializers/wrap_parameters.rb:
--------------------------------------------------------------------------------
1 | # Be sure to restart your server when you modify this file.
2 |
3 | # This file contains settings for ActionController::ParamsWrapper which
4 | # is enabled by default.
5 |
6 | # Enable parameter wrapping for JSON. You can disable this by setting :format to an empty array.
7 | ActiveSupport.on_load(:action_controller) do
8 | wrap_parameters format: [:json]
9 | end
10 |
11 | # To enable root element in JSON for ActiveRecord objects.
12 | # ActiveSupport.on_load(:active_record) do
13 | # self.include_root_in_json = true
14 | # end
15 |
--------------------------------------------------------------------------------
/bin/spring:
--------------------------------------------------------------------------------
1 | #!/usr/bin/env ruby
2 |
3 | # This file loads spring without using Bundler, in order to be fast.
4 | # It gets overwritten when you run the `spring binstub` command.
5 |
6 | unless defined?(Spring)
7 | require 'rubygems'
8 | require 'bundler'
9 |
10 | lockfile = Bundler::LockfileParser.new(Bundler.default_lockfile.read)
11 | spring = lockfile.specs.detect { |spec| spec.name == "spring" }
12 | if spring
13 | Gem.use_paths Gem.dir, Bundler.bundle_path.to_s, *Gem.path
14 | gem 'spring', spring.version
15 | require 'spring/binstub'
16 | end
17 | end
18 |
--------------------------------------------------------------------------------
/frontend/reducers/album_reducer.js:
--------------------------------------------------------------------------------
1 | import { RECEIVE_ALBUM_DETAIL } from '../actions/album_actions';
2 |
3 | const albumDefault = {
4 | id: undefined,
5 | title: undefined,
6 | year: undefined,
7 | artist: { id: undefined, name: undefined },
8 | image_url: undefined,
9 | tracks: []
10 | }
11 |
12 | const AlbumReducer = (state = albumDefault, action) => {
13 | Object.freeze(state);
14 | switch(action.type) {
15 | case RECEIVE_ALBUM_DETAIL:
16 | return action.album;
17 | default:
18 | return state;
19 | }
20 | };
21 |
22 | export default AlbumReducer;
23 |
--------------------------------------------------------------------------------
/frontend/components/user_detail/user_detail_playlists_container.js:
--------------------------------------------------------------------------------
1 | import { connect } from 'react-redux';
2 | import { fetchUserDetail } from '../../actions/user_actions';
3 | import UserDetailPlaylists from './user_detail_playlists';
4 |
5 | const mapStateToProps = (state, ownProps) => ({
6 | userDetail: state.userDetail,
7 | id: parseInt(ownProps.params.id)
8 | });
9 |
10 | const mapDispatchToProps = (dispatch) => ({
11 | fetchUserDetail: (id) => dispatch(fetchUserDetail(id))
12 | });
13 |
14 | export default connect(
15 | mapStateToProps,
16 | mapDispatchToProps
17 | )(UserDetailPlaylists);
18 |
--------------------------------------------------------------------------------
/app/views/api/artists/show.json.jbuilder:
--------------------------------------------------------------------------------
1 | json.extract! @artist, :id, :name
2 | json.image_url @artist.image.url(:banner)
3 | json.albums @artist.albums.includes(:tracks).order('year DESC') do |album|
4 | json.extract! album, :title, :id, :artist
5 | json.image_url album.image.url
6 | json.tracks album.tracks.order('album_ord') do |track|
7 | json.extract! track, :title, :album_ord, :length, :id
8 | json.url track.audio.url
9 | json.artist track.album.artist
10 | json.artist_id track.album.artist.id
11 | json.album_id track.album.id
12 | json.image_url track.album.image.url
13 | end
14 | end
15 |
--------------------------------------------------------------------------------
/frontend/components/search/search_container.js:
--------------------------------------------------------------------------------
1 | import { connect } from 'react-redux';
2 | import Search from './search';
3 | import { search } from '../../actions/search_actions';
4 | import { updateNowPlaying } from '../../actions/now_playing_actions';
5 |
6 | const mapStateToProps = (state) => ({
7 | searchResults: state.searchResults
8 | });
9 |
10 | const mapDispatchToProps = (dispatch) => ({
11 | search: (data) => dispatch(search(data)),
12 | updateNowPlaying: (tracks) => dispatch(updateNowPlaying(tracks))
13 | });
14 |
15 | export default connect(
16 | mapStateToProps,
17 | mapDispatchToProps
18 | )(Search);
19 |
--------------------------------------------------------------------------------
/frontend/components/user_detail/user_detail_followed_users_container.js:
--------------------------------------------------------------------------------
1 | import { connect } from 'react-redux';
2 | import { fetchUserDetail } from '../../actions/user_actions';
3 | import UserDetailFollowedUsers from './user_detail_followed_users';
4 |
5 | const mapStateToProps = (state, ownProps) => ({
6 | userDetail: state.userDetail,
7 | id: parseInt(ownProps.params.id)
8 | });
9 |
10 | const mapDispatchToProps = (dispatch) => ({
11 | fetchUserDetail: (id) => dispatch(fetchUserDetail(id))
12 | });
13 |
14 | export default connect(
15 | mapStateToProps,
16 | mapDispatchToProps
17 | )(UserDetailFollowedUsers);
18 |
--------------------------------------------------------------------------------
/frontend/sstify.jsx:
--------------------------------------------------------------------------------
1 | import React from 'react';
2 | import ReactDOM from 'react-dom';
3 | import Root from './components/root';
4 | import configureStore from './store/store';
5 |
6 |
7 | document.addEventListener('DOMContentLoaded', () => {
8 | let store;
9 | if (window.currentUser) {
10 | const preloadedState = { session: { currentUser: window.currentUser } };
11 | store = configureStore(preloadedState);
12 | delete window.currentUser;
13 | } else {
14 | store = configureStore();
15 | }
16 |
17 | const root = document.getElementById('root');
18 | ReactDOM.render(, root);
19 | });
20 |
--------------------------------------------------------------------------------
/config/routes.rb:
--------------------------------------------------------------------------------
1 | Rails.application.routes.draw do
2 | namespace :api, defaults: {format: :json} do
3 | resources :albums, only: [:show, :index]
4 | resources :artists, only: [:show, :index]
5 | resources :users, only: [:create, :show]
6 | resources :playlists, only: [:show, :create, :destroy]
7 | resources :playlist_tracks, only: [:create, :destroy]
8 | resources :playlist_follows, only: [:create, :destroy]
9 | resources :user_follows, only: [:create, :destroy]
10 | resources :search, only: [:index]
11 | resource :session, only: [:create, :destroy, :show]
12 | end
13 | root "static_pages#root"
14 | end
15 |
--------------------------------------------------------------------------------
/frontend/components/user_detail/user_detail_followed_playlists_container.js:
--------------------------------------------------------------------------------
1 | import { connect } from 'react-redux';
2 | import { fetchUserDetail } from '../../actions/user_actions';
3 | import UserDetailFollowedPlaylists from './user_detail_followed_playlists';
4 |
5 | const mapStateToProps = (state, ownProps) => ({
6 | userDetail: state.userDetail,
7 | id: parseInt(ownProps.params.id)
8 | });
9 |
10 | const mapDispatchToProps = (dispatch) => ({
11 | fetchUserDetail: (id) => dispatch(fetchUserDetail(id))
12 | });
13 |
14 | export default connect(
15 | mapStateToProps,
16 | mapDispatchToProps
17 | )(UserDetailFollowedPlaylists);
18 |
--------------------------------------------------------------------------------
/app/controllers/api/users_controller.rb:
--------------------------------------------------------------------------------
1 | class Api::UsersController < ApplicationController
2 |
3 | def create
4 | @user = User.new(user_params)
5 |
6 | if @user.save
7 | login(@user)
8 | render "api/session/show"
9 | else
10 | render json: @user.errors.full_messages, status: 422
11 | end
12 | end
13 |
14 | def show
15 | @user = User.find(params[:id])
16 | if @user
17 | render "api/users/show"
18 | else
19 | render json: ['Not found'], status: 404
20 | end
21 | end
22 |
23 | private
24 |
25 | def user_params
26 | params.require(:user).permit(:username, :password)
27 | end
28 |
29 | end
30 |
--------------------------------------------------------------------------------
/app/controllers/api/search_controller.rb:
--------------------------------------------------------------------------------
1 | class Api::SearchController < ApplicationController
2 |
3 | def index
4 | @artists = Artist.where('name ilike ?', "%#{search_params[:terms]}%")
5 | @playlists = Playlist.where('title ilike ?', "%#{search_params[:terms]}%")
6 | @albums = Album.where('title ilike ?', "%#{search_params[:terms]}%")
7 | @tracks = Track.where('title ilike ?', "%#{search_params[:terms]}%")
8 | @users = User.where('username ilike ?', "%#{search_params[:terms]}%")
9 | render 'api/search/index'
10 | end
11 |
12 | private
13 | def search_params
14 | params.require(:search).permit(:terms)
15 | end
16 | end
17 |
--------------------------------------------------------------------------------
/app/views/api/playlists/show.json.jbuilder:
--------------------------------------------------------------------------------
1 | json.extract! @playlist, :title, :id
2 | json.user @playlist.user
3 | json.tracks @playlist.playlist_tracks.order('playlist_ord') do |playlist_track|
4 | json.extract! playlist_track, :id, :playlist_ord
5 | json.title playlist_track.track.title
6 | json.length playlist_track.track.length
7 | json.album playlist_track.track.album
8 | json.artist playlist_track.track.album.artist
9 | json.track_id playlist_track.track.id
10 | json.url playlist_track.track.audio.url
11 | json.album_id playlist_track.track.album.id
12 | json.artist_id playlist_track.track.album.artist.id
13 | json.image_url playlist_track.track.album.image.url
14 | end
15 |
--------------------------------------------------------------------------------
/frontend/reducers/browse_reducer.js:
--------------------------------------------------------------------------------
1 | import { RECEIVE_ALL_ARTISTS, RECEIVE_ALL_ALBUMS }
2 | from '../actions/browse_actions';
3 |
4 | const defaultState = {
5 | allArtists: [],
6 | allAlbums: [],
7 | };
8 |
9 | const BrowseReducer = (state = defaultState, action) => {
10 | Object.freeze(state);
11 | const newState = Object.assign({}, state);
12 | switch(action.type) {
13 | case RECEIVE_ALL_ARTISTS:
14 | return Object.assign({}, newState, { allArtists: action.artists });
15 | case RECEIVE_ALL_ALBUMS:
16 | return Object.assign({}, newState, { allAlbums: action.albums });
17 | default:
18 | return state;
19 | }
20 | };
21 |
22 | export default BrowseReducer;
23 |
--------------------------------------------------------------------------------
/app/controllers/api/sessions_controller.rb:
--------------------------------------------------------------------------------
1 | class Api::SessionsController < ApplicationController
2 | def create
3 | @user = User.find_by_credentials(
4 | params[:user][:username],
5 | params[:user][:password]
6 | )
7 | if @user
8 | login(@user)
9 | render "api/session/show"
10 | else
11 | render(
12 | json: ["Invalid username/password combination"],
13 | status: 401
14 | )
15 | end
16 | end
17 |
18 | def destroy
19 | @user = current_user
20 | if @user
21 | logout
22 | render "api/session/show"
23 | else
24 | render(
25 | json: ["Nobody signed in"],
26 | status: 404
27 | )
28 | end
29 | end
30 |
31 | end
32 |
--------------------------------------------------------------------------------
/frontend/util/follow_api_util.js:
--------------------------------------------------------------------------------
1 | export const createPlaylistFollow = (data) => {
2 | return $.ajax({
3 | method: 'POST',
4 | url: `/api/playlist_follows`,
5 | data
6 | });
7 | };
8 |
9 | export const deletePlaylistFollow = (data) => {
10 | return $.ajax({
11 | method: 'DELETE',
12 | url: `/api/playlist_follows/1`,
13 | data
14 | });
15 | };
16 |
17 | export const createUserFollow = (data) => {
18 | return $.ajax({
19 | method: 'POST',
20 | url: `/api/user_follows`,
21 | data
22 | });
23 | };
24 |
25 | export const deleteUserFollow = (data) => {
26 | return $.ajax({
27 | method: 'DELETE',
28 | url: `/api/user_follows/1`,
29 | data
30 | });
31 | };
32 |
--------------------------------------------------------------------------------
/frontend/reducers/playlist_reducer.js:
--------------------------------------------------------------------------------
1 | import { RECEIVE_PLAYLIST_DETAIL, REMOVE_PLAYLIST_TRACK } from '../actions/playlist_actions';
2 |
3 | const playlistDefault = {
4 | tracks: [],
5 | user: { id: undefined, username: undefined }
6 | }
7 |
8 | const PlaylistReducer = (state = playlistDefault, action) => {
9 | Object.freeze(state);
10 | switch(action.type) {
11 | case RECEIVE_PLAYLIST_DETAIL:
12 | return action.playlist;
13 | case REMOVE_PLAYLIST_TRACK:
14 | const tracks = Object.assign({}, state).tracks.filter(track => track.id != action.id);
15 | return Object.assign({}, state, { tracks });
16 | default:
17 | return state;
18 | }
19 | };
20 |
21 | export default PlaylistReducer;
22 |
--------------------------------------------------------------------------------
/config/initializers/inflections.rb:
--------------------------------------------------------------------------------
1 | # Be sure to restart your server when you modify this file.
2 |
3 | # Add new inflection rules using the following format. Inflections
4 | # are locale specific, and you may define rules for as many different
5 | # locales as you wish. All of these examples are active by default:
6 | # ActiveSupport::Inflector.inflections(:en) do |inflect|
7 | # inflect.plural /^(ox)$/i, '\1en'
8 | # inflect.singular /^(ox)en/i, '\1'
9 | # inflect.irregular 'person', 'people'
10 | # inflect.uncountable %w( fish sheep )
11 | # end
12 |
13 | # These inflection rules are supported but not enabled by default:
14 | # ActiveSupport::Inflector.inflections(:en) do |inflect|
15 | # inflect.acronym 'RESTful'
16 | # end
17 |
--------------------------------------------------------------------------------
/frontend/components/artist_detail/artist_detail_container.js:
--------------------------------------------------------------------------------
1 | import { connect } from 'react-redux';
2 | import { fetchArtistDetail } from '../../actions/artist_actions';
3 | import { updateNowPlaying } from '../../actions/now_playing_actions';
4 | import ArtistDetail from './artist_detail';
5 |
6 | const mapStateToProps = (state, ownProps) => ({
7 | artistDetail: state.artistDetail,
8 | id: parseInt(ownProps.params.id)
9 | });
10 |
11 | const mapDispatchToProps = (dispatch) => ({
12 | fetchArtistDetail: (id) => dispatch(fetchArtistDetail(id)),
13 | updateNowPlaying: (tracks) => dispatch(updateNowPlaying(tracks))
14 | });
15 |
16 | export default connect(
17 | mapStateToProps,
18 | mapDispatchToProps
19 | )(ArtistDetail);
20 |
--------------------------------------------------------------------------------
/app/controllers/application_controller.rb:
--------------------------------------------------------------------------------
1 | class ApplicationController < ActionController::Base
2 | protect_from_forgery with: :exception
3 | helper_method :current_user, :logged_in?
4 |
5 | private
6 |
7 | def current_user
8 | User.find_by(session_token: session[:session_token])
9 | end
10 |
11 | def logged_in?
12 | !!current_user
13 | end
14 |
15 | def login(user)
16 | user.reset_session_token!
17 | session[:session_token] = user.session_token
18 | end
19 |
20 | def logout
21 | current_user.reset_session_token!
22 | session[:session_token] = nil
23 | end
24 |
25 | def require_logged_in
26 | render json: {base: ['invalid credentials']}, status: 401 if !current_user
27 | end
28 |
29 | end
30 |
--------------------------------------------------------------------------------
/config/locales/en.yml:
--------------------------------------------------------------------------------
1 | # Files in the config/locales directory are used for internationalization
2 | # and are automatically loaded by Rails. If you want to use locales other
3 | # than English, add the necessary files in this directory.
4 | #
5 | # To use the locales, use `I18n.t`:
6 | #
7 | # I18n.t 'hello'
8 | #
9 | # In views, this is aliased to just `t`:
10 | #
11 | # <%= t('hello') %>
12 | #
13 | # To use a different locale, set it with `I18n.locale`:
14 | #
15 | # I18n.locale = :es
16 | #
17 | # This would use the information in config/locales/es.yml.
18 | #
19 | # To learn more, please read the Rails Internationalization guide
20 | # available at http://guides.rubyonrails.org/i18n.html.
21 |
22 | en:
23 | hello: "Hello world"
24 |
--------------------------------------------------------------------------------
/app/views/static_pages/root.html.erb:
--------------------------------------------------------------------------------
1 |
10 |
11 |
12 |
13 |
14 |
17 |
18 |
--------------------------------------------------------------------------------
/frontend/actions/browse_actions.js:
--------------------------------------------------------------------------------
1 | import * as APIUtil from '../util/browse_api_util';
2 |
3 | export const RECEIVE_ALL_ARTISTS = "RECEIVE_ALL_ARTISTS";
4 | export const RECEIVE_ALL_ALBUMS = "RECEIVE_ALL_ALBUMS";
5 |
6 | export const receiveAllArtists = artists => ({
7 | type: RECEIVE_ALL_ARTISTS,
8 | artists
9 | });
10 |
11 | export const receiveAllAlbums = albums => ({
12 | type: RECEIVE_ALL_ALBUMS,
13 | albums
14 | });
15 |
16 | export const fetchAllArtists = () => dispatch => (
17 | APIUtil.fetchAllArtists()
18 | .then(artists => dispatch(receiveAllArtists(artists)))
19 | );
20 |
21 | export const fetchAllAlbums = () => dispatch => (
22 | APIUtil.fetchAllAlbums()
23 | .then(albums => dispatch(receiveAllAlbums(albums)))
24 | );
25 |
--------------------------------------------------------------------------------
/config/application.rb:
--------------------------------------------------------------------------------
1 | require_relative 'boot'
2 |
3 | require 'rails/all'
4 |
5 | # Require the gems listed in Gemfile, including any gems
6 | # you've limited to :test, :development, or :production.
7 | Bundler.require(*Rails.groups)
8 |
9 | module SSTify
10 | class Application < Rails::Application
11 | config.paperclip_defaults = {
12 | :storage => :s3,
13 | :s3_credentials => {
14 | :bucket => ENV["s3_bucket"],
15 | :access_key_id => ENV["s3_access_key_id"],
16 | :secret_access_key => ENV["s3_secret_access_key"],
17 | :s3_region => ENV["s3_region"]
18 | }
19 | }
20 |
21 | Paperclip.options[:content_type_mappings] = {
22 | :mp3 => "application/octet-stream"
23 | }
24 | end
25 | end
26 |
--------------------------------------------------------------------------------
/app/models/track.rb:
--------------------------------------------------------------------------------
1 | class Track < ActiveRecord::Base
2 | validates :album_id, :title, :album_ord, :length, presence: true
3 | has_attached_file :audio
4 | validates_attachment_content_type :audio, content_type: /\Aaudio\/.*\z/
5 | belongs_to :album
6 | after_initialize :extract_metadata
7 | #
8 | def extract_metadata
9 | if (audio.queued_for_write[:original])
10 | path = audio.queued_for_write[:original].path
11 | open_opts = { :encoding => 'utf-8' }
12 | Mp3Info.open(path, open_opts) do |mp3info|
13 | self.length = mp3info.length.to_i
14 | self.title = mp3info.tag.title
15 | self.album_ord = mp3info.tag.tracknum.to_i
16 | self.album = Album.find_by_title(mp3info.tag.album)
17 | end
18 | end
19 | end
20 | end
21 |
--------------------------------------------------------------------------------
/app/views/api/search/index.json.jbuilder:
--------------------------------------------------------------------------------
1 | json.artists @artists do |artist|
2 | json.extract! artist, :id, :name
3 | json.image_url artist.image.url(:thumb)
4 | end
5 | json.albums @albums do |album|
6 | json.extract! album, :id, :title
7 | json.artist album.artist
8 | json.image_url album.image.url(:thumb)
9 | end
10 | json.tracks @tracks do |track|
11 | json.extract! track, :id, :title, :album, :length
12 | json.artist track.album.artist
13 | json.url track.audio.url
14 | json.image_url track.album.image.url
15 | json.artist_id track.album.artist.id
16 | json.album_id track.album.id
17 | end
18 | json.playlists @playlists do |playlist|
19 | json.extract! playlist, :id, :title
20 | end
21 | json.users @users do |user|
22 | json.extract! user, :id, :username
23 | end
24 |
--------------------------------------------------------------------------------
/frontend/actions/user_actions.js:
--------------------------------------------------------------------------------
1 | import * as APIUtil from '../util/user_api_util';
2 |
3 | export const RECEIVE_USER_DETAIL = "RECEIVE_USER_DETAIL";
4 | export const RECEIVE_CURRENT_USER_DETAIL = "RECEIVE_CURRENT_USER_DETAIL";
5 |
6 | export const receiveUserDetail = user => ({
7 | type: RECEIVE_USER_DETAIL,
8 | user
9 | });
10 |
11 | export const receiveCurrentUserDetail = user => ({
12 | type: RECEIVE_CURRENT_USER_DETAIL,
13 | user
14 | });
15 |
16 | export const fetchUserDetail = (id) => dispatch => (
17 | APIUtil.fetchUserDetail(id)
18 | .then(user => dispatch(receiveUserDetail(user)))
19 | );
20 |
21 | export const fetchCurrentUserDetail = (id) => dispatch => (
22 | APIUtil.fetchUserDetail(id)
23 | .then(user => dispatch(receiveCurrentUserDetail(user)))
24 | );
25 |
--------------------------------------------------------------------------------
/frontend/reducers/session_reducer.js:
--------------------------------------------------------------------------------
1 | import {
2 | RECEIVE_CURRENT_USER,
3 | RECEIVE_ERRORS } from '../actions/session_actions';
4 | import merge from 'lodash/merge';
5 |
6 | const _nullUser = Object.freeze({
7 | currentUser: null,
8 | errors: []
9 | });
10 |
11 | const SessionReducer = (state = _nullUser, action) => {
12 | Object.freeze(state);
13 | switch(action.type) {
14 | case RECEIVE_CURRENT_USER:
15 | const currentUser = action.currentUser;
16 | return merge({}, _nullUser, {
17 | currentUser
18 | });
19 | case RECEIVE_ERRORS:
20 | const errors = action.errors;
21 | return merge({}, _nullUser, {
22 | errors
23 | });
24 | default:
25 | return state;
26 | }
27 | };
28 |
29 | export default SessionReducer;
30 |
--------------------------------------------------------------------------------
/frontend/components/main/main.jsx:
--------------------------------------------------------------------------------
1 | import React from 'react';
2 | import SidebarContainer from '../sidebar/sidebar_container';
3 | import PlaybarContainer from '../playbar/playbar_container';
4 |
5 | class Main extends React.Component {
6 |
7 | constructor(props) {
8 | super(props);
9 | }
10 |
11 | componentWillReceiveProps() {
12 | this.render();
13 | }
14 |
15 | render() {
16 | return (
17 |
18 |
19 |
20 | {this.props.children}
21 |
22 |
25 |
26 | );
27 | }
28 | }
29 |
30 | export default Main;
31 |
--------------------------------------------------------------------------------
/app/assets/javascripts/application.js:
--------------------------------------------------------------------------------
1 | // This is a manifest file that'll be compiled into application.js, which will include all the files
2 | // listed below.
3 | //
4 | // Any JavaScript/Coffee file within this directory, lib/assets/javascripts, vendor/assets/javascripts,
5 | // or any plugin's vendor/assets/javascripts directory can be referenced here using a relative path.
6 | //
7 | // It's not advisable to add code directly here, but if you do, it'll appear at the bottom of the
8 | // compiled file. JavaScript code in this file should be added after the last require_* statement.
9 | //
10 | // Read Sprockets README (https://github.com/rails/sprockets#sprockets-directives) for details
11 | // about supported directives.
12 | //
13 | //= require jquery
14 | //= require jquery_ujs
15 | //= require_tree .
16 |
--------------------------------------------------------------------------------
/frontend/util/playlist_api_util.js:
--------------------------------------------------------------------------------
1 | export const createPlaylist = (data) => {
2 | return $.ajax({
3 | method: 'POST',
4 | url: `/api/playlists`,
5 | data
6 | });
7 | };
8 |
9 | export const fetchPlaylistDetail = (id) => {
10 | return $.ajax({
11 | method: 'GET',
12 | url: `/api/playlists/${id}`
13 | });
14 | };
15 |
16 | export const addTracksToPlaylist = (data) => {
17 | return $.ajax({
18 | method: 'POST',
19 | url: `/api/playlist_tracks`,
20 | data
21 | });
22 | };
23 |
24 | export const deletePlaylist = (id) => {
25 | return $.ajax({
26 | method: 'DELETE',
27 | url: `/api/playlists/${id}`
28 | });
29 | };
30 |
31 | export const removeTrack = (id) => {
32 | return $.ajax({
33 | method: 'DELETE',
34 | url: `api/playlist_tracks/${id}`
35 | });
36 | };
37 |
--------------------------------------------------------------------------------
/app/assets/stylesheets/application.css:
--------------------------------------------------------------------------------
1 | /*
2 | * This is a manifest file that'll be compiled into application.css, which will include all the files
3 | * listed below.
4 | *
5 | * Any CSS and SCSS file within this directory, lib/assets/stylesheets, vendor/assets/stylesheets,
6 | * or any plugin's vendor/assets/stylesheets directory can be referenced here using a relative path.
7 | *
8 | * You're free to add application-wide styles to this file and they'll appear at the bottom of the
9 | * compiled file so the styles you add here take precedence over styles defined in any other CSS/SCSS
10 | * files in this directory. Styles in this file should be added after the last require_* statement.
11 | * It is generally better to create a new file per style scope.
12 | *
13 | *= require_tree .
14 | *= require_self
15 | */
16 |
--------------------------------------------------------------------------------
/app/controllers/api/user_follows_controller.rb:
--------------------------------------------------------------------------------
1 | class Api::UserFollowsController < ApplicationController
2 | def create
3 | @user_follow = UserFollow.new(user_follow_params)
4 | @user = User.find(@user_follow.follower_id)
5 | if @user_follow.save
6 | render "api/users/show"
7 | else
8 | render json: @user_follow.errors.full_messages, status: 422
9 | end
10 | end
11 |
12 | def destroy
13 | @user_follow = UserFollow.find_by(user_follow_params)
14 | @user = User.find(@user_follow.follower_id)
15 | if @user_follow.destroy
16 | render "api/users/show"
17 | else
18 | render json: ["Not found"], status: 404
19 | end
20 | end
21 |
22 | private
23 | def user_follow_params
24 | params.require(:user_follow).permit(:follower_id, :followed_id)
25 | end
26 | end
27 |
--------------------------------------------------------------------------------
/frontend/actions/follow_actions.js:
--------------------------------------------------------------------------------
1 | import * as APIUtil from '../util/follow_api_util';
2 | import { receiveCurrentUserDetail } from './user_actions';
3 |
4 | export const createPlaylistFollow = (data) => dispatch => (
5 | APIUtil.createPlaylistFollow(data)
6 | .then(user => dispatch(receiveCurrentUserDetail(user)))
7 | );
8 |
9 | export const deletePlaylistFollow = (data) => dispatch => (
10 | APIUtil.deletePlaylistFollow(data)
11 | .then(user => dispatch(receiveCurrentUserDetail(user)))
12 | );
13 |
14 | export const createUserFollow = (data) => dispatch => (
15 | APIUtil.createUserFollow(data)
16 | .then(user => dispatch(receiveCurrentUserDetail(user)))
17 | );
18 |
19 | export const deleteUserFollow = (data) => dispatch => (
20 | APIUtil.deleteUserFollow(data)
21 | .then(user => dispatch(receiveCurrentUserDetail(user)))
22 | );
23 |
--------------------------------------------------------------------------------
/frontend/components/session_form/session_form_container.js:
--------------------------------------------------------------------------------
1 | import { connect } from 'react-redux';
2 | import { login, logout, signup, receiveCurrentUser, receiveErrors } from '../../actions/session_actions';
3 | import SessionForm from './session_form';
4 |
5 |
6 | const mapStateToProps = ({ session }) => ({
7 | loggedIn: Boolean(session.currentUser),
8 | errors: session.errors
9 | });
10 |
11 | const mapDispatchToProps = (dispatch, { location }) => {
12 | const formType = location.pathname.slice(1);
13 | const processForm = (formType === 'login') ? login : signup;
14 |
15 | return {
16 | processForm: user => dispatch(processForm(user)),
17 | formType,
18 | clearErrors: () => dispatch(receiveErrors([]))
19 | };
20 | };
21 |
22 | export default connect(
23 | mapStateToProps,
24 | mapDispatchToProps
25 | )(SessionForm);
26 |
--------------------------------------------------------------------------------
/frontend/components/sidebar/sidebar_container.js:
--------------------------------------------------------------------------------
1 | import { connect } from 'react-redux';
2 | import { logout } from '../../actions/session_actions';
3 | import { createPlaylist } from '../../actions/playlist_actions';
4 | import { fetchCurrentUserDetail } from '../../actions/user_actions';
5 | import Sidebar from './sidebar';
6 |
7 | const mapStateToProps = (state, ownProps) => ({
8 | currentUser: state.session.currentUser,
9 | currentUserDetail: state.currentUserDetail,
10 | path: ownProps.router.location.pathname
11 | });
12 |
13 | const mapDispatchToProps = (dispatch) => ({
14 | createPlaylist: (data) => dispatch(createPlaylist(data)),
15 | logout: () => dispatch(logout()),
16 | fetchCurrentUserDetail: (id) => dispatch(fetchCurrentUserDetail(id))
17 | });
18 |
19 | export default connect(
20 | mapStateToProps,
21 | mapDispatchToProps
22 | )(Sidebar);
23 |
--------------------------------------------------------------------------------
/app/controllers/api/playlist_follows_controller.rb:
--------------------------------------------------------------------------------
1 | class Api::PlaylistFollowsController < ApplicationController
2 | def create
3 | @playlist_follow = PlaylistFollow.new(playlist_follow_params)
4 | @user = User.find(@playlist_follow.user_id)
5 | if @playlist_follow.save
6 | render "api/users/show"
7 | else
8 | render json: @playlist_follow.errors.full_messages, status: 422
9 | end
10 | end
11 |
12 | def destroy
13 | @playlist_follow = PlaylistFollow.find_by(playlist_follow_params)
14 | @user = User.find(@playlist_follow.user_id)
15 | if @playlist_follow.destroy
16 | render "api/users/show"
17 | else
18 | render json: ["Not found"], status: 404
19 | end
20 | end
21 |
22 | private
23 | def playlist_follow_params
24 | params.require(:playlist_follow).permit(:user_id, :playlist_id)
25 | end
26 | end
27 |
--------------------------------------------------------------------------------
/app/controllers/api/playlists_controller.rb:
--------------------------------------------------------------------------------
1 | class Api::PlaylistsController < ApplicationController
2 |
3 | def show
4 | @playlist = Playlist.find(params[:id])
5 | if @playlist
6 | render 'api/playlists/show'
7 | else
8 | render json: ['Not found'], status: 404
9 | end
10 | end
11 |
12 | def create
13 | @playlist = Playlist.new(playlist_params)
14 | if @playlist.save
15 | render 'api/playlists/show'
16 | else
17 | render json: @playlist.errors.full_messages, status: 422
18 | end
19 | end
20 |
21 | def destroy
22 | @playlist = Playlist.find(params[:id])
23 | if @playlist.destroy
24 | render 'api/playlists/show'
25 | else
26 | render json: ['Not found'], status: 404
27 | end
28 | end
29 |
30 | private
31 | def playlist_params
32 | params.require(:playlist).permit(:user_id, :title)
33 | end
34 |
35 | end
36 |
--------------------------------------------------------------------------------
/bin/update:
--------------------------------------------------------------------------------
1 | #!/usr/bin/env ruby
2 | require 'pathname'
3 | require 'fileutils'
4 | include FileUtils
5 |
6 | # path to your application root.
7 | APP_ROOT = Pathname.new File.expand_path('../../', __FILE__)
8 |
9 | def system!(*args)
10 | system(*args) || abort("\n== Command #{args} failed ==")
11 | end
12 |
13 | chdir APP_ROOT do
14 | # This script is a way to update your development environment automatically.
15 | # Add necessary update steps to this file.
16 |
17 | puts '== Installing dependencies =='
18 | system! 'gem install bundler --conservative'
19 | system('bundle check') || system!('bundle install')
20 |
21 | puts "\n== Updating database =="
22 | system! 'bin/rails db:migrate'
23 |
24 | puts "\n== Removing old logs and tempfiles =="
25 | system! 'bin/rails log:clear tmp:clear'
26 |
27 | puts "\n== Restarting application server =="
28 | system! 'bin/rails restart'
29 | end
30 |
--------------------------------------------------------------------------------
/webpack.config.js:
--------------------------------------------------------------------------------
1 | var path = require("path");
2 | const webpack = require('webpack');
3 | const prod = process.argv.indexOf('-p') !== -1;
4 |
5 | module.exports = {
6 | context: __dirname,
7 | entry: "./frontend/sstify.jsx",
8 | output: {
9 | path: path.resolve(__dirname, 'app', 'assets', 'javascripts'),
10 | filename: "bundle.js"
11 | },
12 | module: {
13 | loaders: [
14 | {
15 | test: [/\.jsx?$/, /\.js?$/],
16 | exclude: /node_modules/,
17 | loader: 'babel-loader',
18 | query: {
19 | presets: ['es2015', 'react']
20 | }
21 | }
22 | ]
23 | },
24 | plugins: [
25 | new webpack.DefinePlugin({
26 | 'process.env': {
27 | NODE_ENV: JSON.stringify('production')
28 | }
29 | })
30 | // new webpack.optimize.UglifyJsPlugin()
31 | ],
32 | devtool: 'source-maps',
33 | resolve: {
34 | extensions: [".js", ".jsx", "*"]
35 | }
36 | };
37 |
--------------------------------------------------------------------------------
/frontend/components/album_detail/album_detail_container.js:
--------------------------------------------------------------------------------
1 | import { connect } from 'react-redux';
2 | import { fetchAlbumDetail } from '../../actions/album_actions';
3 | import { addTracksToPlaylist } from '../../actions/playlist_actions';
4 | import { updateNowPlaying } from '../../actions/now_playing_actions';
5 | import AlbumDetail from './album_detail';
6 |
7 | const mapStateToProps = (state, ownProps) => ({
8 | albumDetail: state.albumDetail,
9 | id: parseInt(ownProps.params.id),
10 | currentUser: state.session.currentUser,
11 | currentUserDetail: state.currentUserDetail
12 | });
13 |
14 | const mapDispatchToProps = (dispatch) => ({
15 | fetchAlbumDetail: (id) => dispatch(fetchAlbumDetail(id)),
16 | updateNowPlaying: (tracks) => dispatch(updateNowPlaying(tracks)),
17 | addTracksToPlaylist: (data) => dispatch(addTracksToPlaylist(data))
18 | });
19 |
20 | export default connect(
21 | mapStateToProps,
22 | mapDispatchToProps
23 | )(AlbumDetail);
24 |
--------------------------------------------------------------------------------
/frontend/components/browse/browse.jsx:
--------------------------------------------------------------------------------
1 | import React from 'react';
2 | import { Link } from 'react-router';
3 |
4 | class Browse extends React.Component {
5 |
6 | constructor(props) {
7 | super(props);
8 | }
9 |
10 | checkSelected(linkPath) {
11 | if (linkPath === this.props.location.pathname) { return 'selected-browse-nav'; }
12 | }
13 |
14 | render() {
15 | return (
16 |
17 |
18 |
Browse
19 |
20 | ARTISTS
22 | ALBUMS
24 |
25 | {this.props.children}
26 |
27 |
28 | );
29 | }
30 | }
31 |
32 | export default Browse;
33 |
--------------------------------------------------------------------------------
/frontend/reducers/root_reducer.js:
--------------------------------------------------------------------------------
1 | import {combineReducers} from 'redux';
2 |
3 | import SessionReducer from './session_reducer';
4 | import AlbumReducer from './album_reducer';
5 | import ArtistReducer from './artist_reducer';
6 | import BrowseReducer from './browse_reducer';
7 | import PlaylistReducer from './playlist_reducer';
8 | import UserReducer from './user_reducer';
9 | import CurrentUserDetailReducer from './current_user_detail_reducer.js';
10 | import NowPlayingReducer from './now_playing_reducer';
11 | import SearchReducer from './search_reducer';
12 |
13 | const RootReducer = combineReducers({
14 | session: SessionReducer,
15 | albumDetail: AlbumReducer,
16 | artistDetail: ArtistReducer,
17 | browse: BrowseReducer,
18 | playlistDetail: PlaylistReducer,
19 | userDetail: UserReducer,
20 | currentUserDetail: CurrentUserDetailReducer,
21 | nowPlaying: NowPlayingReducer,
22 | searchResults: SearchReducer
23 | });
24 |
25 | export default RootReducer;
26 |
--------------------------------------------------------------------------------
/frontend/components/browse/browse_artists.jsx:
--------------------------------------------------------------------------------
1 | import React from 'react';
2 | import { Link } from 'react-router';
3 |
4 | class BrowseArtists extends React.Component {
5 |
6 | constructor(props) {
7 | super(props);
8 | }
9 |
10 | componentWillMount() {
11 | this.props.fetchAllArtists();
12 | }
13 |
14 | renderArtists() {
15 | return (
16 |
17 | {
18 | this.props.allArtists.map((artist) => (
19 |
20 |
21 | {artist.name}
22 |
23 | ))
24 | }
25 |
26 | );
27 | }
28 |
29 | render() {
30 | return (
31 |
32 | {this.renderArtists()}
33 |
34 | );
35 | }
36 | }
37 |
38 | export default BrowseArtists;
39 |
--------------------------------------------------------------------------------
/frontend/components/user_detail/user_detail_container.js:
--------------------------------------------------------------------------------
1 | import { connect } from 'react-redux';
2 | import { fetchCurrentUserDetail, fetchUserDetail } from '../../actions/user_actions';
3 | import { createUserFollow, deleteUserFollow } from '../../actions/follow_actions';
4 | import UserDetail from './user_detail';
5 |
6 | const mapStateToProps = (state, ownProps) => ({
7 | currentUserDetail: state.currentUserDetail,
8 | currentUser: state.session.currentUser,
9 | userDetail: state.userDetail,
10 | id: parseInt(ownProps.params.id)
11 | });
12 |
13 | const mapDispatchToProps = (dispatch) => ({
14 | fetchCurrentUserDetail: (id) => dispatch(fetchCurrentUserDetail(id)),
15 | fetchUserDetail: (id) => dispatch(fetchUserDetail(id)),
16 | createUserFollow: (data) => dispatch(createUserFollow(data)),
17 | deleteUserFollow: (data) => dispatch(deleteUserFollow(data))
18 | });
19 |
20 | export default connect(
21 | mapStateToProps,
22 | mapDispatchToProps
23 | )(UserDetail);
24 |
--------------------------------------------------------------------------------
/config/secrets.yml:
--------------------------------------------------------------------------------
1 | # Be sure to restart your server when you modify this file.
2 |
3 | # Your secret key is used for verifying the integrity of signed cookies.
4 | # If you change this key, all old signed cookies will become invalid!
5 |
6 | # Make sure the secret is at least 30 characters and all random,
7 | # no regular words or you'll be exposed to dictionary attacks.
8 | # You can use `rails secret` to generate a secure secret key.
9 |
10 | # Make sure the secrets in this file are kept private
11 | # if you're sharing your code publicly.
12 |
13 | development:
14 | secret_key_base: 499918d4a1e29c09f692cd701530e82d6d2cdb9b23913fc59a3d60a540ee04e134e7ea390ac5177d2d8a9fdc4be57b813364e77c45ff9671f1a3724b171a9d8e
15 |
16 | test:
17 | secret_key_base: bd573dfc7bc2e7a16aaea442df051c8b58c05bd5b2bffaf3eb7314eaa2299ec1dffbbf854661b210844708f06ae08bec9d95ca51f4b01bf7ed29f8470f8e8f8d
18 |
19 | # Do not keep production secrets in the repository,
20 | # instead read values from the environment.
21 | production:
22 | secret_key_base: <%= ENV["SECRET_KEY_BASE"] %>
23 |
--------------------------------------------------------------------------------
/app/controllers/api/playlist_tracks_controller.rb:
--------------------------------------------------------------------------------
1 | class Api::PlaylistTracksController < ApplicationController
2 |
3 | def create
4 | @playlist = Playlist.find(params[:playlist_id])
5 | if PlaylistTrack.where(playlist_id: @playlist.id).length > 0
6 | playlist_ord = PlaylistTrack.order('playlist_ord').where("playlist_id = #{@playlist.id}").last.playlist_ord
7 | else
8 | playlist_ord = 1
9 | end
10 | params[:tracks].values.each do |track|
11 | playlist_ord += 1
12 | new_track = PlaylistTrack.new(playlist_id: @playlist.id, playlist_ord: playlist_ord, track_id: track[:id])
13 | unless new_track.save
14 | render json: new_track.errors.full_messages, status: 422
15 | break
16 | end
17 | end
18 | render 'api/playlists/show'
19 | end
20 |
21 | def destroy
22 | @playlist_track = PlaylistTrack.find(params[:id])
23 | if @playlist_track.destroy
24 | render json: @playlist_track.id
25 | else
26 | render json: ['Not found'], status: 404
27 | end
28 | end
29 |
30 | end
31 |
--------------------------------------------------------------------------------
/bin/setup:
--------------------------------------------------------------------------------
1 | #!/usr/bin/env ruby
2 | require 'pathname'
3 | require 'fileutils'
4 | include FileUtils
5 |
6 | # path to your application root.
7 | APP_ROOT = Pathname.new File.expand_path('../../', __FILE__)
8 |
9 | def system!(*args)
10 | system(*args) || abort("\n== Command #{args} failed ==")
11 | end
12 |
13 | chdir APP_ROOT do
14 | # This script is a starting point to setup your application.
15 | # Add necessary setup steps to this file.
16 |
17 | puts '== Installing dependencies =='
18 | system! 'gem install bundler --conservative'
19 | system('bundle check') || system!('bundle install')
20 |
21 | # puts "\n== Copying sample files =="
22 | # unless File.exist?('config/database.yml')
23 | # cp 'config/database.yml.sample', 'config/database.yml'
24 | # end
25 |
26 | puts "\n== Preparing database =="
27 | system! 'bin/rails db:setup'
28 |
29 | puts "\n== Removing old logs and tempfiles =="
30 | system! 'bin/rails log:clear tmp:clear'
31 |
32 | puts "\n== Restarting application server =="
33 | system! 'bin/rails restart'
34 | end
35 |
--------------------------------------------------------------------------------
/frontend/actions/session_actions.js:
--------------------------------------------------------------------------------
1 | import * as APIUtil from '../util/session_api_util';
2 |
3 | export const RECEIVE_CURRENT_USER = "RECEIVE_CURRENT_USER";
4 | export const RECEIVE_ERRORS = "RECEIVE_ERRORS";
5 | export const userDefault = {
6 | playlists: [],
7 | followed_playlists: [],
8 | followed_users: []
9 | }
10 |
11 | export const signup = user => dispatch => (
12 | APIUtil.signup(user)
13 | .then(user => dispatch(receiveCurrentUser(user)),
14 | err => dispatch(receiveErrors(err.responseJSON)))
15 | );
16 |
17 | export const login = user => dispatch => (
18 | APIUtil.login(user)
19 | .then(user => dispatch(receiveCurrentUser(user)),
20 | err => dispatch(receiveErrors(err.responseJSON)))
21 | );
22 |
23 | export const logout = () => dispatch => (
24 | APIUtil.logout().then(user => dispatch(receiveCurrentUser(user)))
25 | );
26 |
27 | export const receiveCurrentUser = currentUser => ({
28 | type: RECEIVE_CURRENT_USER,
29 | currentUser
30 | });
31 |
32 | export const receiveErrors = errors => ({
33 | type: RECEIVE_ERRORS,
34 | errors
35 | });
36 |
--------------------------------------------------------------------------------
/frontend/components/browse/browse_albums.jsx:
--------------------------------------------------------------------------------
1 | import React from 'react';
2 | import { Link } from 'react-router';
3 |
4 | class BrowseAlbums extends React.Component {
5 |
6 | constructor(props) {
7 | super(props);
8 | }
9 |
10 | componentWillMount() {
11 | this.props.fetchAllAlbums();
12 | }
13 |
14 | renderAlbums() {
15 | return (
16 |
17 | {
18 | this.props.allAlbums.map((album) => (
19 |
20 |
21 | {album.title}
22 | By {album.artist.name}
23 |
24 | ))
25 | }
26 |
27 | );
28 | }
29 |
30 | render() {
31 | return (
32 |
33 | {this.renderAlbums()}
34 |
35 | );
36 | }
37 | }
38 |
39 | export default BrowseAlbums;
40 |
--------------------------------------------------------------------------------
/docs/api-endpoints.md:
--------------------------------------------------------------------------------
1 | # API Endpoints
2 |
3 | ## HTML API
4 |
5 | ### Root
6 |
7 | - `GET /` - loads React web app
8 |
9 | ## JSON API
10 |
11 | ### Users
12 |
13 | - `POST /api/users` - create new user
14 |
15 | ### Session
16 |
17 | - `POST /api/session`
18 | - `DELETE /api/session`
19 |
20 | ### Artists
21 |
22 | - `GET /api/artists`
23 | - `GET /api/artists/:id`
24 |
25 | ### Albums
26 |
27 | - `GET /api/albums` - gets all releases for given artist
28 | - `GET /api/albums/:id` - gets details for specific release
29 |
30 | ### Playlists
31 |
32 | - `GET /api/users/playlists`
33 | - `GET /api/users/playlists/:id`
34 | - `POST /api/users/playlists`
35 | - `PATCH /api/users/playlists/:id`
36 | - `DELETE /api/users/playlists/:id`
37 |
38 | ### PlaylistFollows
39 |
40 | - `GET /api/users/playlist_follows`
41 | - `POST /api/users/playlist_follows/:id`
42 | - `DELETE /api/users/playlist_follows/:id`
43 |
44 | ### UserFollows
45 |
46 | - `GET /api/users/user_follows`
47 | - `POST /api/users/user_follows/:id`
48 | - `DELETE /api/users/user_follows/:id`
49 |
--------------------------------------------------------------------------------
/frontend/components/search/artist_results.jsx:
--------------------------------------------------------------------------------
1 | import React from 'react';
2 | import { Link } from 'react-router';
3 |
4 | class ArtistResults extends React.Component {
5 |
6 | constructor(props) {
7 | super(props);
8 | }
9 |
10 | render() {
11 | if (!this.props.results ||
12 | this.props.results.length === 0 ||
13 | this.props.input.value === "") { return (
); }
14 | if (this.props.results.length > 0 && this.props.input.value !== "") {
15 | return (
16 |
17 |
Artists
18 |
19 | {
20 | this.props.results.map((artist) => (
21 |
22 |
23 | {artist.name}
24 |
25 | ))
26 | }
27 |
28 |
29 | );
30 | }
31 | }
32 |
33 | }
34 |
35 | export default ArtistResults;
36 |
--------------------------------------------------------------------------------
/package.json:
--------------------------------------------------------------------------------
1 | {
2 | "name": "SSTify",
3 | "version": "1.0.0",
4 | "description": "This README would normally document whatever steps are necessary to get the application up and running.",
5 | "main": "index.js",
6 | "directories": {
7 | "doc": "docs",
8 | "test": "test"
9 | },
10 | "scripts": {
11 | "test": "echo \"Error: no test specified\" && exit 1",
12 | "webpack": "webpack --watch",
13 | "production-build": "webpack -p --define process.env.NODE_ENV='\"production\"' --progress --colors",
14 | "postinstall": "webpack"
15 | },
16 | "keywords": [],
17 | "author": "",
18 | "license": "ISC",
19 | "dependencies": {
20 | "babel-core": "^6.24.1",
21 | "babel-loader": "^6.4.1",
22 | "babel-preset-es2015": "^6.24.1",
23 | "babel-preset-react": "^6.24.1",
24 | "react": "^15.5.4",
25 | "react-dom": "^15.5.4",
26 | "react-redux": "^5.0.4",
27 | "react-router": "3.0.5",
28 | "redux": "^3.6.0",
29 | "redux-thunk": "^2.2.0",
30 | "shuffle-array": "^1.0.1",
31 | "webpack": "^2.4.1"
32 | },
33 | "engines": {
34 | "node": "8.11.1",
35 | "npm": "3.10.7"
36 | }
37 | }
38 |
--------------------------------------------------------------------------------
/frontend/components/search/user_results.jsx:
--------------------------------------------------------------------------------
1 | import React from 'react';
2 | import { Link } from 'react-router';
3 |
4 | class UserResults extends React.Component {
5 |
6 | constructor(props) {
7 | super(props);
8 | }
9 |
10 | render() {
11 | if (!this.props.results ||
12 | this.props.results.length === 0 ||
13 | this.props.input.value === "") { return (
); }
14 | if (this.props.results.length > 0 && this.props.input.value !== "") {
15 | return (
16 |
17 |
Users
18 |
19 | {
20 | this.props.results.map((user) => (
21 |
22 |
23 |
24 | {user.username}
25 |
26 |
27 | ))
28 | }
29 |
30 |
31 | );
32 | }
33 | }
34 |
35 | }
36 |
37 | export default UserResults;
38 |
--------------------------------------------------------------------------------
/frontend/components/search/playlist_results.jsx:
--------------------------------------------------------------------------------
1 | import React from 'react';
2 | import { Link } from 'react-router';
3 |
4 | class PlaylistResults extends React.Component {
5 |
6 | constructor(props) {
7 | super(props);
8 | }
9 |
10 | render() {
11 | if (!this.props.results ||
12 | this.props.results.length === 0 ||
13 | this.props.input.value === "") { return (
); }
14 | if (this.props.results.length > 0 && this.props.input.value !== "") {
15 | return (
16 |
17 |
Playlists
18 |
19 | {
20 | this.props.results.map((playlist) => (
21 |
22 |
23 |
24 | {playlist.title}
25 |
26 |
27 | ))
28 | }
29 |
30 |
31 | );
32 | }
33 | }
34 | }
35 |
36 | export default PlaylistResults;
37 |
--------------------------------------------------------------------------------
/frontend/components/user_detail/user_detail_playlists.jsx:
--------------------------------------------------------------------------------
1 | import React from 'react';
2 | import { Link } from 'react-router';
3 |
4 | class UserDetailPlaylists extends React.Component {
5 |
6 | constructor(props) {
7 | super(props);
8 | }
9 |
10 | componentWillMount() {
11 | this.props.fetchUserDetail(this.props.id);
12 | }
13 |
14 | renderPlaylists() {
15 | if (this.props.userDetail.playlists) {
16 | return (
17 |
18 | {
19 | this.props.userDetail.playlists.map((playlist) => (
20 |
21 |
22 |
23 | {playlist.title}
24 |
25 |
26 | ))
27 | }
28 |
29 | );
30 | }
31 | }
32 |
33 | render() {
34 | return (
35 |
36 | {this.renderPlaylists()}
37 |
38 | );
39 | }
40 | }
41 |
42 | export default UserDetailPlaylists;
43 |
--------------------------------------------------------------------------------
/frontend/components/user_detail/user_detail_followed_users.jsx:
--------------------------------------------------------------------------------
1 | import React from 'react';
2 | import { Link } from 'react-router';
3 |
4 | class UserDetailFollowedUsers extends React.Component {
5 |
6 | constructor(props) {
7 | super(props);
8 | }
9 |
10 | componentWillMount() {
11 | this.props.fetchUserDetail(this.props.id);
12 | }
13 |
14 | renderUsers() {
15 | if (this.props.userDetail.followed_users) {
16 | return (
17 |
18 | {
19 | this.props.userDetail.followed_users.map((user) => (
20 |
21 |
22 |
23 | {user.username}
24 |
25 |
26 | ))
27 | }
28 |
29 | );
30 | }
31 | }
32 |
33 | render() {
34 | return (
35 |
36 | {this.renderUsers()}
37 |
38 | );
39 | }
40 | }
41 |
42 | export default UserDetailFollowedUsers;
43 |
--------------------------------------------------------------------------------
/frontend/actions/playlist_actions.js:
--------------------------------------------------------------------------------
1 | import * as APIUtil from '../util/playlist_api_util';
2 |
3 | export const RECEIVE_PLAYLIST_DETAIL = "RECEIVE_PLAYLIST_DETAIL";
4 | export const REMOVE_PLAYLIST_TRACK = "REMOVE_PLAYLIST_TRACK";
5 |
6 | export const fetchPlaylistDetail = (id) => dispatch => (
7 | APIUtil.fetchPlaylistDetail(id)
8 | .then(playlist => dispatch(receivePlaylistDetail(playlist)))
9 | );
10 |
11 | export const createPlaylist = playlist => dispatch => (
12 | APIUtil.createPlaylist(playlist).then(playlist => {
13 | dispatch(receivePlaylistDetail(playlist));
14 | return playlist;
15 | })
16 | );
17 |
18 | export const removeTrack = (id) => dispatch => (
19 | APIUtil.removeTrack(id).then((id) => {
20 | dispatch(removePlaylistTrack(id));
21 | })
22 | );
23 |
24 | export const deletePlaylist = id => dispatch => (
25 | APIUtil.deletePlaylist(id)
26 | );
27 |
28 | export const addTracksToPlaylist = data => dispatch => (
29 | APIUtil.addTracksToPlaylist(data)
30 | );
31 |
32 | export const receivePlaylistDetail = playlist => ({
33 | type: RECEIVE_PLAYLIST_DETAIL,
34 | playlist
35 | });
36 |
37 | export const removePlaylistTrack = id => ({
38 | type: REMOVE_PLAYLIST_TRACK,
39 | id
40 | });
41 |
--------------------------------------------------------------------------------
/frontend/components/search/album_results.jsx:
--------------------------------------------------------------------------------
1 | import React from 'react';
2 | import { Link } from 'react-router';
3 |
4 | class AlbumResults extends React.Component {
5 |
6 | constructor(props) {
7 | super(props);
8 | }
9 |
10 | render() {
11 | if (!this.props.results ||
12 | this.props.results.length === 0 ||
13 | this.props.input.value === "") { return (
); }
14 | if (this.props.results.length > 0 && this.props.input.value !== "") {
15 | return (
16 |
17 |
Albums
18 |
19 | {
20 | this.props.results.map((album) => (
21 |
22 |
23 | {album.title}
24 | By {album.artist.name}
25 |
26 | ))
27 | }
28 |
29 |
30 | );
31 | }
32 | }
33 |
34 | }
35 |
36 | export default AlbumResults;
37 |
--------------------------------------------------------------------------------
/config/initializers/new_framework_defaults.rb:
--------------------------------------------------------------------------------
1 | # Be sure to restart your server when you modify this file.
2 | #
3 | # This file contains migration options to ease your Rails 5.0 upgrade.
4 | #
5 | # Read the Guide for Upgrading Ruby on Rails for more info on each option.
6 |
7 | # Enable per-form CSRF tokens. Previous versions had false.
8 | Rails.application.config.action_controller.per_form_csrf_tokens = true
9 |
10 | # Enable origin-checking CSRF mitigation. Previous versions had false.
11 | Rails.application.config.action_controller.forgery_protection_origin_check = true
12 |
13 | # Make Ruby 2.4 preserve the timezone of the receiver when calling `to_time`.
14 | # Previous versions had false.
15 | ActiveSupport.to_time_preserves_timezone = true
16 |
17 | # Require `belongs_to` associations by default. Previous versions had false.
18 | Rails.application.config.active_record.belongs_to_required_by_default = true
19 |
20 | # Do not halt callback chains when a callback returns false. Previous versions had true.
21 | ActiveSupport.halt_callback_chains_on_return_false = false
22 |
23 | # Configure SSL options to enable HSTS with subdomains. Previous versions had false.
24 | Rails.application.config.ssl_options = { hsts: { subdomains: true } }
25 |
--------------------------------------------------------------------------------
/frontend/components/user_detail/user_detail_followed_playlists.jsx:
--------------------------------------------------------------------------------
1 | import React from 'react';
2 | import { Link } from 'react-router';
3 |
4 | class UserDetailFollowedPlaylists extends React.Component {
5 |
6 | constructor(props) {
7 | super(props);
8 | }
9 |
10 | componentWillMount() {
11 | this.props.fetchUserDetail(this.props.id);
12 | }
13 |
14 | renderPlaylists() {
15 | if (this.props.userDetail.followed_playlists) {
16 | return (
17 |
18 | {
19 | this.props.userDetail.followed_playlists.map((playlist) => (
20 |
21 |
22 |
23 | {playlist.title}
24 |
25 |
26 | ))
27 | }
28 |
29 | );
30 | }
31 | }
32 |
33 | render() {
34 | return (
35 |
36 | {this.renderPlaylists()}
37 |
38 | );
39 | }
40 | }
41 |
42 | export default UserDetailFollowedPlaylists;
43 |
--------------------------------------------------------------------------------
/frontend/components/playlist_detail/playlist_detail_container.js:
--------------------------------------------------------------------------------
1 | import { connect } from 'react-redux';
2 | import { fetchPlaylistDetail, deletePlaylist, removeTrack } from '../../actions/playlist_actions';
3 | import { receiveCurrentUser } from '../../actions/session_actions';
4 | import { fetchCurrentUserDetail } from '../../actions/user_actions';
5 | import { createPlaylistFollow, deletePlaylistFollow } from '../../actions/follow_actions';
6 | import { updateNowPlaying } from '../../actions/now_playing_actions';
7 | import PlaylistDetail from './playlist_detail';
8 |
9 | const mapStateToProps = (state, ownProps) => ({
10 | playlistDetail: state.playlistDetail,
11 | id: parseInt(ownProps.params.id),
12 | currentUser: state.session.currentUser,
13 | currentUserDetail: state.currentUserDetail
14 | });
15 |
16 | const mapDispatchToProps = (dispatch) => ({
17 | removeTrack: (data) => dispatch(removeTrack(data)),
18 | fetchCurrentUserDetail: (id) => dispatch(fetchCurrentUserDetail(id)),
19 | fetchPlaylistDetail: (id) => dispatch(fetchPlaylistDetail(id)),
20 | deletePlaylist: (id) => dispatch(deletePlaylist(id)),
21 | createPlaylistFollow: (data) => dispatch(createPlaylistFollow(data)),
22 | deletePlaylistFollow: (data) => dispatch(deletePlaylistFollow(data)),
23 | updateNowPlaying: (tracks) => dispatch(updateNowPlaying(tracks))
24 | });
25 |
26 | export default connect(
27 | mapStateToProps,
28 | mapDispatchToProps
29 | )(PlaylistDetail);
30 |
--------------------------------------------------------------------------------
/docs/components.md:
--------------------------------------------------------------------------------
1 | ## Component Hierarchy
2 |
3 | **AuthFormContainer**
4 | - AuthForm
5 |
6 | **AppContainer**
7 | - Navigation
8 | - Queue
9 | - Audio Player
10 |
11 | **BrowseContainer**
12 | - Browse
13 | - BrowseItem
14 |
15 | **ArtistDetailContainer**
16 | - ArtistDetail
17 | - ReleaseContainer
18 | - Release
19 |
20 | **SearchResultsContainer**
21 | - SearchResults
22 | - SearchResultItem
23 |
24 | **ReleaseContainer**
25 | - Release
26 | - Track
27 |
28 | **FollowedPlaylistsContainer**
29 | - FollowedPlaylists
30 | - FollowedPlaylistItem
31 |
32 | **UserContainer**
33 | - User
34 | - UserPlaylistItem
35 |
36 | **UserFollowsContainer**
37 | - UserFollows
38 | - UserFollowItem
39 |
40 | **FollowedPlaylists**
41 | - FollowedPlaylists
42 | - FollowedPlaylistItem
43 |
44 | **FollowerContainer**
45 | - Followers
46 | - FollowerItem
47 |
48 | **PlaylistContainer**
49 | - Playlist
50 | - PlaylistTrack
51 |
52 | ## Routes
53 |
54 | |Path | Component |
55 | |-------|-------------|
56 | | "/sign-up" | "AuthFormContainer" |
57 | | "/sign-in" | "AuthFormContainer" |
58 | | "/" | "App" |
59 | | "/browse" | "BrowseContainer" |
60 | | "search" | "SearchContainer" |
61 | | "/artists/:artistId" | "ArtistDetailContainer" |
62 | | "/artists/:artistId/releases/releaseId" | "ReleaseDetailContainer" |
63 | | "/home/users/:userId/" | "UserContainer" |
64 | | "/home/users/:userId/playlists" | "UserPlaylistsContainer" |
65 | | "/home/users/:userId/userfollows" | "UserFollowsContainer" |
66 | | "/home/users/:userId/followers" | "UserFollowedContainer" |
67 | | "/home/users/:userId/followedplaylists" | "FollowedPlaylistsContainer" |
68 |
--------------------------------------------------------------------------------
/app/models/user.rb:
--------------------------------------------------------------------------------
1 | class User < ActiveRecord::Base
2 | attr_reader :password
3 |
4 | after_initialize :ensure_session_token
5 |
6 | validates :username, :password_digest, :session_token, presence: true
7 | validates :password, length: { minimum: 6, allow_nil: true }
8 | validates :session_token, :username, uniqueness: true
9 | has_many :playlists
10 | has_many :playlist_follows,
11 | class_name: 'PlaylistFollow',
12 | primary_key: :id,
13 | foreign_key: :user_id
14 | has_many :user_follows,
15 | class_name: 'UserFollow',
16 | primary_key: :id,
17 | foreign_key: :follower_id
18 | has_many :followed_playlists,
19 | through: :playlist_follows,
20 | source: :playlist
21 | has_many :followed_users,
22 | through: :user_follows,
23 | source: :followed
24 |
25 |
26 | def self.find_by_credentials(username, password)
27 | user = User.find_by(username: username)
28 | user.try(:is_password?, password) ? user : nil
29 | end
30 |
31 | def self.generate_session_token
32 | SecureRandom::urlsafe_base64(16)
33 | end
34 |
35 | def is_password?(unencrypted_password)
36 | BCrypt::Password.new(self.password_digest).is_password?(unencrypted_password)
37 | end
38 |
39 | def password=(unencrypted_password)
40 | if unencrypted_password.present?
41 | @password = unencrypted_password
42 | self.password_digest = BCrypt::Password.create(unencrypted_password)
43 | end
44 | end
45 |
46 | def reset_session_token!
47 | self.session_token = self.class.generate_session_token
48 | self.save!
49 | self.session_token
50 | end
51 |
52 | private
53 |
54 | def ensure_session_token
55 | self.session_token ||= self.class.generate_session_token
56 | end
57 | end
58 |
--------------------------------------------------------------------------------
/public/500.html:
--------------------------------------------------------------------------------
1 |
2 |
3 |
4 | We're sorry, but something went wrong (500)
5 |
6 |
55 |
56 |
57 |
58 |
59 |
60 |
61 |
We're sorry, but something went wrong.
62 |
63 |
If you are the application owner check the logs for more information.
64 |
65 |
66 |
67 |
--------------------------------------------------------------------------------
/app/assets/stylesheets/browse-css.css:
--------------------------------------------------------------------------------
1 | .browse-header {
2 | margin-top: 20px;
3 | width: 100%;
4 | font-size: 32px;
5 | display: flex;
6 | flex-direction: column;
7 | justify-content: center;
8 | align-items: center;
9 | }
10 |
11 | .browse-nav {
12 | color: gray;
13 | font-size: 12px;
14 | letter-spacing: 1px;
15 | display: flex;
16 | width: 10%;
17 | justify-content: center;
18 | }
19 |
20 | .browse-nav li {
21 | margin-left: 10px;
22 | margin-right: 10px;
23 | }
24 |
25 | .browse-nav li:hover {
26 | color: white;
27 | }
28 |
29 | .selected-browse-nav {
30 | color: white;
31 | border-bottom: 1px solid #1aff66;
32 | }
33 |
34 | .browse-artists {
35 | margin-top: 40px;
36 | font-family: 'Muli';
37 | font-size: 16px;
38 | width: 100%;
39 | }
40 |
41 | .browse-artists ul {
42 | width: 100%;
43 | display: flex;
44 | justify-content: flex-start;
45 | flex-flow: wrap;
46 | }
47 |
48 | .browse-artist-item {
49 | display: flex;
50 | flex-direction: column;
51 | align-items: center;
52 | margin-left: 20px;
53 | margin-right: 20px;
54 | margin-bottom: 20px;
55 | }
56 |
57 | .browse-artist-item img {
58 | height: 200px;
59 | width: 200px;
60 | border-radius: 50%;
61 | }
62 |
63 | .browse-albums {
64 | margin-top: 40px;
65 | font-family: 'Muli';
66 | font-size: 16px;
67 | width: 100%;
68 | }
69 |
70 | .browse-albums ul {
71 | width: 100%;
72 | display: flex;
73 | justify-content: flex-start;
74 | flex-flow: wrap;
75 | }
76 |
77 | .browse-album-item {
78 | display: flex;
79 | flex-direction: column;
80 | align-items: center;
81 | margin-left: 20px;
82 | margin-right: 20px;
83 | margin-bottom: 20px;
84 | }
85 |
86 | .browse-album-item img {
87 | height: 200px;
88 | width: 200px;
89 | }
90 |
--------------------------------------------------------------------------------
/public/422.html:
--------------------------------------------------------------------------------
1 |
2 |
3 |
4 | The change you wanted was rejected (422)
5 |
6 |
55 |
56 |
57 |
58 |
59 |
60 |
61 |
The change you wanted was rejected.
62 |
Maybe you tried to change something you didn't have access to.
63 |
64 |
If you are the application owner check the logs for more information.
65 |
66 |
67 |
68 |
--------------------------------------------------------------------------------
/public/404.html:
--------------------------------------------------------------------------------
1 |
2 |
3 |
4 | The page you were looking for doesn't exist (404)
5 |
6 |
55 |
56 |
57 |
58 |
59 |
60 |
61 |
The page you were looking for doesn't exist.
62 |
You may have mistyped the address or the page may have moved.
63 |
64 |
If you are the application owner check the logs for more information.
65 |
66 |
67 |
68 |
--------------------------------------------------------------------------------
/app/assets/stylesheets/css-reset.css:
--------------------------------------------------------------------------------
1 | /* http://meyerweb.com/eric/tools/css/reset/
2 | v2.0 | 20110126
3 | License: none (public domain)
4 | */
5 |
6 | html, body, div, span, applet, object, iframe,
7 | h1, h2, h3, h4, h5, h6, p, blockquote, pre,
8 | a, abbr, acronym, address, big, cite, code,
9 | del, dfn, em, img, ins, kbd, q, s, samp,
10 | small, strike, strong, sub, sup, tt, var,
11 | b, u, i, center,
12 | dl, dt, dd, ol, ul, li,
13 | fieldset, form, label, legend,
14 | table, caption, tbody, tfoot, thead, tr, th, td,
15 | article, aside, canvas, details, embed,
16 | figure, figcaption, footer, header, hgroup,
17 | menu, nav, output, ruby, section, summary,
18 | time, mark, audio, video {
19 | margin: 0;
20 | padding: 0;
21 | border: 0;
22 | font-size: 100%;
23 | font: inherit;
24 | vertical-align: baseline;
25 | text-decoration: none;
26 | color: inherit;
27 | font-family: inherit;
28 | }
29 | /* HTML5 display-role reset for older browsers */
30 | article, aside, details, figcaption, figure,
31 | footer, header, hgroup, menu, nav, section {
32 | display: block;
33 | }
34 | body {
35 | line-height: 1;
36 | }
37 | ol, ul {
38 | list-style: none;
39 | }
40 | blockquote, q {
41 | quotes: none;
42 | }
43 | blockquote:before, blockquote:after,
44 | q:before, q:after {
45 | content: '';
46 | content: none;
47 | }
48 | table {
49 | border-collapse: collapse;
50 | border-spacing: 0;
51 | }
52 |
53 | input:focus,
54 | select:focus,
55 | textarea:focus,
56 | button:focus {
57 | outline: none;
58 | }
59 |
60 | input::-webkit-input-placeholder {
61 | color: white;
62 | }
63 |
64 | input:-moz-placeholder { /* Firefox 18- */
65 | color: white;
66 | }
67 |
68 | input::-moz-placeholder { /* Firefox 19+ */
69 | color: white;
70 | }
71 |
72 | input:-ms-input-placeholder {
73 | color: white;
74 | }
75 |
76 | button:hover {
77 | cursor: pointer;
78 | }
79 |
--------------------------------------------------------------------------------
/app/assets/stylesheets/artist-detail.css:
--------------------------------------------------------------------------------
1 | .artist-detail {
2 | height: 100%;
3 | width: 100%;
4 | display: flex;
5 | flex-direction: column;
6 | align-items: center;
7 | }
8 |
9 | .artist-detail > span {
10 | margin: 20px;
11 | font-size: 24px;
12 | font-family: 'Montserrat';
13 | }
14 |
15 | .artist-detail .banner {
16 | width: 100%;
17 | height: 350px;
18 | display: flex;
19 | flex-direction: column;
20 | font-size: 60px;
21 | font-family: 'Montserrat';
22 | font-weight: bold;
23 | justify-content: center;
24 | align-items: center;
25 | }
26 |
27 | .banner button {
28 | width: 130px;
29 | border-radius: 50px;
30 | font-family: 'VegurRegular';
31 | color: white;
32 | background-color: #40ad59;
33 | border: none;
34 | padding-top: 12px;
35 | padding-bottom: 12px;
36 | font-size: 14px;
37 | margin-top: 20px;
38 | }
39 |
40 | .artist-album-list {
41 | width: 95%;
42 | display: flex;
43 | justify-content: flex-start;
44 | }
45 |
46 | .artist-album-list ul {
47 | width: 100%;
48 | display: flex;
49 | flex-direction: row;
50 | justify-content: flex-start;
51 | align-items: flex-start;
52 | flex-flow: wrap;
53 | }
54 |
55 | .artist-album-item {
56 | display: flex;
57 | flex-direction: column;
58 | align-items: center;
59 | font-family: 'Muli';
60 | font-size: 16px;
61 | margin-left: 10px;
62 | margin-right: 10px;
63 | margin-bottom: 20px;
64 | }
65 |
66 | .artist-album-item span {
67 | padding-top: 5px;
68 | }
69 |
70 | .artist-album-item img {
71 | height: 200px;
72 | width: 200px;
73 | margin-bottom: 15px;
74 | }
75 |
76 | .album-artist-link {
77 | color: gray;
78 | }
79 |
80 | .album-artist-link a:hover {
81 | color: white;
82 | }
83 |
84 | #browse-album-artist-link {
85 | margin-top: 5px;
86 | color: gray;
87 | }
88 |
89 | #browse-album-artist-link a:hover {
90 | color: white;
91 | }
92 |
--------------------------------------------------------------------------------
/frontend/components/playbar/scroll_bar.jsx:
--------------------------------------------------------------------------------
1 | import React from 'react';
2 |
3 | class ScrollBar extends React.Component {
4 |
5 | constructor(props) {
6 | super(props);
7 | this.playbar = this.props.playbar;
8 | this.handleScrollClick = this.handleScrollClick.bind(this);
9 | }
10 |
11 | handleScrollClick(e) {
12 | e.preventDefault();
13 | const timelineWidth = this.scrollbar.getBoundingClientRect().width;
14 | const timelineLeft = this.scrollbar.getBoundingClientRect().left;
15 | const clickPos = (e.clientX - timelineLeft) / timelineWidth;
16 |
17 | this.props.audio.currentTime = this.props.audio.duration * clickPos;
18 | this.playbar.setState({ elapsed: this.props.audio.currentTime });
19 | }
20 |
21 | renderLength(length) {
22 | if (this.props.audio) {
23 | const seconds = length % 60 < 10 ? `0${Math.floor(length % 60)}` : Math.floor(length % 60);
24 | return `${Math.floor(length / 60)}:${seconds}`;
25 | }
26 | }
27 |
28 | render() {
29 | if (!this.playbar.state.queue) {
30 | return
;
31 | }
32 | if (this.playbar.state.queue[0]) {
33 | return (
34 |
35 |
{this.renderLength(this.playbar.state.elapsed)}
36 |
{ this.scrollbar = scrollbar; } }
37 | onClick={this.handleScrollClick} value={(this.playbar.state.elapsed/this.playbar.state.queue[0].length) * 100} max="100">
38 |
{this.renderLength(this.playbar.state.queue[0].length)}
39 |
40 | );
41 | } else {
42 | return (
43 |
44 |
0:00
45 |
46 |
0:00
47 |
48 | );
49 | }
50 | }
51 | }
52 |
53 | export default ScrollBar;
54 |
--------------------------------------------------------------------------------
/config/environments/test.rb:
--------------------------------------------------------------------------------
1 | Rails.application.configure do
2 | # Settings specified here will take precedence over those in config/application.rb.
3 |
4 | # The test environment is used exclusively to run your application's
5 | # test suite. You never need to work with it otherwise. Remember that
6 | # your test database is "scratch space" for the test suite and is wiped
7 | # and recreated between test runs. Don't rely on the data there!
8 | config.cache_classes = true
9 |
10 | # Do not eager load code on boot. This avoids loading your whole application
11 | # just for the purpose of running a single test. If you are using a tool that
12 | # preloads Rails for running tests, you may have to set it to true.
13 | config.eager_load = false
14 |
15 | # Configure public file server for tests with Cache-Control for performance.
16 | config.public_file_server.enabled = true
17 | config.public_file_server.headers = {
18 | 'Cache-Control' => 'public, max-age=3600'
19 | }
20 |
21 | # Show full error reports and disable caching.
22 | config.consider_all_requests_local = true
23 | config.action_controller.perform_caching = false
24 |
25 | # Raise exceptions instead of rendering exception templates.
26 | config.action_dispatch.show_exceptions = false
27 |
28 | # Disable request forgery protection in test environment.
29 | config.action_controller.allow_forgery_protection = false
30 | config.action_mailer.perform_caching = false
31 |
32 | # Tell Action Mailer not to deliver emails to the real world.
33 | # The :test delivery method accumulates sent emails in the
34 | # ActionMailer::Base.deliveries array.
35 | config.action_mailer.delivery_method = :test
36 |
37 | # Print deprecation notices to the stderr.
38 | config.active_support.deprecation = :stderr
39 |
40 | # Raises error for missing translations
41 | # config.action_view.raise_on_missing_translations = true
42 | end
43 |
--------------------------------------------------------------------------------
/docs/README.md:
--------------------------------------------------------------------------------
1 | # SSTify
2 |
3 | [Heroku Link](https://sstify.herokuapp.com)
4 |
5 | ## Minimum Viable Product
6 |
7 | SSTify is a web application inspired by Spotify and built using Ruby on Rails and React/Redux that serves a home for the wonderful music of legendary '80s punk/alternative label SST Records. By the end of Week 9, this app will, at a minimum, satisfy the following criteria with smooth, bug-free navigation, adequate seed data and sufficient CSS Styling:
8 |
9 | - [ ] New account creation, login, and guest/demo login
10 | - [ ] A production README
11 | - [ ] Hosting on Heroku
12 | - [ ] Song/Playlist CRUD
13 | - [ ] Search
14 | - [ ] Continuous play while navigating site
15 | - [ ] Following playlists/Friending users
16 |
17 | ## Design Docs
18 | * [View Wireframes](wireframes)
19 | * [React Components](components.md)
20 | * [API endpoints](api-endpoints.md)
21 | * [DB schema](schema.md)
22 | * [Sample State](state.md)
23 |
24 | ## Implementation Timeline
25 |
26 | ### Phase 1: Backend setup and Front End User Authentication (2 days)
27 |
28 | **Objective:** Functioning rails project with front-end Authentication
29 |
30 | ### Phase 2: Landing page, artist, and album views (2 days)
31 |
32 | **Objective:** Users can navigate to root page, artist pages, and album pages
33 |
34 | ### Phase 3: Playlist CRUD (1 day)
35 |
36 | **Objective:** Users can create, view, update and destroy playlists.
37 |
38 | ### Phase 4: User pages/following (1 day)
39 |
40 | **Objective:** Users can follow users and view their playlists and follows.
41 |
42 | ### Phase 5: Music player and continuous play while navigating site (2 day)
43 |
44 | **Objective:** Users can play tracks and navigate the site while tracks play.
45 |
46 | ### Phase 6: - Search (1 day)
47 |
48 | **Objective:** Users can search for users, artists, albums, songs, and playlists.
49 |
50 | ### Bonus Features (TBD)
51 | - [ ] Radio (shuffle play)
52 | - [ ] Explore page
53 |
--------------------------------------------------------------------------------
/app/assets/stylesheets/user-detail.css:
--------------------------------------------------------------------------------
1 | .user-header {
2 | margin-top: 20px;
3 | width: 100%;
4 | font-size: 32px;
5 | display: flex;
6 | flex-direction: column;
7 | justify-content: center;
8 | align-items: center;
9 | }
10 |
11 | .user-header img {
12 | height: 100px;
13 | width: 100px;
14 | border-radius: 50%;
15 | margin-bottom: 10px;
16 | }
17 |
18 | .user-nav {
19 | margin-top: 15px;
20 | color: gray;
21 | font-size: 12px;
22 | letter-spacing: 1px;
23 | display: flex;
24 | justify-content: center;
25 | }
26 |
27 | .user-nav li {
28 | margin-left: 10px;
29 | margin-right: 10px;
30 | }
31 |
32 | .user-nav li:hover {
33 | color: white;
34 | }
35 |
36 | .selected-user-nav {
37 | color: white;
38 | border-bottom: 1px solid #1aff66;
39 | }
40 |
41 | .user-follow-button {
42 | width: 110px;
43 | border-radius: 50px;
44 | font-family: 'VegurRegular';
45 | color: white;
46 | background-color: #40ad59;
47 | border: none;
48 | padding-top: 15px;
49 | padding-bottom: 15px;
50 | font-size: 14px;
51 | margin-top: 10px;
52 | margin-bottom: 10px;
53 | letter-spacing: 3px;
54 | }
55 |
56 | .user-followed-playlists {
57 | display: flex;
58 | justify-content: center;
59 | margin-top: 40px;
60 | font-family: 'Muli';
61 | font-size: 16px;
62 | width: 100%;
63 | }
64 |
65 | .user-followed-playlists ul {
66 | width: 90%;
67 | display: flex;
68 | justify-content: flex-start;
69 | flex-flow: wrap;
70 | }
71 |
72 | .followed-playlist-item {
73 | display: flex;
74 | flex-direction: column;
75 | align-items: center;
76 | margin-left: 20px;
77 | margin-right: 20px;
78 | margin-bottom: 20px;
79 | }
80 |
81 | .followed-playlist-item a {
82 | display: flex;
83 | color: gray;
84 | align-items: center;
85 | flex-direction: column;
86 | }
87 |
88 | .followed-playlist-item a:hover {
89 | color: white;
90 | }
91 |
92 | .followed-playlist-item img {
93 | height: 150px;
94 | width: 150px;
95 | border-radius: 50%;
96 | }
97 |
--------------------------------------------------------------------------------
/frontend/components/artist_detail/artist_detail.jsx:
--------------------------------------------------------------------------------
1 | import React from 'react';
2 | import { Link } from 'react-router';
3 |
4 | class ArtistDetail extends React.Component {
5 |
6 | constructor(props) {
7 | super(props);
8 | this.handleButton = this.handleButton.bind(this);
9 | }
10 |
11 | componentWillMount() {
12 | this.props.fetchArtistDetail(this.props.id);
13 | }
14 |
15 | handleButton() {
16 | let tracks = [];
17 | this.props.artistDetail.albums.forEach((album) => {
18 | tracks = tracks.concat(album.tracks);
19 | });
20 | this.props.updateNowPlaying({ played: [], queue: tracks });
21 | }
22 |
23 | renderBanner(artist) {
24 | const bannerImage = {
25 | background: `linear-gradient(rgba(84,72,72,.6), rgba(84,72,72,.6)), url("${artist.image_url}")`,
26 | backgroundSize: '70% 100%'
27 | };
28 | return (
29 |
30 | {artist.name}
31 | P L A Y
32 |
33 | );
34 | }
35 |
36 | renderAlbums(artist) {
37 | return (
38 |
39 |
40 | {artist.albums.map((album) =>
41 |
42 |
43 | {album.title}
44 | By {album.artist.name}
45 |
46 | )}
47 |
48 |
49 | );
50 | }
51 |
52 | render() {
53 | const artist = this.props.artistDetail;
54 | return (
55 |
56 |
57 | {this.renderBanner(artist)}
58 | Albums
59 | {this.renderAlbums(artist)}
60 |
61 |
62 | );
63 | }
64 |
65 | }
66 |
67 | export default ArtistDetail;
68 |
--------------------------------------------------------------------------------
/config/environments/development.rb:
--------------------------------------------------------------------------------
1 | Rails.application.configure do
2 | # Settings specified here will take precedence over those in config/application.rb.
3 |
4 | # In the development environment your application's code is reloaded on
5 | # every request. This slows down response time but is perfect for development
6 | # since you don't have to restart the web server when you make code changes.
7 | config.cache_classes = false
8 |
9 | # Do not eager load code on boot.
10 | config.eager_load = false
11 |
12 | # Show full error reports.
13 | config.consider_all_requests_local = true
14 |
15 | # Enable/disable caching. By default caching is disabled.
16 | if Rails.root.join('tmp/caching-dev.txt').exist?
17 | config.action_controller.perform_caching = true
18 |
19 | config.cache_store = :memory_store
20 | config.public_file_server.headers = {
21 | 'Cache-Control' => 'public, max-age=172800'
22 | }
23 | else
24 | config.action_controller.perform_caching = false
25 |
26 | config.cache_store = :null_store
27 | end
28 |
29 | # Don't care if the mailer can't send.
30 | config.action_mailer.raise_delivery_errors = false
31 |
32 | config.action_mailer.perform_caching = false
33 |
34 | # Print deprecation notices to the Rails logger.
35 | config.active_support.deprecation = :log
36 |
37 | # Raise an error on page load if there are pending migrations.
38 | config.active_record.migration_error = :page_load
39 |
40 | # Debug mode disables concatenation and preprocessing of assets.
41 | # This option may cause significant delays in view rendering with a large
42 | # number of complex assets.
43 | config.assets.debug = true
44 |
45 | # Suppress logger output for asset requests.
46 | config.assets.quiet = true
47 |
48 | # Raises error for missing translations
49 | # config.action_view.raise_on_missing_translations = true
50 |
51 | # Use an evented file watcher to asynchronously detect changes in source code,
52 | # routes, locales, etc. This feature depends on the listen gem.
53 | config.file_watcher = ActiveSupport::EventedFileUpdateChecker
54 | end
55 |
--------------------------------------------------------------------------------
/Gemfile:
--------------------------------------------------------------------------------
1 | source 'https://rubygems.org'
2 |
3 | git_source(:github) do |repo_name|
4 | repo_name = "#{repo_name}/#{repo_name}" unless repo_name.include?("/")
5 | "https://github.com/#{repo_name}.git"
6 | end
7 |
8 | gem 'ruby-mp3info', :require => 'mp3info'
9 | gem 'paperclip', '~>5.2.0'
10 | gem 'figaro'
11 | gem 'aws-sdk', '>=2.0'
12 | gem 'rails_12factor'
13 | gem 'pry-rails'
14 | # Bundle edge Rails instead: gem 'rails', github: 'rails/rails'
15 | gem 'rails', '~> 5.0.2'
16 | # Use postgresql as the database for Active Record
17 | gem 'pg', '~> 0.18'
18 | # Use Puma as the app server
19 | gem 'puma', '~> 3.0'
20 | # Use SCSS for stylesheets
21 | gem 'sass-rails', '~> 5.0'
22 | # Use Uglifier as compressor for JavaScript assets
23 | gem 'uglifier', '>= 1.3.0'
24 | # Use CoffeeScript for .coffee assets and views
25 | gem 'coffee-rails', '~> 4.2'
26 | # See https://github.com/rails/execjs#readme for more supported runtimes
27 | # gem 'therubyracer', platforms: :ruby
28 |
29 | # Use jquery as the JavaScript library
30 | gem 'jquery-rails'
31 | # Build JSON APIs with ease. Read more: https://github.com/rails/jbuilder
32 | gem 'jbuilder', '~> 2.5'
33 | gem 'bcrypt'
34 | gem 'js_assets'
35 | # Use Redis adapter to run Action Cable in production
36 | # gem 'redis', '~> 3.0'
37 | # Use ActiveModel has_secure_password
38 | # gem 'bcrypt', '~> 3.1.7'
39 |
40 | # Use Capistrano for deployment
41 | # gem 'capistrano-rails', group: :development
42 |
43 | group :development, :test do
44 | # Call 'byebug' anywhere in the code to stop execution and get a debugger console
45 | gem 'byebug', platform: :mri
46 | end
47 |
48 | group :development do
49 | # Access an IRB console on exception pages or by using <%= console %> anywhere in the code.
50 | gem 'web-console', '>= 3.3.0'
51 | gem 'listen', '~> 3.0.5'
52 | # Spring speeds up development by keeping your application running in the background. Read more: https://github.com/rails/spring
53 | gem 'spring'
54 | gem 'spring-watcher-listen', '~> 2.0.0'
55 | end
56 |
57 | # Windows does not include zoneinfo files, so bundle the tzinfo-data gem
58 | gem 'tzinfo-data', platforms: [:mingw, :mswin, :x64_mingw, :jruby]
59 |
--------------------------------------------------------------------------------
/frontend/components/playbar/play_controls.jsx:
--------------------------------------------------------------------------------
1 | import React from 'react';
2 |
3 | class PlayControls extends React.Component {
4 |
5 | constructor(props) {
6 | super(props);
7 | this.playbar = this.props.playbar;
8 | this.previousTrack = this.previousTrack.bind(this);
9 | this.pause = this.pause.bind(this);
10 | this.play = this.play.bind(this);
11 | this.playClass = this.playClass.bind(this);
12 | this.buttonClass = this.buttonClass.bind(this);
13 | }
14 |
15 | previousTrack() {
16 | const newQueue = this.playbar.state.queue;
17 | const newPlayed = this.playbar.state.played;
18 | newQueue.unshift(newPlayed.pop());
19 | this.playbar.setState({ played: newPlayed, queue: newQueue, status: 'play' });
20 | }
21 |
22 | pause() {
23 | this.playbar.audio.pause();
24 | this.playbar.setState({ status: 'pause' });
25 | }
26 |
27 | play() {
28 | if (this.playbar.state.queue[0]) {
29 | this.playbar.audio.play();
30 | this.playbar.setState({ status: 'play' });
31 | }
32 | }
33 |
34 | playClass(button) {
35 | if (this.playbar.state.status === 'play') {
36 | return button === 'play' ? 'hidden' : 'material-icons';
37 | } else {
38 | return button === 'play' ? 'material-icons' : 'hidden';
39 | }
40 | }
41 |
42 | buttonClass(button) {
43 | return this.playbar.state[button] ? "material-icons selected" : "material-icons"
44 | }
45 |
46 | render() {
47 | return (
48 |
49 | shuffle
50 | skip_previous
51 | play_circle_outline
52 | pause_circle_outline
53 | skip_next
54 | repeat
55 |
56 | );
57 | }
58 | }
59 |
60 | export default PlayControls;
61 |
--------------------------------------------------------------------------------
/config/puma.rb:
--------------------------------------------------------------------------------
1 | # Puma can serve each request in a thread from an internal thread pool.
2 | # The `threads` method setting takes two numbers a minimum and maximum.
3 | # Any libraries that use thread pools should be configured to match
4 | # the maximum value specified for Puma. Default is set to 5 threads for minimum
5 | # and maximum, this matches the default thread size of Active Record.
6 | #
7 | threads_count = ENV.fetch("RAILS_MAX_THREADS") { 5 }.to_i
8 | threads threads_count, threads_count
9 |
10 | # Specifies the `port` that Puma will listen on to receive requests, default is 3000.
11 | #
12 | port ENV.fetch("PORT") { 3000 }
13 |
14 | # Specifies the `environment` that Puma will run in.
15 | #
16 | environment ENV.fetch("RAILS_ENV") { "development" }
17 |
18 | # Specifies the number of `workers` to boot in clustered mode.
19 | # Workers are forked webserver processes. If using threads and workers together
20 | # the concurrency of the application would be max `threads` * `workers`.
21 | # Workers do not work on JRuby or Windows (both of which do not support
22 | # processes).
23 | #
24 | # workers ENV.fetch("WEB_CONCURRENCY") { 2 }
25 |
26 | # Use the `preload_app!` method when specifying a `workers` number.
27 | # This directive tells Puma to first boot the application and load code
28 | # before forking the application. This takes advantage of Copy On Write
29 | # process behavior so workers use less memory. If you use this option
30 | # you need to make sure to reconnect any threads in the `on_worker_boot`
31 | # block.
32 | #
33 | # preload_app!
34 |
35 | # The code in the `on_worker_boot` will be called if you are using
36 | # clustered mode by specifying a number of `workers`. After each worker
37 | # process is booted this block will be run, if you are using `preload_app!`
38 | # option you will want to use this block to reconnect to any threads
39 | # or connections that may have been created at application boot, Ruby
40 | # cannot share connections between processes.
41 | #
42 | # on_worker_boot do
43 | # ActiveRecord::Base.establish_connection if defined?(ActiveRecord)
44 | # end
45 |
46 | # Allow puma to be restarted by `rails restart` command.
47 | plugin :tmp_restart
48 |
--------------------------------------------------------------------------------
/frontend/components/search/search.jsx:
--------------------------------------------------------------------------------
1 | import React from 'react';
2 | import { Link } from 'react-router';
3 | import TrackResults from './track_results';
4 | import ArtistResults from './artist_results';
5 | import PlaylistResults from './playlist_results';
6 | import UserResults from './user_results';
7 | import AlbumResults from './album_results';
8 |
9 | class Search extends React.Component {
10 |
11 | constructor(props) {
12 | super(props);
13 | this.handleChange = this.handleChange.bind(this);
14 | this.state = { change: null };
15 | }
16 |
17 | componentWillMount() {
18 | this.input = {};
19 | }
20 |
21 | handleChange(e) {
22 | if (this.state.change) {
23 | clearTimeout(this.state.change);
24 | this.setState({ change: null });
25 | }
26 | var terms = e.currentTarget.value;
27 | this.setState({ change: setTimeout(() => {
28 | this.props.search({ search: { terms: terms } })
29 | }, 1000) });
30 | }
31 |
32 | render() {
33 | return (
34 |
35 |
36 |
37 | Search for an Artist, Song, Album, Playlist, or User
38 | { this.input = input; } }
39 | onChange={this.handleChange} placeholder="Start typing..."
40 | type="text">
41 |
42 |
43 |
44 |
47 |
48 |
49 |
50 |
51 |
52 |
53 |
55 |
56 |
57 | );
58 | }
59 |
60 | }
61 |
62 | export default Search;
63 |
--------------------------------------------------------------------------------
/frontend/components/playlist_detail/playlist_tracks.jsx:
--------------------------------------------------------------------------------
1 | import React from 'react';
2 | import { Link } from 'react-router';
3 |
4 | class PlaylistTracks extends React.Component {
5 |
6 | constructor(props) {
7 | super(props);
8 | this.parent = this.props.parent;
9 | }
10 |
11 | removeTrack(id) {
12 | this.parent.props.removeTrack(id);
13 | }
14 |
15 | handleTrackButton(i) {
16 | const tracks = this.props.playlist.tracks;
17 | this.parent.props.updateNowPlaying({ played: tracks.slice(0, i), queue: tracks.slice(i) });
18 | }
19 |
20 | renderLength(track) {
21 | const seconds = track.length % 60 < 10 ? `0${track.length % 60}` : track.length % 60;
22 | return `${Math.floor(track.length / 60)}:${seconds}`;
23 | }
24 |
25 | render() {
26 | return (
27 |
28 |
29 | {this.props.playlist.tracks.map((track, i) =>
30 | this.handleTrackButton(i)}>
31 |
32 |
33 |
34 | {i+1}.
35 | this.handleTrackButton(i)} className='play-button'>
36 |
37 |
38 |
39 |
40 | {track.title}
41 |
42 |
43 | {track.artist.name}
44 | ·
45 | {track.album.title}
46 |
47 |
48 |
49 |
50 | this.removeTrack(track.id)} className="material-icons">delete
51 | {this.renderLength(track)}
52 |
53 | )
54 | }
55 |
56 |
57 | );
58 | }
59 |
60 | }
61 |
62 | export default PlaylistTracks;
63 |
--------------------------------------------------------------------------------
/frontend/components/search/track_results.jsx:
--------------------------------------------------------------------------------
1 | import React from 'react';
2 | import { Link } from 'react-router';
3 |
4 | class TrackResults extends React.Component {
5 |
6 | constructor(props) {
7 | super(props);
8 | }
9 |
10 | renderLength(track) {
11 | const seconds = track.length % 60 < 10 ? `0${track.length % 60}` : track.length % 60;
12 | return `${Math.floor(track.length / 60)}:${seconds}`;
13 | }
14 |
15 | handleTrackButton(i) {
16 | const tracks = this.props.results;
17 | this.props.updateNowPlaying({ played: tracks.slice(0, i), queue: tracks.slice(i) });
18 | }
19 |
20 | render() {
21 | if (!this.props.results ||
22 | this.props.results.length === 0 ||
23 | this.props.input.value === "") { return (
); }
24 | if (this.props.results.length > 0 && this.props.input.value !== "") {
25 | return (
26 |
27 |
Tracks
28 |
29 |
30 | {this.props.results.map((track, i) =>
31 | this.handleTrackButton(i)}>
32 |
33 |
34 |
35 | this.handleTrackButton(i)} className='play-button'>
36 |
37 |
38 |
39 |
40 | {track.title}
41 |
42 |
43 | {track.artist.name}
44 | ·
45 | {track.album.title}
46 |
47 |
48 |
49 |
50 | {this.renderLength(track)}
51 |
52 | )
53 | }
54 |
55 |
56 |
57 | );
58 | }
59 | }
60 | }
61 |
62 | export default TrackResults;
63 |
--------------------------------------------------------------------------------
/frontend/components/album_detail/track_list.jsx:
--------------------------------------------------------------------------------
1 | import React from 'react';
2 | import { Link } from 'react-router';
3 |
4 | class TrackList extends React.Component {
5 |
6 | constructor(props) {
7 | super(props);
8 | this.parent = this.props.parent;
9 | }
10 |
11 | handleTrackButton(i) {
12 | const tracks = this.parent.props.albumDetail.tracks;
13 | this.parent.props.updateNowPlaying({ played: tracks.slice(0, i), queue: tracks.slice(i) });
14 | }
15 |
16 | toggleTrackDropdown(i) {
17 | if (this.parent.state.showTrackDropdown === null) {
18 | this.parent.setState({showTrackDropdown: i});
19 | } else {
20 | this.parent.setState({showTrackDropdown: null});
21 | }
22 | }
23 |
24 | renderLength(track) {
25 | const seconds = track.length % 60 < 10 ? `0${track.length % 60}` : track.length % 60;
26 | return `${Math.floor(track.length / 60)}:${seconds}`;
27 | }
28 |
29 | renderTrackDropdown(track, i) {
30 | if (i == this.parent.state.showTrackDropdown) {
31 | return (
32 |
33 |
Add to Playlist
34 |
35 | {
36 | this.parent.props.currentUserDetail.playlists.map((playlist) => (
37 | this.parent.addTracksToPlaylist([track], playlist.id)} key={playlist.id}>
38 | {playlist.title}
39 |
40 | ))
41 | }
42 |
43 |
44 | );
45 | }
46 | }
47 |
48 | render() {
49 | return (
50 |
51 |
52 | {
53 | this.props.album.tracks.map((track, i) =>
54 | this.handleTrackButton(i)}>
55 |
56 |
57 | {i+1}.
58 | this.handleTrackButton(i)} className='play-button'>
59 |
60 | {track.title}
61 |
62 |
63 | this.toggleTrackDropdown(i+1)}>. . .
65 | {this.renderLength(track)}
66 | {this.renderTrackDropdown(track, i+1)}
67 |
68 | )
69 | }
70 |
71 |
72 | );
73 | }
74 | }
75 |
76 | export default TrackList;
77 |
--------------------------------------------------------------------------------
/README.md:
--------------------------------------------------------------------------------
1 | # SSTify
2 |
3 | [SSTify live](https://sstify.herokuapp.com)
4 |
5 | SSTify is a full-stack web application inspired by Spotify and created as a tribute to the catalog of the legendary SST Records. It's built on a Ruby on Rails backend, a PostgreSQL database, and a Reactjs/Redux frontend.
6 |
7 | ## Features and Implementation
8 |
9 | ### Asynchronous Music Playback
10 |
11 | 
12 |
13 | SSTify's music playbar is routed at the root page, allowing it to remain on screen and actively playing music regardless of where else the user navigates. When the user clicks a play button on an album, playlist, or song, the store is update with an array holding a queue of tracks and their attached information, which then gets placed in the playbar component's state. The page's audio HTML element gets its source attribute filled in with the URL of the first track in the queue, and when the track is finished or skipped, an event is fired that unshifts the track from the queue, thus replacing the audio element's source with the next track's URL. The playbar component's state also holds an "elapsed" variable, which counts the number of seconds that have elapsed in the currently playing track. This allows for constant updating of the time information in the playbar as well as the percentage of the progress bar is filled.
14 |
15 | ### Playlist creation
16 |
17 | 
18 |
19 | Users can create playlists at any time using the Add Playlist button located on the sidebar. After the playlist has been created, it immediately shows up in the playlists section of the sidebar, allowing users to have access to their playlists at all times. Users can then add tracks or entire albums to their playlists by clicking the add icons and selecting their desired playlist from the resulting dropdown menu. Playlists are managed in the database through the join table PlaylistTracks, which connects the Playlists and Tracks table. A PlaylistTrack item has foreign keys for the playlist and track, as well as a playlist_ord column, which indicates where in the playlist the track is located.
20 |
21 | ### Search
22 |
23 | 
24 |
25 | Users can search for artists, albums, tracks, playlists, and users through the search function, conveniently located on the sidebar. Each keystroke the user makes fires off a new AJAX request to the database, populating the store with new results and thus rendering the results immediately on the page. Search has its own backend route, which uses an ActiveRecord 'where' query (including an 'ILIKE' operator to accomodate uppercase/lowercase letters) to find matching items for each category.
26 |
--------------------------------------------------------------------------------
/docs/schema.md:
--------------------------------------------------------------------------------
1 | # Schema Information
2 |
3 | ## users
4 | column name | data type | details
5 | ----------------|-----------|-----------------------
6 | id | integer | not null, primary key
7 | username | string | not null, indexed, unique
8 | email | string | not null, indexed, unique
9 | password_digest | string | not null
10 | session_token | string | not null, indexed, unique
11 |
12 | ## artists
13 | column name | data type | details
14 | ------------|-----------|-----------------------
15 | id | integer | not null, primary key
16 | name | string | not null
17 | image_url | string | not null
18 | bio | text | not null
19 |
20 | ## albums
21 | column name | data type | details
22 | ------------|-----------|-----------------------
23 | id | integer | not null, primary key
24 | artist_id | integer | not null, foreign key (references artists), indexed
25 | title | integer | not null
26 | image_url | string | not null
27 | year | integer | not null
28 |
29 | ## tracks
30 | column name | data type | details
31 | ------------|-----------|-----------------------
32 | id | integer | not null, primary key
33 | album_id | integer | not null, foreign key (references releases), indexed
34 | title | string | not null
35 | album_ord | integer | not null
36 | track_url | string | not null
37 |
38 | ## playlists
39 | column name | data type | details
40 | ------------|-----------|-----------------------
41 | id | integer | not null, primary key
42 | user_id | integer | not null, foreign key (references users), indexed
43 | title | string | not null
44 |
45 | ## playlist_follows
46 | column name | data type | details
47 | ------------|-----------|-----------------------
48 | id | integer | not null, primary key
49 | user_id | integer | not null, foreign key (references users), indexed
50 | playlist_id | integer | not null, foreign key (references playlists), indexed
51 |
52 | ## playlist_tracks
53 | column name | data type | details
54 | ------------|-----------|-----------------------
55 | id | integer | not null, primary key
56 | track_id | integer | not null, foreign key (references tracks), indexed
57 | playlist_id | integer | not null, foreign key (references playlists), indexed
58 | playlist_ord| integer | not null
59 |
60 | ## user_follows
61 | column name | data type | details
62 | ------------|-----------|-----------------------
63 | id | integer | not null, primary key
64 | follower_id | integer | not null, foreign key (references users), indexed
65 | followed_id | integer | not null, foreign key (references users), indexed
66 |
--------------------------------------------------------------------------------
/frontend/components/album_detail/album_detail.jsx:
--------------------------------------------------------------------------------
1 | import React from 'react';
2 | import { Link } from 'react-router';
3 | import TrackList from './track_list';
4 |
5 | class AlbumDetail extends React.Component {
6 |
7 | constructor(props) {
8 | super(props);
9 | this.state = { showAlbumDropdown: false, showTrackDropdown: null };
10 | this.toggleAlbumDropdown = this.toggleAlbumDropdown.bind(this);
11 | this.handleAlbumButton = this.handleAlbumButton.bind(this);
12 | }
13 |
14 | componentWillMount() {
15 | this.props.fetchAlbumDetail(this.props.id);
16 | }
17 |
18 | showAlbumDropdown() {
19 | return this.state.showAlbumDropdown ? 'album-playlist-add-show' : 'album-playlist-add';
20 | }
21 |
22 | toggleAlbumDropdown() {
23 | const newState = this.state.showAlbumDropdown ? false : true;
24 | this.setState({ showAlbumDropdown: newState });
25 | }
26 |
27 | handleAlbumButton() {
28 | this.props.updateNowPlaying({ played: [], queue: this.props.albumDetail.tracks });
29 | }
30 |
31 | renderDropdown(tracks) {
32 | return (
33 |
34 |
Add to Playlist
35 |
36 | {
37 | this.props.currentUserDetail.playlists.map((playlist) => (
38 | this.addTracksToPlaylist(tracks, playlist.id)} key={playlist.id}>
39 | {playlist.title}
40 |
41 | ))
42 | }
43 |
44 |
45 | );
46 | }
47 |
48 | addTracksToPlaylist(tracks, playlist_id) {
49 | this.props.addTracksToPlaylist({ tracks, playlist_id });
50 | this.setState({ showAlbumDropdown: false, showTrackDropdown: null });
51 | }
52 |
53 | renderInfo(album) {
54 | return (
55 |
56 |
57 |
{album.title}
58 |
59 | By {album.artist.name}
60 |
61 |
{album.tracks.length} SONGS
62 |
PLAY
63 |
. . .
64 |
65 | {this.renderDropdown(album.tracks)}
66 |
67 |
68 | );
69 | }
70 |
71 | render() {
72 | const album = this.props.albumDetail;
73 | return (
74 |
75 |
76 | {this.renderInfo(album)}
77 |
78 |
79 |
80 | );
81 | }
82 |
83 | }
84 |
85 | export default AlbumDetail;
86 |
--------------------------------------------------------------------------------
/db/schema.rb:
--------------------------------------------------------------------------------
1 | # This file is auto-generated from the current state of the database. Instead
2 | # of editing this file, please use the migrations feature of Active Record to
3 | # incrementally modify your database, and then regenerate this schema definition.
4 | #
5 | # Note that this schema.rb definition is the authoritative source for your
6 | # database schema. If you need to create the application database on another
7 | # system, you should be using db:schema:load, not running all the migrations
8 | # from scratch. The latter is a flawed and unsustainable approach (the more migrations
9 | # you'll amass, the slower it'll run and the greater likelihood for issues).
10 | #
11 | # It's strongly recommended that you check this file into your version control system.
12 |
13 | ActiveRecord::Schema.define(version: 20170426004840) do
14 |
15 | # These are extensions that must be enabled in order to support this database
16 | enable_extension "plpgsql"
17 |
18 | create_table "albums", force: :cascade do |t|
19 | t.integer "artist_id", null: false
20 | t.string "title", null: false
21 | t.integer "year", null: false
22 | t.string "image_file_name"
23 | t.string "image_content_type"
24 | t.integer "image_file_size"
25 | t.datetime "image_updated_at"
26 | end
27 |
28 | create_table "artists", force: :cascade do |t|
29 | t.string "name", null: false
30 | t.string "image_file_name"
31 | t.string "image_content_type"
32 | t.integer "image_file_size"
33 | t.datetime "image_updated_at"
34 | end
35 |
36 | create_table "playlist_follows", force: :cascade do |t|
37 | t.integer "user_id", null: false
38 | t.integer "playlist_id", null: false
39 | end
40 |
41 | create_table "playlist_tracks", force: :cascade do |t|
42 | t.integer "track_id", null: false
43 | t.integer "playlist_id", null: false
44 | t.integer "playlist_ord", null: false
45 | end
46 |
47 | create_table "playlists", force: :cascade do |t|
48 | t.integer "user_id", null: false
49 | t.string "title", null: false
50 | end
51 |
52 | create_table "tracks", force: :cascade do |t|
53 | t.integer "album_id", null: false
54 | t.string "title", null: false
55 | t.integer "album_ord", null: false
56 | t.string "audio_file_name"
57 | t.string "audio_content_type"
58 | t.integer "audio_file_size"
59 | t.datetime "audio_updated_at"
60 | t.integer "length", null: false
61 | end
62 |
63 | create_table "user_follows", force: :cascade do |t|
64 | t.integer "follower_id"
65 | t.integer "followed_id"
66 | end
67 |
68 | create_table "users", force: :cascade do |t|
69 | t.string "username", null: false
70 | t.string "password_digest", null: false
71 | t.string "session_token", null: false
72 | end
73 |
74 | end
75 |
--------------------------------------------------------------------------------
/app/assets/stylesheets/auth-css.css:
--------------------------------------------------------------------------------
1 | /* AUTH CSS */
2 |
3 | .login-form-container {
4 | font-family: 'Montserrat', sans-serif;
5 | position: fixed;
6 | top: 0;
7 | left: 0;
8 | bottom: 0;
9 | right: 0;
10 | overflow: auto;
11 | background: black;
12 | background-size: 100%;
13 | display: flex;
14 | justify-content: center;
15 | align-items: center;
16 | }
17 |
18 | .login-form-box {
19 | color: white;
20 | width: 20%;
21 | padding-right: 30px;
22 | border-right: 2px solid gray;
23 | display: flex;
24 | flex-direction: column;
25 | justify-content: space-between;
26 | }
27 |
28 | .login-form-box input[type="text"], .login-form-box input[type="password"] {
29 | color: white;
30 | background: none;
31 | margin: 10px 10px 10px 0px;
32 | border: none;
33 | border-bottom: solid 2px #c9c9c9;
34 | transition: border 0.3s;
35 | width: 100%;
36 | font-size: 15px;
37 | }
38 |
39 | .login-form-box input[type="submit"] {
40 | background-color: Transparent;
41 | color: white;
42 | border-radius: 50px;
43 | width: 100%;
44 | font-family: 'Montserrat', sans-serif;
45 | padding: 10px;
46 | font-size: 12px;
47 | cursor: pointer;
48 | margin: 10px 0px 10px 0px;
49 | }
50 |
51 | .login-form-box button {
52 | background-color: Transparent;
53 | color: white;
54 | border-radius: 50px;
55 | width: 100%;
56 | font-family: 'Montserrat', sans-serif;
57 | padding: 10px;
58 | font-size: 12px;
59 | cursor: pointer;
60 | margin: 10px 0px 10px 0px;
61 | }
62 |
63 | .logo {
64 | display: flex;
65 | justify-content: center;
66 | align-items: center;
67 | margin-bottom: 15px;
68 | }
69 |
70 | .logo img {
71 | height: 50px;
72 | width: 50px;
73 | }
74 |
75 | .logo span {
76 | margin-left: 15px;
77 | color: #1aff66;
78 | font-size: 24px;
79 | }
80 |
81 | .login-form {
82 | display: flex;
83 | flex-direction: column;
84 | justify-content: space-between;
85 | align-items: center;
86 | }
87 |
88 | .login-form-label {
89 | font-size: 12px;
90 | color: gray;
91 | width: 100%
92 | }
93 |
94 | .form-change-link {
95 | font-size: 10px;
96 | color: gray;
97 | text-decoration: none;
98 | align: center;
99 | }
100 |
101 | .form-change-link:hover {
102 | text-decoration: underline;
103 | }
104 |
105 | .form-error {
106 | color: red;
107 | font-size: 10px;
108 | text-align: center;
109 | }
110 |
111 | .app-description {
112 | padding-left: 20px;
113 | width: 25%;
114 | }
115 |
116 | .app-description h1 {
117 | color: #1aff66;
118 | font-size: 40px;
119 | padding-bottom: 20px;
120 | }
121 |
122 | .app-description h3 {
123 | color: white;
124 | padding-bottom: 20px;
125 | font-weight: lighter;
126 | font-size: 20px;
127 | }
128 |
129 | .app-description li {
130 | color: white;
131 | font-size: 20px;
132 | }
133 |
134 | .app-description li:before {
135 | content: "\2713\0020";
136 | }
137 |
--------------------------------------------------------------------------------
/frontend/components/user_detail/user_detail.jsx:
--------------------------------------------------------------------------------
1 | import React from 'react';
2 | import { Link } from 'react-router';
3 |
4 | class UserDetail extends React.Component {
5 |
6 | constructor(props) {
7 | super(props);
8 | this.renderButton = this.renderButton.bind(this);
9 | this.toggleFollow = this.toggleFollow.bind(this);
10 | this.followStatus = this.followStatus.bind(this);
11 | }
12 |
13 | componentWillMount() {
14 | this.props.fetchUserDetail(this.props.id);
15 | }
16 |
17 | componentWillReceiveProps(nextProps) {
18 | if (this.props.id !== nextProps.id) {
19 | this.props.fetchUserDetail(nextProps.id);
20 | }
21 | }
22 |
23 | checkSelected(linkPath) {
24 | if (linkPath === this.props.location.pathname) { return 'selected-user-nav'; }
25 | }
26 |
27 | renderButton() {
28 | if (this.props.currentUser) {
29 | if (this.props.id === this.props.currentUser.id) {
30 | return;
31 | } else {
32 | return (
33 | {this.followStatus()}
34 | );
35 | }
36 | }
37 | }
38 |
39 | followStatus() {
40 | if (this.props.currentUserDetail.followed_users) {
41 | let status = "FOLLOW";
42 | this.props.currentUserDetail.followed_users.forEach((user) => {
43 | if ( user.id === this.props.userDetail.id) {
44 | status = "UNFOLLOW";
45 | }
46 | });
47 | return status;
48 | }
49 | }
50 |
51 | toggleFollow() {
52 | if (this.followStatus() === "FOLLOW") {
53 | this.props.createUserFollow({ user_follow: { follower_id: this.props.currentUser.id, followed_id: this.props.userDetail.id } });
54 | } else {
55 | this.props.deleteUserFollow({ user_follow: { follower_id: this.props.currentUser.id, followed_id: this.props.userDetail.id } });
56 | }
57 | }
58 |
59 | render() {
60 | if (!this.props.userDetail) {
61 | return (
);
62 | }
63 | return (
64 |
65 |
66 |
67 |
{this.props.userDetail.username}
68 | {this.renderButton()}
69 |
70 | PLAYLISTS
72 | FOLLOWED PLAYLISTS
74 | FOLLOWED USERS
76 |
77 | {this.props.children}
78 |
79 |
80 | );
81 | }
82 | }
83 |
84 | export default UserDetail;
85 |
--------------------------------------------------------------------------------
/docs/state.md:
--------------------------------------------------------------------------------
1 | ```js
2 | {
3 | session: {
4 | currentUser: {
5 | id: 1,
6 | username: 'jtbrubak'
7 | },
8 | errors: []
9 | },
10 | allArtists: [
11 | {
12 | id: 1,
13 | name: 'Deerhunter',
14 | image_url: 'http://image.com'
15 | }
16 | ...
17 | ]
18 | currentTrack: {
19 | id: 1,
20 | track_url: 'http://mp3.com',
21 | title: 'Helicopter',
22 | artist_id: 1,
23 | artist: 'Deerhunter',
24 | album_id: 1,
25 | album: 'Halcyon Digest'
26 | }
27 | userDetail: {
28 | id: 2,
29 | username: 'kabrubak',
30 | email: 'kabrubak@gmail.com',
31 | playlists: [
32 | {
33 | id: 1,
34 | title: 'ROCKIN PLAYLIST'
35 | }
36 | ],
37 | followed_playlists: [
38 | {
39 | id: 2,
40 | title: 'NOT SO ROCKIN PLAYLIST'
41 | }
42 | ],
43 | followed_users: [
44 | {
45 | id: 1,
46 | username: 'jtbrubak'
47 | }
48 | ],
49 | followers: [
50 | {
51 | id: 1,
52 | username: 'jtbrubak'
53 | }
54 | ]
55 | },
56 | artistDetail: {
57 | id: 1,
58 | name: 'Deerhunter',
59 | image_url: 'http://image',
60 | bio: 'Good band',
61 | albums: [
62 | {
63 | id: 1,
64 | title: 'Halcyon Digest',
65 | type: 'album',
66 | image_url: 'http://image',
67 | year: '2010',
68 | tracks: [
69 | {
70 | id: 1,
71 | title: 'Earthquake',
72 | album_ord: 1,
73 | track_url: 'http://music'
74 | },
75 | {
76 | id: 2,
77 | title: 'Don\'t Cry',
78 | album_ord: 2,
79 | track_url: 'http://music'
80 | }
81 | ...
82 | ]
83 | }
84 | ],
85 | },
86 | albumDetail: {
87 | id: 1,
88 | artist_id: 1,
89 | artist: 'Deerhunter',
90 | title: '',
91 | image_url: 'http://image',
92 | year: '2010',
93 | tracks: [
94 | {
95 | id: 1,
96 | title: 'Earthquake',
97 | album_ord: 1,
98 | track_url: 'http://music'
99 | },
100 | {
101 | id: 2,
102 | title: 'Don\'t Cry',
103 | album_ord: 2,
104 | track_url: 'http://music'
105 | }
106 | ...
107 | ]
108 | },
109 | playlistDetail: {
110 | id: 1,
111 | user_id: 1,
112 | username: 'jtbrubak',
113 | title: 'ROCKIN PLAYLIST',
114 | tracks: [
115 | {
116 | id: 1,
117 | artist_id: 1,
118 | artist: 'Deerhunter',
119 | title: 'Earthquake',
120 | playlist_ord: 1,
121 | track_url: 'http://music'
122 | },
123 | {
124 | id: 2,
125 | artist_id: 1,
126 | artist: 'Deerhunter',
127 | title: 'Don\'t Cry',
128 | release_ord: 2,
129 | track_url: 'http://music'
130 | }
131 | ]
132 | }
133 | }
134 |
--------------------------------------------------------------------------------
/config/database.yml:
--------------------------------------------------------------------------------
1 | # PostgreSQL. Versions 9.1 and up are supported.
2 | #
3 | # Install the pg driver:
4 | # gem install pg
5 | # On OS X with Homebrew:
6 | # gem install pg -- --with-pg-config=/usr/local/bin/pg_config
7 | # On OS X with MacPorts:
8 | # gem install pg -- --with-pg-config=/opt/local/lib/postgresql84/bin/pg_config
9 | # On Windows:
10 | # gem install pg
11 | # Choose the win32 build.
12 | # Install PostgreSQL and put its /bin directory on your path.
13 | #
14 | # Configure Using Gemfile
15 | # gem 'pg'
16 | #
17 | default: &default
18 | adapter: postgresql
19 | encoding: unicode
20 | # For details on connection pooling, see rails configuration guide
21 | # http://guides.rubyonrails.org/configuring.html#database-pooling
22 | pool: <%= ENV.fetch("RAILS_MAX_THREADS") { 5 } %>
23 |
24 | development:
25 | <<: *default
26 | database: SSTify_development
27 |
28 | # The specified database role being used to connect to postgres.
29 | # To create additional roles in postgres see `$ createuser --help`.
30 | # When left blank, postgres will use the default role. This is
31 | # the same name as the operating system user that initialized the database.
32 | #username: SSTify
33 |
34 | # The password associated with the postgres role (username).
35 | #password:
36 |
37 | # Connect on a TCP socket. Omitted by default since the client uses a
38 | # domain socket that doesn't need configuration. Windows does not have
39 | # domain sockets, so uncomment these lines.
40 | #host: localhost
41 |
42 | # The TCP port the server listens on. Defaults to 5432.
43 | # If your server runs on a different port number, change accordingly.
44 | #port: 5432
45 |
46 | # Schema search path. The server defaults to $user,public
47 | #schema_search_path: myapp,sharedapp,public
48 |
49 | # Minimum log levels, in increasing order:
50 | # debug5, debug4, debug3, debug2, debug1,
51 | # log, notice, warning, error, fatal, and panic
52 | # Defaults to warning.
53 | #min_messages: notice
54 |
55 | # Warning: The database defined as "test" will be erased and
56 | # re-generated from your development database when you run "rake".
57 | # Do not set this db to the same as development or production.
58 | test:
59 | <<: *default
60 | database: SSTify_test
61 |
62 | # As with config/secrets.yml, you never want to store sensitive information,
63 | # like your database password, in your source code. If your source code is
64 | # ever seen by anyone, they now have access to your database.
65 | #
66 | # Instead, provide the password as a unix environment variable when you boot
67 | # the app. Read http://guides.rubyonrails.org/configuring.html#configuring-a-database
68 | # for a full rundown on how to provide these environment variables in a
69 | # production deployment.
70 | #
71 | # On Heroku and other platform providers, you may have a full connection URL
72 | # available as an environment variable. For example:
73 | #
74 | # DATABASE_URL="postgres://myuser:mypass@localhost/somedatabase"
75 | #
76 | # You can use this database configuration with:
77 | #
78 | # production:
79 | # url: <%= ENV['DATABASE_URL'] %>
80 | #
81 | production:
82 | <<: *default
83 | database: SSTify_production
84 | username: SSTify
85 | password: <%= ENV['SSTIFY_DATABASE_PASSWORD'] %>
86 |
--------------------------------------------------------------------------------
/frontend/components/playlist_detail/playlist_detail.jsx:
--------------------------------------------------------------------------------
1 | import React from 'react';
2 | import { Link } from 'react-router';
3 | import PlaylistTracks from './playlist_tracks';
4 |
5 | class PlaylistDetail extends React.Component {
6 |
7 | constructor(props) {
8 | super(props);
9 | this.handleDelete = this.handleDelete.bind(this);
10 | this.followStatus = this.followStatus.bind(this);
11 | this.toggleFollow = this.toggleFollow.bind(this);
12 | this.handlePlaylistButton = this.handlePlaylistButton.bind(this);
13 | }
14 |
15 | componentWillMount() {
16 | this.props.fetchPlaylistDetail(this.props.id);
17 | }
18 |
19 | componentWillReceiveProps(nextProps) {
20 | if (this.props.id !== nextProps.id) {
21 | this.props.fetchPlaylistDetail(nextProps.id);
22 | }
23 | }
24 |
25 | handleDelete() {
26 | this.props.deletePlaylist(this.props.id).then(() => {
27 | this.props.fetchCurrentUserDetail(this.props.currentUser.id);
28 | this.props.router.replace('/');
29 | });
30 | }
31 |
32 | renderButton() {
33 | if (this.props.playlistDetail.user.id === this.props.currentUser.id) {
34 | return (
35 | DELETE
36 | );
37 | } else {
38 | return (
39 | {this.followStatus()}
40 | );
41 | }
42 | }
43 |
44 | handlePlaylistButton() {
45 | this.props.updateNowPlaying({ played: [], queue: this.props.playlistDetail.tracks });
46 | }
47 |
48 | toggleFollow() {
49 | if (this.followStatus() === "FOLLOW") {
50 | this.props.createPlaylistFollow({ playlist_follow: { user_id: this.props.currentUser.id, playlist_id: this.props.playlistDetail.id } });
51 | } else {
52 | this.props.deletePlaylistFollow({ playlist_follow: { user_id: this.props.currentUser.id, playlist_id: this.props.playlistDetail.id } });
53 | }
54 | }
55 |
56 | followStatus() {
57 | let status = "FOLLOW";
58 | this.props.currentUserDetail.followed_playlists.forEach((playlist) => {
59 | if ( playlist.id === this.props.playlistDetail.id) {
60 | status = "UNFOLLOW";
61 | }
62 | });
63 | return status;
64 | }
65 |
66 | renderInfo(playlist) {
67 | const image_url = "http://greenlea.ru/Articles-Directory/Online-Dating-the-First-Step-Is-Your-Profile/i0099rp.jpg";
68 | return (
69 |
70 |
71 |
{playlist.title}
72 |
73 | By {playlist.user.username}
74 |
75 |
{playlist.tracks.length} SONGS
76 |
PLAY
77 | {this.renderButton()}
78 |
79 | );
80 | }
81 |
82 | render() {
83 | const playlist = this.props.playlistDetail;
84 | return (
85 |
86 |
87 | {this.renderInfo(playlist)}
88 |
89 |
90 |
91 | );
92 | }
93 | }
94 |
95 | export default PlaylistDetail;
96 |
--------------------------------------------------------------------------------
/frontend/components/root.jsx:
--------------------------------------------------------------------------------
1 | import React from 'react';
2 | import { Provider } from 'react-redux';
3 |
4 | // react router
5 | import { Router, Route, IndexRoute, hashHistory } from 'react-router';
6 |
7 | // components
8 | import SessionFormContainer from './session_form/session_form_container';
9 | import MainContainer from './main/main_container';
10 | import BrowseContainer from './browse/browse_container';
11 | import BrowseArtistsContainer from './browse/browse_artists_container';
12 | import BrowseAlbumsContainer from './browse/browse_albums_container';
13 | import AlbumDetailContainer from './album_detail/album_detail_container';
14 | import ArtistDetailContainer from './artist_detail/artist_detail_container';
15 | import PlaylistDetailContainer from './playlist_detail/playlist_detail_container'
16 | import UserDetailContainer from './user_detail/user_detail_container';
17 | import UserDetailFollowedPlaylistsContainer from './user_detail/user_detail_followed_playlists_container';
18 | import UserDetailFollowedUsersContainer from './user_detail/user_detail_followed_users_container';
19 | import UserDetailPlaylistsContainer from './user_detail/user_detail_playlists_container';
20 | import SearchContainer from './search/search_container';
21 |
22 | const Root = ({ store }) => {
23 |
24 | const _ensureLoggedIn = (nextState, replace) => {
25 | const currentUser = store.getState().session.currentUser;
26 | if (!currentUser) {
27 | replace('/login');
28 | }
29 | };
30 |
31 | const _redirectIfLoggedIn = (nextState, replace) => {
32 | const currentUser = store.getState().session.currentUser;
33 | if (currentUser) {
34 | replace('/browse/artists');
35 | }
36 | }
37 |
38 | const _redirect = (nextState, replace) => {
39 | const currentUser = store.getState().session.currentUser;
40 | if (currentUser) {
41 | replace('/browse/artists');
42 | } else {
43 | replace('/login');
44 | }
45 | }
46 |
47 | return (
48 |
49 |
50 |
51 |
52 |
53 |
54 |
55 |
56 |
57 |
58 |
59 |
60 |
61 |
62 |
63 |
64 |
65 |
66 |
67 |
68 |
69 |
70 |
71 |
72 | );
73 | };
74 |
75 | export default Root;
76 |
--------------------------------------------------------------------------------
/config/environments/production.rb:
--------------------------------------------------------------------------------
1 | Rails.application.configure do
2 | # Settings specified here will take precedence over those in config/application.rb.
3 |
4 | # Code is not reloaded between requests.
5 | config.cache_classes = true
6 |
7 | # Eager load code on boot. This eager loads most of Rails and
8 | # your application in memory, allowing both threaded web servers
9 | # and those relying on copy on write to perform better.
10 | # Rake tasks automatically ignore this option for performance.
11 | config.eager_load = true
12 |
13 | # Full error reports are disabled and caching is turned on.
14 | config.consider_all_requests_local = false
15 | config.action_controller.perform_caching = true
16 |
17 | # Disable serving static files from the `/public` folder by default since
18 | # Apache or NGINX already handles this.
19 | config.public_file_server.enabled = ENV['RAILS_SERVE_STATIC_FILES'].present?
20 |
21 | # Compress JavaScripts and CSS.
22 | config.assets.js_compressor = :uglifier
23 | # config.assets.css_compressor = :sass
24 |
25 | # Do not fallback to assets pipeline if a precompiled asset is missed.
26 | config.assets.compile = false
27 |
28 | # `config.assets.precompile` and `config.assets.version` have moved to config/initializers/assets.rb
29 |
30 | # Enable serving of images, stylesheets, and JavaScripts from an asset server.
31 | # config.action_controller.asset_host = 'http://assets.example.com'
32 |
33 | # Specifies the header that your server uses for sending files.
34 | # config.action_dispatch.x_sendfile_header = 'X-Sendfile' # for Apache
35 | # config.action_dispatch.x_sendfile_header = 'X-Accel-Redirect' # for NGINX
36 |
37 | # Mount Action Cable outside main process or domain
38 | # config.action_cable.mount_path = nil
39 | # config.action_cable.url = 'wss://example.com/cable'
40 | # config.action_cable.allowed_request_origins = [ 'http://example.com', /http:\/\/example.*/ ]
41 |
42 | # Force all access to the app over SSL, use Strict-Transport-Security, and use secure cookies.
43 | # config.force_ssl = true
44 |
45 | # Use the lowest log level to ensure availability of diagnostic information
46 | # when problems arise.
47 | config.log_level = :debug
48 |
49 | # Prepend all log lines with the following tags.
50 | config.log_tags = [ :request_id ]
51 |
52 | # Use a different cache store in production.
53 | # config.cache_store = :mem_cache_store
54 |
55 | # Use a real queuing backend for Active Job (and separate queues per environment)
56 | # config.active_job.queue_adapter = :resque
57 | # config.active_job.queue_name_prefix = "SSTify_#{Rails.env}"
58 | config.action_mailer.perform_caching = false
59 |
60 | # Ignore bad email addresses and do not raise email delivery errors.
61 | # Set this to true and configure the email server for immediate delivery to raise delivery errors.
62 | # config.action_mailer.raise_delivery_errors = false
63 |
64 | # Enable locale fallbacks for I18n (makes lookups for any locale fall back to
65 | # the I18n.default_locale when a translation cannot be found).
66 | config.i18n.fallbacks = true
67 |
68 | # Send deprecation notices to registered listeners.
69 | config.active_support.deprecation = :notify
70 |
71 | # Use default logging formatter so that PID and timestamp are not suppressed.
72 | config.log_formatter = ::Logger::Formatter.new
73 |
74 | # Use a different logger for distributed setups.
75 | # require 'syslog/logger'
76 | # config.logger = ActiveSupport::TaggedLogging.new(Syslog::Logger.new 'app-name')
77 |
78 | if ENV["RAILS_LOG_TO_STDOUT"].present?
79 | logger = ActiveSupport::Logger.new(STDOUT)
80 | logger.formatter = config.log_formatter
81 | config.logger = ActiveSupport::TaggedLogging.new(logger)
82 | end
83 |
84 | # Do not dump schema after migrations.
85 | config.active_record.dump_schema_after_migration = false
86 | end
87 |
--------------------------------------------------------------------------------
/app/assets/stylesheets/search-css.css:
--------------------------------------------------------------------------------
1 | .search-bar {
2 | display: flex;
3 | align-items: center;
4 | justify-content: center;
5 | height: 150px;
6 | width: 100%;
7 | background-color: #3b3b3b;
8 | }
9 |
10 | .search-input-box {
11 | height: 75%;
12 | width: 95%;
13 | display: flex;
14 | flex-direction: column;
15 | }
16 |
17 | .search-input-box input {
18 | height: 70%;
19 | font-size: 60px;
20 | border: none;
21 | color: white;
22 | background-color: transparent;
23 | }
24 |
25 | .search-input-box span {
26 | font-family: 'Muli';
27 | font-size: 14px;
28 | margin-bottom: 5px;
29 | }
30 |
31 | .results {
32 | width: 95%;
33 | display: flex;
34 | flex-direction: column;
35 | justify-content: flex-start;
36 | align-items: center;
37 | margin-top: 20px;
38 | }
39 |
40 | .result-box {
41 | width: 95%;
42 | display: flex;
43 | flex-direction: column;
44 | align-items: center;
45 | justify-content: center;
46 | flex-wrap: wrap;
47 | }
48 |
49 | .result-box span {
50 | font-size: 24px;
51 | margin-bottom: 10px;
52 | }
53 |
54 | .result-box ul {
55 | width: 100%;
56 | display: flex;
57 | justify-content: flex-start;
58 | flex-wrap: wrap;
59 | }
60 |
61 | .search-item {
62 | font-size: 16px;
63 | display: flex;
64 | flex-direction: column;
65 | align-items: center;
66 | margin-left: 20px;
67 | margin-right: 20px;
68 | margin-bottom: 20px;
69 | }
70 |
71 | .search-item span {
72 | font-size: 16px;
73 | }
74 |
75 | .search-item a {
76 | font-size: 16px;
77 | display: flex;
78 | color: gray;
79 | align-items: center;
80 | flex-direction: column;
81 | }
82 |
83 | .search-item a:hover {
84 | color: white;
85 | }
86 |
87 | .search-item img {
88 | height: 200px;
89 | width: 200px;
90 | border-radius: 50%;
91 | }
92 |
93 | .result-box .browse-album-item span {
94 | font-size: 16px;
95 | }
96 |
97 | .result-box .browse-album-item a {
98 | font-size: 16px;
99 | }
100 |
101 | .result-box .browse-album-item img {
102 | height: 200px;
103 | width: 200px;
104 | }
105 |
106 | .result-box .track-list {
107 | width: 100%;
108 | }
109 |
110 | .result-box .track-list ol {
111 | font-family: 'Muli';
112 | color: #d7dbe2;
113 | font-size: 20px;
114 | }
115 |
116 | .result-box .track-list li {
117 | -webkit-user-select: none;
118 | -khtml-user-select: none;
119 | -moz-user-select: none;
120 | -ms-user-select: none;
121 | -o-user-select: none;
122 | user-select: none;
123 | margin-right: 20px;
124 | counter-increment: count-me;
125 | padding: 20px;
126 | display: flex;
127 | justify-content: space-between;
128 | }
129 |
130 | .result-box .track-list li:hover {
131 | cursor: default;
132 | background: rgba(0, 0, 0, 0.3);
133 | }
134 |
135 | .result-box .track-list-left-side {
136 | display: flex;
137 | flex-direction: column;
138 | }
139 |
140 | .result-box .play-pause-button {
141 | background: none;
142 | border: none;
143 | padding-right: 10px;
144 | color: gray;
145 | font-size: 18px;
146 | bottom: 5px;
147 | right: 5px;
148 | position: relative;
149 | }
150 |
151 | .result-box .track-list-right-side {
152 | margin: 0;
153 | display: flex;
154 | justify-content: space-between;
155 | width: 150px;
156 | position: relative;
157 | top: 10px;
158 | }
159 |
160 | .result-box .track-list-right-side i {
161 | cursor: pointer;
162 | }
163 |
164 | .result-box .track-list-right-side span {
165 | position: relative;
166 | top: 3px;
167 | }
168 |
169 | .search-input-box input::-webkit-input-placeholder {
170 | color: gray;
171 | }
172 |
173 | .search-input-box input:-moz-placeholder { /* Firefox 18- */
174 | color: gray;
175 | }
176 |
177 | .search-input-box input::-moz-placeholder { /* Firefox 19+ */
178 | color: gray;
179 | }
180 |
181 | .search-input-box input:-ms-input-placeholder {
182 | color: gray;
183 | }
184 |
--------------------------------------------------------------------------------
/frontend/components/session_form/session_form.jsx:
--------------------------------------------------------------------------------
1 | import React from 'react';
2 | import { Link, withRouter } from 'react-router';
3 |
4 | class SessionForm extends React.Component {
5 | constructor(props) {
6 | super(props);
7 | this.state = { username: "", password: "" };
8 | this.handleSubmit = this.handleSubmit.bind(this);
9 | this.handleGuestLogin = this.handleGuestLogin.bind(this);
10 | this.handleError = this.handleError.bind(this);
11 | this.handleSwitch = this.handleSwitch.bind(this);
12 | }
13 |
14 | componentDidUpdate() {
15 | this.redirectIfLoggedIn();
16 | }
17 |
18 | handleError(error) {
19 | if (error === "Password digest can't be blank") {
20 | return "Password can't be blank";
21 | }
22 | return error;
23 | }
24 |
25 | redirectIfLoggedIn() {
26 | if (this.props.loggedIn) {
27 | this.props.router.push("/");
28 | }
29 | }
30 |
31 | update(field) {
32 | return e => this.setState({
33 | [field]: e.currentTarget.value
34 | });
35 | }
36 |
37 | handleSubmit(e) {
38 | e.preventDefault();
39 | const user = this.state;
40 | this.props.processForm({user});
41 | }
42 |
43 | handleGuestLogin(e) {
44 | e.preventDefault();
45 | const user = { username: 'guest', password: 'password' };
46 | this.props.processForm({ user });
47 | }
48 |
49 | handleSwitch() {
50 | this.props.clearErrors();
51 | this.setState({ username: "", password: "" });
52 | }
53 |
54 | navLink() {
55 | if (this.props.formType === "login") {
56 | return Don't have an account? Sign up here!;
57 | } else {
58 | return Already have an account? Log in here!;
59 | }
60 | }
61 |
62 | renderGuestLogin() {
63 | if (this.props.formType === "login") {
64 | return GUEST LOGIN ;
65 | }
66 | }
67 |
68 | renderErrors() {
69 | return(
70 |
71 | {this.props.errors.map((error, i) => (
72 |
73 | {this.handleError(error)}
74 |
75 | ))}
76 |
77 | );
78 | }
79 |
80 | render() {
81 | return (
82 |
83 |
112 |
113 |
Get the right music, right now
114 |
Listen to the legendary SST Records catalog for free.
115 |
116 | Search & discover music you'll love
117 | Create playlists of your favorite music
118 |
119 |
120 |
121 | );
122 | }
123 | }
124 |
125 | export default withRouter(SessionForm);
126 |
--------------------------------------------------------------------------------
/app/assets/stylesheets/playlist-detail.css:
--------------------------------------------------------------------------------
1 | .playlist-detail {
2 | display: flex;
3 | align-items: space-between;
4 | }
5 |
6 | .playlist-detail .left-side {
7 | display: flex;
8 | flex-direction: column;
9 | align-items: center;
10 | justify-content: space-around;
11 | width: 30%;
12 | height: 100%;
13 | flex: 1;
14 | }
15 |
16 | .playlist-detail .track-list {
17 | width: 70%;
18 | }
19 |
20 | .playlist-detail .left-side img {
21 | margin-top: 30px;
22 | margin-bottom: 20px;
23 | size: 100%;
24 | align-self: center;
25 | }
26 |
27 | .playlist-detail .left-side .playlist-add-button {
28 | font-size: 16px;
29 | flex: 1;
30 | }
31 |
32 | .playlist-detail .left-side .play-playlist-button {
33 | width: 175px;
34 | border-radius: 50px;
35 | font-family: 'VegurRegular';
36 | color: white;
37 | background-color: #40ad59;
38 | border: none;
39 | padding-top: 15px;
40 | padding-bottom: 15px;
41 | font-size: 14px;
42 | margin-top: 10px;
43 | margin-bottom: 10px;
44 | letter-spacing: 3px;
45 | }
46 |
47 | .playlist-detail .left-side .playlist-add-button {
48 | background: none;
49 | color: #d7dbe2;
50 | border: none;
51 | }
52 |
53 | .playlist-detail .track-list {
54 | margin-top: 50px;
55 | }
56 |
57 | .playlist-detail .track-list ol {
58 | font-family: 'Muli';
59 | color: #d7dbe2;
60 | font-size: 20px;
61 | }
62 |
63 | .playlist-detail .track-list li {
64 | -webkit-user-select: none;
65 | -khtml-user-select: none;
66 | -moz-user-select: none;
67 | -ms-user-select: none;
68 | -o-user-select: none;
69 | user-select: none;
70 | margin-right: 20px;
71 | counter-increment: count-me;
72 | padding: 20px;
73 | display: flex;
74 | justify-content: space-between;
75 | }
76 |
77 | .playlist-detail .track-list li:hover {
78 | cursor: default;
79 | background: rgba(0, 0, 0, 0.3);
80 | }
81 |
82 | .playlist-detail .track-list-right-side {
83 | display: flex;
84 | justify-content: space-between;
85 | width: 150px;
86 | position: relative;
87 | top: 10px;
88 | }
89 |
90 | .playlist-detail .track-list-right-side i {
91 | cursor: pointer;
92 | }
93 |
94 | .playlist-detail .track-list-right-side span {
95 | position: relative;
96 | top: 3px;
97 | }
98 |
99 | .track-list-right-side .playlist-add-button {
100 | margin-right: 70px;
101 | bottom: 5px;
102 | display: none;
103 | position: relative;
104 | }
105 |
106 | .track-list li:hover .playlist-add-button {
107 | display: inline;
108 | }
109 |
110 | .before-track-name {
111 | display: flex;
112 | justify-content: flex-end;
113 | align-items: flex
114 | }
115 |
116 | .playlist-detail .play-pause-button {
117 | background: none;
118 | border: none;
119 | padding-right: 10px;
120 | color: gray;
121 | font-size: 18px;
122 | bottom: 10px;
123 | right: 5px;
124 | position: relative;
125 | }
126 |
127 | .track-list li:hover .track-num {
128 | display: none;
129 | }
130 |
131 | .track-list li:hover .play-button:before {
132 | content: '►';
133 | }
134 |
135 | .playlist-detail .track-list li:hover .play-pause-button {
136 | content: '►';
137 | }
138 |
139 | #playlist-title {
140 | font-size: 24px;
141 | padding-bottom: 10px;
142 | text-align: center;
143 | }
144 |
145 | #playlist-info {
146 | font-size: 16px;
147 | color: gray;
148 | font-family: 'VegurRegular';
149 | font-weight: lighter;
150 | padding-bottom: 10px;
151 | }
152 |
153 | #playlist-info a {
154 | text-decoration: none;
155 | color: gray;
156 | }
157 |
158 | #playlist-info a:hover {
159 | text-decoration: underline;
160 | color: white;
161 | }
162 |
163 | @media all and (max-width: 700px) {
164 | .playlist-detail .left-side { display: none; }
165 | }
166 |
167 | .second-line {
168 | display: flex;
169 | justify-content: flex-start;
170 | }
171 |
172 | .second-line span {
173 | padding-right: 10px;
174 | color: gray;
175 | font-size: 16px;
176 | }
177 |
178 | .second-line a span:hover {
179 | color: white;
180 | }
181 |
182 | .playlist-track-display {
183 | display: flex;
184 | }
185 |
--------------------------------------------------------------------------------
/frontend/components/playbar/playbar.jsx:
--------------------------------------------------------------------------------
1 | import React from 'react';
2 | import { Link } from 'react-router';
3 | import PlayControls from './play_controls';
4 | import ScrollBar from './scroll_bar';
5 | import shuffle from 'shuffle-array';
6 |
7 | class Playbar extends React.Component {
8 |
9 | constructor(props) {
10 | super(props);
11 | this.state = { played: [], queue: [], shuffleQueue: [], shufflePlayed: [],
12 | status: 'pause', shuffle: false, repeat: false, elapsed: 0 };
13 | this.nextTrack = this.nextTrack.bind(this);
14 | this.updateElapsed = this.updateElapsed.bind(this);
15 | this.changeVolume = this.changeVolume.bind(this);
16 | this.toggleShuffle = this.toggleShuffle.bind(this);
17 | this.toggleRepeat = this.toggleRepeat.bind(this);
18 | }
19 |
20 | currentTrack() {
21 | // if (!this.state.queue) {
22 | // return;
23 | // }
24 | if (this.state.shuffle && this.state.shuffleQueue[0]) {
25 | return this.state.shuffleQueue[0];
26 | }
27 | else if (this.state.queue[0]) {
28 | return this.state.queue[0];
29 | } else {
30 | return "";
31 | }
32 | }
33 |
34 | toggleShuffle() {
35 | var shuffle = this.state.shuffle ? false : true;
36 | this.setState({ shuffle: shuffle });
37 | }
38 |
39 | shuffleTrack() {
40 | if (this.state.shuffleQueue.length === 0) {
41 | newQueue.concat(shuffle(this.state.played.concat(this.state.queue)));
42 | this.setState({ shuffleQueue: newQueue });
43 | }
44 | const newQueue = this.state.shuffleQueue;
45 | const newPlayed = this.state.shufflePlayed;
46 | newPlayed.push(newQueue.shift());
47 | this.setState({ shufflePlayed: newPlayed, shuffleQueue: newQueue, status: 'play' });
48 | }
49 |
50 | toggleRepeat() {
51 | var repeat = this.state.repeat ? false : true;
52 | this.setState({ repeat: repeat });
53 | }
54 |
55 | componentWillReceiveProps(nextProps) {
56 | var shuffled = nextProps.nowPlaying.queue.slice(0);
57 | this.setState({ played: nextProps.nowPlaying.played,
58 | queue: nextProps.nowPlaying.queue,
59 | shuffleQueue: shuffle(shuffled), status: 'play' });
60 | }
61 |
62 | nextTrack() {
63 | if (this.state.repeat) {
64 | this.audio.currentTime = 0;
65 | this.audio.play();
66 | return;
67 | } else if (this.state.shuffle) {
68 | this.shuffleTrack();
69 | return;
70 | }
71 | const newQueue = this.state.queue;
72 | const newPlayed = this.state.played;
73 | newPlayed.push(newQueue.shift());
74 | this.setState({ played: newPlayed, queue: newQueue, status: 'play' });
75 | }
76 |
77 | renderNowPlayingInfo() {
78 | if (this.state.queue[0]) {
79 | const nowPlaying = this.currentTrack();
80 | return (
81 |
82 |
83 |
84 |
85 | {nowPlaying.title}
86 |
87 |
88 | {nowPlaying.artist.name}
89 |
90 |
91 |
92 | );
93 | } else {
94 | return (
);
95 | }
96 | }
97 |
98 | updateElapsed() {
99 | if (this.audio !== undefined) {
100 | this.setState({ elapsed: this.audio.currentTime });
101 | }
102 | }
103 |
104 | changeVolume(e) {
105 | this.audio.volume = (e.currentTarget.value);
106 | }
107 |
108 | render() {
109 | return (
110 |
111 |
{ this.audio = audio; } }
114 | src={this.currentTrack().url} autoPlay id="audio-player">
115 |
116 | {this.renderNowPlayingInfo()}
117 |
121 |
122 | volume_up
123 |
124 |
125 |
126 | );
127 | }
128 | }
129 |
130 | export default Playbar;
131 |
--------------------------------------------------------------------------------
/frontend/components/sidebar/sidebar.jsx:
--------------------------------------------------------------------------------
1 | import React from 'react';
2 | import { Link, withRouter } from 'react-router';
3 |
4 | class Sidebar extends React.Component {
5 |
6 | constructor(props) {
7 | super(props);
8 | this.state = { showForm: false, title: "" };
9 | this.handleUpdate = this.handleUpdate.bind(this);
10 | this.handleClick = this.handleClick.bind(this);
11 | this.renderUsername = this.renderUsername.bind(this);
12 | this.togglePlaylistForm = this.togglePlaylistForm.bind(this);
13 | this.handleSubmit = this.handleSubmit.bind(this);
14 | }
15 |
16 | componentWillMount() {
17 | this.props.fetchCurrentUserDetail(this.props.currentUser.id);
18 | }
19 |
20 | handleClick(e) {
21 | e.preventDefault();
22 | this.props.logout().then(() => {
23 | location.reload(true);
24 | });
25 | }
26 |
27 | togglePlaylistForm() {
28 | const newState = this.state.showForm ? false : true;
29 | this.setState({ showForm: newState, title: "" });
30 | this.forceUpdate();
31 | }
32 |
33 | renderUsername() {
34 | if (this.props.currentUser) {
35 | return (
36 | {this.props.currentUser.username}
37 | );
38 | } else {
39 | return
;
40 | }
41 | }
42 |
43 | handleSubmit(e) {
44 | e.preventDefault();
45 | const data = { playlist: { user_id: this.props.currentUser.id, title: this.state.title } };
46 | this.props.createPlaylist(data).then((playlist) => {
47 | this.togglePlaylistForm();
48 | this.props.router.replace(`/playlist/${playlist.id}`);
49 | this.props.fetchCurrentUserDetail(this.props.currentUser.id);
50 | }
51 | );
52 | }
53 |
54 | checkCurrent(linkPath) {
55 | if (this.props.path.includes(linkPath)) { return 'sidebar-selected'; }
56 | }
57 |
58 | showForm() {
59 | return this.state.showForm ? 'playlist-form-show' : 'playlist-form';
60 | }
61 |
62 | handleUpdate(e) {
63 | this.setState({ title: e.currentTarget.value });
64 | }
65 |
66 | renderPlaylists() {
67 | if (this.props.currentUserDetail.playlists) {
68 | return (
69 |
70 | {
71 | this.props.currentUserDetail.playlists.map((playlist) => (
72 |
73 | {playlist.title}
74 |
75 | ))
76 | }
77 |
78 | );
79 | }
80 | }
81 |
82 | render() {
83 | return (
84 |
85 |
86 |
87 |
88 |
SSTify
89 |
90 |
91 | Search
92 | search
93 |
94 |
95 | Browse
96 |
97 |
98 |
99 |
100 | {this.renderPlaylists()}
101 |
102 |
103 |
104 |
105 |
106 |
118 |
119 | control_point
120 | Add Playlist
121 |
122 |
{this.renderUsername()}
123 |
Log Out
124 |
125 |
126 | );
127 | }
128 | }
129 |
130 | export default Sidebar;
131 |
--------------------------------------------------------------------------------
/app/assets/stylesheets/playbar-css.css:
--------------------------------------------------------------------------------
1 | .playbar {
2 | display: flex;
3 | flex-direction: row;
4 | justify-content: space-between;
5 | align-items: center;
6 | }
7 |
8 | .play-controls {
9 | height: 70%;
10 | color: gray;
11 | width: 60%;
12 | display: flex;
13 | flex-direction: column;
14 | align-items: center;
15 | justify-content: space-between;
16 | margin-right: 80px;
17 | }
18 |
19 | .play-controls i {
20 | font-size: 24px;
21 | }
22 |
23 | .play-controls i:hover {
24 | color: white;
25 | cursor: pointer;
26 | }
27 |
28 | .play-controls i.selected {
29 | color: white;
30 | }
31 |
32 | .play-scroll-bar {
33 | display: flex;
34 | align-items: center;
35 | justify-content: space-between;
36 | }
37 |
38 | .play-scroll-bar span {
39 | font-size: 12px;
40 | }
41 |
42 | .play-scroll-bar progress {
43 | margin: 0px 10px 0px 10px;
44 | width: 500px;
45 | height: 5px;
46 | -webkit-appearance: none;
47 | -moz-appearance: none;
48 | appearance: none;
49 | }
50 |
51 | .play-scroll-bar progress:hover {
52 | cursor: pointer;
53 | }
54 |
55 | progress::-webkit-progress-bar {
56 | background: gray;
57 | border-radius: 100px;
58 | }
59 |
60 | progress::-moz-progress-bar {
61 | background: gray;
62 | border-radius: 100px;
63 | }
64 |
65 | progress::-webkit-progress-value {
66 | background: white;
67 | border-radius: 100px;
68 | }
69 |
70 | progress::-moz-progress-value {
71 | background: white;
72 | border-radius: 100px;
73 | }
74 |
75 | .control-buttons {
76 | display: flex;
77 | flex-direction: row;
78 | align-items: center;
79 | width: 33%;
80 | justify-content: space-between;
81 | }
82 |
83 | .play-icon {
84 | size: 24px;
85 | }
86 |
87 | .hidden {
88 | display: none;
89 | }
90 |
91 | .volume-control {
92 | margin-right: 40px;
93 | width: 15%;
94 | display: flex;
95 | align-items: center;
96 | justify-content: space-between;
97 | }
98 |
99 | .volume-control i {
100 | size: 24px;
101 | color: gray;
102 | }
103 |
104 | #audio-play-button {
105 | font-size: 36px;
106 | }
107 |
108 | #audio-pause-button {
109 | font-size: 36px;
110 | }
111 |
112 | .now-playing-info {
113 | display: flex;
114 | margin-left: 20px;
115 | width: 300px;
116 | overflow: hidden;
117 | border: 1px solid #353131;
118 | }
119 |
120 | .now-playing-spans {
121 | margin-left: 20px;
122 | display: flex;
123 | justify-content: center;
124 | flex-direction: column;
125 | }
126 |
127 | .now-playing-spans span:first-child {
128 | font-size: 14px;
129 | margin-bottom: 10px;
130 | }
131 |
132 | .now-playing-spans span:first-child {
133 | font-size: 14px;
134 | margin-bottom: 10px;
135 | }
136 |
137 | .now-playing-spans span:last-child {
138 | font-size: 12px;
139 | color: gray;
140 | font-family: 'Muli';
141 | }
142 |
143 | .now-playing-spans span:hover {
144 | text-decoration: underline;
145 | color: white;
146 | }
147 |
148 | .now-playing-info img {
149 | height: 60px;
150 | width: 60px;
151 | }
152 |
153 | input[type=range] {
154 | -webkit-appearance: none;
155 | width: 80%;
156 | margin: 3.95px 0;
157 | }
158 | input[type=range]:focus {
159 | outline: none;
160 | }
161 | input[type=range]::-webkit-slider-runnable-track {
162 | width: 100%;
163 | height: 2.1px;
164 | cursor: pointer;
165 | box-shadow: 0px 0px 0px #000000, 0px 0px 0px #0d0d0d;
166 | background: rgba(19, 16, 16, 0.59);
167 | border-radius: 0px;
168 | border: 0px solid rgba(1, 1, 1, 0);
169 | }
170 | input[type=range]::-webkit-slider-thumb {
171 | box-shadow: 0px 0px 0px rgba(0, 0, 0, 0), 0px 0px 0px rgba(13, 13, 13, 0);
172 | border: 0px solid rgba(0, 0, 0, 0);
173 | height: 10px;
174 | width: 10px;
175 | border-radius: 50px;
176 | background: #d0d3da;
177 | cursor: pointer;
178 | -webkit-appearance: none;
179 | margin-top: -3.95px;
180 | }
181 | input[type=range]:focus::-webkit-slider-runnable-track {
182 | background: rgba(33, 28, 28, 0.59);
183 | }
184 | input[type=range]::-moz-range-track {
185 | width: 100%;
186 | height: 2.1px;
187 | cursor: pointer;
188 | box-shadow: 0px 0px 0px #000000, 0px 0px 0px #0d0d0d;
189 | background: rgba(19, 16, 16, 0.59);
190 | border-radius: 0px;
191 | border: 0px solid rgba(1, 1, 1, 0);
192 | }
193 | input[type=range]::-moz-range-thumb {
194 | box-shadow: 0px 0px 0px rgba(0, 0, 0, 0), 0px 0px 0px rgba(13, 13, 13, 0);
195 | border: 0px solid rgba(0, 0, 0, 0);
196 | height: 10px;
197 | width: 10px;
198 | border-radius: 50px;
199 | background: #d0d3da;
200 | cursor: pointer;
201 | }
202 | input[type=range]::-ms-track {
203 | width: 100%;
204 | height: 2.1px;
205 | cursor: pointer;
206 | background: transparent;
207 | border-color: transparent;
208 | color: transparent;
209 | }
210 | input[type=range]::-ms-fill-lower {
211 | background: rgba(5, 4, 4, 0.59);
212 | border: 0px solid rgba(1, 1, 1, 0);
213 | border-radius: 0px;
214 | box-shadow: 0px 0px 0px #000000, 0px 0px 0px #0d0d0d;
215 | }
216 | input[type=range]::-ms-fill-upper {
217 | background: rgba(19, 16, 16, 0.59);
218 | border: 0px solid rgba(1, 1, 1, 0);
219 | border-radius: 0px;
220 | box-shadow: 0px 0px 0px #000000, 0px 0px 0px #0d0d0d;
221 | }
222 | input[type=range]::-ms-thumb {
223 | box-shadow: 0px 0px 0px rgba(0, 0, 0, 0), 0px 0px 0px rgba(13, 13, 13, 0);
224 | border: 0px solid rgba(0, 0, 0, 0);
225 | height: 10px;
226 | width: 10px;
227 | border-radius: 50px;
228 | background: #d0d3da;
229 | cursor: pointer;
230 | height: 2.1px;
231 | }
232 | input[type=range]:focus::-ms-fill-lower {
233 | background: rgba(19, 16, 16, 0.59);
234 | }
235 | input[type=range]:focus::-ms-fill-upper {
236 | background: rgba(33, 28, 28, 0.59);
237 | }
238 |
--------------------------------------------------------------------------------
/app/assets/stylesheets/main-css.css:
--------------------------------------------------------------------------------
1 | .main-container {
2 | font-family: 'Montserrat', sans-serif;
3 | position: fixed;
4 | top: 0;
5 | left: 0;
6 | bottom: 0;
7 | right: 0;
8 | background-size: 100%;
9 | height: 100%;
10 | display: flex;
11 | align-items: space-between;
12 | flex-direction: column;
13 | }
14 |
15 | .top-side-elements {
16 | flex: auto;
17 | display: flex;
18 | flex-direction: row;
19 | overflow: auto;
20 | height: calc(100% - 90px);
21 | width: 100%;
22 | }
23 |
24 | .bottom-side-elements {
25 | flex: auto;
26 | height: 90px;
27 | width: 100%;
28 | background-color: #353131;
29 | z-index: 200;
30 | }
31 |
32 | .sidebar {
33 | height: calc(100% - 90px);
34 | width: 220px;
35 | position: fixed;
36 | background: linear-gradient(#161616, black);
37 | color: gray;
38 | display: flex;
39 | flex-direction: column;
40 | align-items: center;
41 | justify-content: space-between;
42 | flex: auto;
43 | }
44 |
45 | .sidebar > div {
46 | width: 80%
47 | }
48 |
49 | .sidebar-content {
50 | margin-top: 20px;
51 | display: flex;
52 | flex-direction: column;
53 | align-items: flex-start;
54 | justify-content: space-between;
55 | width: 100%;
56 | }
57 |
58 | .sidebar-content > span {
59 | margin-bottom: 15px;
60 | }
61 |
62 | .sidebar-middle {
63 | margin: 15px 0px 15px 0px;
64 | }
65 |
66 | .sidebar-middle ul {
67 | height: 50%;
68 | overflow: auto;
69 | margin-top: 15px;
70 | }
71 |
72 | .sidebar-middle ul li {
73 | margin-bottom: 10px;
74 | }
75 |
76 | .sidebar-search {
77 | width: 100%;
78 | height: 100%;
79 | padding-top: 15px;
80 | padding-bottom: 15px;
81 | border-top: 1px solid gray;
82 | border-bottom: 1px solid gray;
83 | }
84 |
85 | .playbar {
86 | height: 100%;
87 | color: white;
88 | }
89 |
90 | .content-box {
91 | margin-left: 220px;
92 | width: calc(100% - 220px);
93 | height: 100%;
94 | top: 0;
95 | left: 0;
96 | bottom: 0;
97 | right: 0;
98 | overflow: auto;
99 | background: linear-gradient(#544848, black);
100 | background-size: cover;
101 | color: white;
102 | flex: auto;
103 | }
104 |
105 | .sidebar-logo {
106 | display: flex;
107 | justify-content: center;
108 | align-items: center;
109 | margin-top: 10px;
110 | margin-bottom: 15px;
111 | }
112 |
113 | .sidebar-logo img {
114 | height: 35px;
115 | width: 35px;
116 | }
117 |
118 | .sidebar a:hover {
119 | color: white;
120 | }
121 |
122 | .sidebar-logo span {
123 | margin-left: 15px;
124 | color: #1aff66;
125 | font-size: 24px;
126 | }
127 |
128 | .sidebar-selected {
129 | color: #1aff66;
130 | }
131 |
132 | .playlist-add-button {
133 | background: none;
134 | color: #d7dbe2;
135 | border: none;
136 | }
137 |
138 | .sidebar-search {
139 | margin-bottom: 15px;
140 | }
141 |
142 | .sidebar-search a {
143 | display: flex;
144 | align-items: center;
145 | justify-content: space-between;
146 | }
147 |
148 | .create-playlist {
149 | display: flex;
150 | align-items: center;
151 | padding-bottom: 10px;
152 | padding-top: 10px;
153 | border-top: 1px solid gray;
154 | border-bottom: 1px solid gray;
155 | cursor: pointer;
156 | }
157 |
158 | .create-playlist span {
159 | margin-left: 10px;
160 | position: relative;
161 | top: 1px;
162 | }
163 |
164 | .sidebar-bottom {
165 | height: 150px;
166 | display: flex;
167 | flex-direction: column;
168 | justify-content: space-between;
169 | }
170 |
171 | .sidebar-top {
172 | width: 100%;
173 | height: 70%;
174 | }
175 |
176 | .browse-link {
177 | width: 100%;
178 | padding-bottom: 15px;
179 | border-bottom: 1px solid gray;
180 | }
181 |
182 | .sidebar-top > span {
183 | width: 100%;
184 | border-bottom: 1px solid gray;
185 | }
186 |
187 | .log-out-button {
188 | background-color: Transparent;
189 | color: gray;
190 | border-radius: 50px;
191 | width: 100%;
192 | font-family: 'Montserrat', sans-serif;
193 | padding: 10px;
194 | font-size: 12px;
195 | cursor: pointer;
196 | margin: 0px 0px 10px 0px;
197 | }
198 |
199 | .create-playlist:hover {
200 | color: white;
201 | }
202 |
203 | .sidebar-bottom > span {
204 | align-self: center;
205 | }
206 |
207 | #playlist-form > span {
208 | width: 85%;
209 | height: 12px;
210 | padding: 10px 10px 10px 0px;
211 | color: #1aff66;
212 | border-bottom: 1px solid gray;
213 | }
214 |
215 | .button-row {
216 | width: 90%;
217 | height: 35px;
218 | display: flex;
219 | justify-content: space-between;
220 | }
221 |
222 | .button-row > button:first-child {
223 | color: #1aff66;
224 | width: 45%;
225 | border: 1px solid #1aff66;
226 | background: transparent;
227 | font-size: 16px;
228 | border-radius: 50px;
229 | font-family: 'Montserrat';
230 | }
231 |
232 | .button-row > button:last-child {
233 | color: black;
234 | width: 45%;
235 | border: 1px solid #1aff66;
236 | background: #1aff66;
237 | font-size: 16px;
238 | border-radius: 50px;
239 | font-family: 'Montserrat';
240 | }
241 |
242 | .playlist-form {
243 | display: none;
244 | }
245 |
246 | .playlist-form-show {
247 | border: 1px solid black;
248 | display: flex;
249 | flex-direction: column;
250 | justify-content: space-between;
251 | align-items: center;
252 | bottom: 90px;
253 | left: 220px;
254 | position: fixed;
255 | height: 150px;
256 | width: 250px;
257 | background-color: black;
258 | }
259 |
260 | .playlist-title-input {
261 | background: transparent;
262 | border: none;
263 | font-size: 12px;
264 | color: white;
265 | width: 85%;
266 | border-bottom: 1px solid white;
267 | }
268 |
269 | .button-row {
270 | display: flex;
271 | }
272 |
273 |
274 | /*scrollbar*/
275 | .sidebar-middle ul::-webkit-scrollbar {
276 | width: 6px;
277 | height: 6px;
278 | }
279 | .sidebar-middle ul::-webkit-scrollbar-button {
280 | width: 0px;
281 | height: 0px;
282 | }
283 | .sidebar-middle ul::-webkit-scrollbar-thumb {
284 | background: #404040;
285 | border: 0px none #ffffff;
286 | border-radius: 50px;
287 | }
288 | .sidebar-middle ul::-webkit-scrollbar-track {
289 | background: #000000;
290 | border: 0px none #ffffff;
291 | border-radius: 50px;
292 | }
293 | .sidebar-middle ul::-webkit-scrollbar-corner {
294 | background: transparent;
295 | }
296 |
--------------------------------------------------------------------------------
/Gemfile.lock:
--------------------------------------------------------------------------------
1 | GEM
2 | remote: https://rubygems.org/
3 | specs:
4 | actioncable (5.0.2)
5 | actionpack (= 5.0.2)
6 | nio4r (>= 1.2, < 3.0)
7 | websocket-driver (~> 0.6.1)
8 | actionmailer (5.0.2)
9 | actionpack (= 5.0.2)
10 | actionview (= 5.0.2)
11 | activejob (= 5.0.2)
12 | mail (~> 2.5, >= 2.5.4)
13 | rails-dom-testing (~> 2.0)
14 | actionpack (5.0.2)
15 | actionview (= 5.0.2)
16 | activesupport (= 5.0.2)
17 | rack (~> 2.0)
18 | rack-test (~> 0.6.3)
19 | rails-dom-testing (~> 2.0)
20 | rails-html-sanitizer (~> 1.0, >= 1.0.2)
21 | actionview (5.0.2)
22 | activesupport (= 5.0.2)
23 | builder (~> 3.1)
24 | erubis (~> 2.7.0)
25 | rails-dom-testing (~> 2.0)
26 | rails-html-sanitizer (~> 1.0, >= 1.0.3)
27 | activejob (5.0.2)
28 | activesupport (= 5.0.2)
29 | globalid (>= 0.3.6)
30 | activemodel (5.0.2)
31 | activesupport (= 5.0.2)
32 | activerecord (5.0.2)
33 | activemodel (= 5.0.2)
34 | activesupport (= 5.0.2)
35 | arel (~> 7.0)
36 | activesupport (5.0.2)
37 | concurrent-ruby (~> 1.0, >= 1.0.2)
38 | i18n (~> 0.7)
39 | minitest (~> 5.1)
40 | tzinfo (~> 1.1)
41 | arel (7.1.4)
42 | aws-sdk (2.9.9)
43 | aws-sdk-resources (= 2.9.9)
44 | aws-sdk-core (2.9.9)
45 | aws-sigv4 (~> 1.0)
46 | jmespath (~> 1.0)
47 | aws-sdk-resources (2.9.9)
48 | aws-sdk-core (= 2.9.9)
49 | aws-sigv4 (1.0.0)
50 | bcrypt (3.1.11)
51 | bindex (0.5.0)
52 | builder (3.2.3)
53 | byebug (9.0.6)
54 | climate_control (0.2.0)
55 | cocaine (0.5.8)
56 | climate_control (>= 0.0.3, < 1.0)
57 | coderay (1.1.1)
58 | coffee-rails (4.2.1)
59 | coffee-script (>= 2.2.0)
60 | railties (>= 4.0.0, < 5.2.x)
61 | coffee-script (2.4.1)
62 | coffee-script-source
63 | execjs
64 | coffee-script-source (1.12.2)
65 | concurrent-ruby (1.0.5)
66 | crass (1.0.3)
67 | erubis (2.7.0)
68 | execjs (2.7.0)
69 | ffi (1.9.18)
70 | figaro (1.1.1)
71 | thor (~> 0.14)
72 | globalid (0.3.7)
73 | activesupport (>= 4.1.0)
74 | i18n (0.8.1)
75 | jbuilder (2.6.3)
76 | activesupport (>= 3.0.0, < 5.2)
77 | multi_json (~> 1.2)
78 | jmespath (1.3.1)
79 | jquery-rails (4.3.1)
80 | rails-dom-testing (>= 1, < 3)
81 | railties (>= 4.2.0)
82 | thor (>= 0.14, < 2.0)
83 | js_assets (0.1.2)
84 | rails
85 | sprockets (>= 3.0)
86 | listen (3.0.8)
87 | rb-fsevent (~> 0.9, >= 0.9.4)
88 | rb-inotify (~> 0.9, >= 0.9.7)
89 | loofah (2.2.1)
90 | crass (~> 1.0.2)
91 | nokogiri (>= 1.5.9)
92 | mail (2.6.4)
93 | mime-types (>= 1.16, < 4)
94 | method_source (0.8.2)
95 | mime-types (3.1)
96 | mime-types-data (~> 3.2015)
97 | mime-types-data (3.2016.0521)
98 | mimemagic (0.3.2)
99 | mini_portile2 (2.3.0)
100 | minitest (5.10.1)
101 | multi_json (1.12.1)
102 | nio4r (2.0.0)
103 | nokogiri (1.8.2)
104 | mini_portile2 (~> 2.3.0)
105 | paperclip (5.2.1)
106 | activemodel (>= 4.2.0)
107 | activesupport (>= 4.2.0)
108 | cocaine (~> 0.5.5)
109 | mime-types
110 | mimemagic (~> 0.3.0)
111 | pg (0.20.0)
112 | pry (0.10.4)
113 | coderay (~> 1.1.0)
114 | method_source (~> 0.8.1)
115 | slop (~> 3.4)
116 | pry-rails (0.3.6)
117 | pry (>= 0.10.4)
118 | puma (3.8.2)
119 | rack (2.0.1)
120 | rack-test (0.6.3)
121 | rack (>= 1.0)
122 | rails (5.0.2)
123 | actioncable (= 5.0.2)
124 | actionmailer (= 5.0.2)
125 | actionpack (= 5.0.2)
126 | actionview (= 5.0.2)
127 | activejob (= 5.0.2)
128 | activemodel (= 5.0.2)
129 | activerecord (= 5.0.2)
130 | activesupport (= 5.0.2)
131 | bundler (>= 1.3.0, < 2.0)
132 | railties (= 5.0.2)
133 | sprockets-rails (>= 2.0.0)
134 | rails-dom-testing (2.0.2)
135 | activesupport (>= 4.2.0, < 6.0)
136 | nokogiri (~> 1.6)
137 | rails-html-sanitizer (1.0.3)
138 | loofah (~> 2.0)
139 | rails_12factor (0.0.3)
140 | rails_serve_static_assets
141 | rails_stdout_logging
142 | rails_serve_static_assets (0.0.5)
143 | rails_stdout_logging (0.0.5)
144 | railties (5.0.2)
145 | actionpack (= 5.0.2)
146 | activesupport (= 5.0.2)
147 | method_source
148 | rake (>= 0.8.7)
149 | thor (>= 0.18.1, < 2.0)
150 | rake (12.0.0)
151 | rb-fsevent (0.9.8)
152 | rb-inotify (0.9.8)
153 | ffi (>= 0.5.0)
154 | ruby-mp3info (0.8.10)
155 | sass (3.4.23)
156 | sass-rails (5.0.6)
157 | railties (>= 4.0.0, < 6)
158 | sass (~> 3.1)
159 | sprockets (>= 2.8, < 4.0)
160 | sprockets-rails (>= 2.0, < 4.0)
161 | tilt (>= 1.1, < 3)
162 | slop (3.6.0)
163 | spring (2.0.1)
164 | activesupport (>= 4.2)
165 | spring-watcher-listen (2.0.1)
166 | listen (>= 2.7, < 4.0)
167 | spring (>= 1.2, < 3.0)
168 | sprockets (3.7.1)
169 | concurrent-ruby (~> 1.0)
170 | rack (> 1, < 3)
171 | sprockets-rails (3.2.0)
172 | actionpack (>= 4.0)
173 | activesupport (>= 4.0)
174 | sprockets (>= 3.0.0)
175 | thor (0.19.4)
176 | thread_safe (0.3.6)
177 | tilt (2.0.7)
178 | tzinfo (1.2.3)
179 | thread_safe (~> 0.1)
180 | uglifier (3.2.0)
181 | execjs (>= 0.3.0, < 3)
182 | web-console (3.5.0)
183 | actionview (>= 5.0)
184 | activemodel (>= 5.0)
185 | bindex (>= 0.4.0)
186 | railties (>= 5.0)
187 | websocket-driver (0.6.5)
188 | websocket-extensions (>= 0.1.0)
189 | websocket-extensions (0.1.2)
190 |
191 | PLATFORMS
192 | ruby
193 |
194 | DEPENDENCIES
195 | aws-sdk (>= 2.0)
196 | bcrypt
197 | byebug
198 | coffee-rails (~> 4.2)
199 | figaro
200 | jbuilder (~> 2.5)
201 | jquery-rails
202 | js_assets
203 | listen (~> 3.0.5)
204 | paperclip (~> 5.2.0)
205 | pg (~> 0.18)
206 | pry-rails
207 | puma (~> 3.0)
208 | rails (~> 5.0.2)
209 | rails_12factor
210 | ruby-mp3info
211 | sass-rails (~> 5.0)
212 | spring
213 | spring-watcher-listen (~> 2.0.0)
214 | tzinfo-data
215 | uglifier (>= 1.3.0)
216 | web-console (>= 3.3.0)
217 |
218 | BUNDLED WITH
219 | 1.16.1
220 |
--------------------------------------------------------------------------------
/app/assets/stylesheets/album-detail.css:
--------------------------------------------------------------------------------
1 | .album-detail {
2 | display: flex;
3 | align-items: space-between;
4 | }
5 |
6 | .album-detail .left-side {
7 | display: flex;
8 | flex-direction: column;
9 | align-items: center;
10 | justify-content: space-around;
11 | width: 30%;
12 | height: 100%;
13 | flex: 1;
14 | }
15 |
16 | .album-detail .track-list {
17 | width: 70%;
18 | }
19 |
20 | .album-detail .left-side img {
21 | margin-top: 30px;
22 | margin-bottom: 20px;
23 | size: 100%;
24 | align-self: center;
25 | }
26 |
27 | .album-detail .left-side .play-album-button {
28 | width: 25%;
29 | border-radius: 50px;
30 | font-family: 'VegurRegular';
31 | color: white;
32 | background-color: #40ad59;
33 | border: none;
34 | padding-top: 15px;
35 | padding-bottom: 15px;
36 | font-size: 14px;
37 | margin-top: 10px;
38 | margin-bottom: 10px;
39 | letter-spacing: 3px;
40 | }
41 |
42 | .album-detail .left-side .playlist-add-button {
43 | background: none;
44 | color: #d7dbe2;
45 | border: none;
46 | font-size: 16px;
47 | flex: 1;
48 | }
49 |
50 | .album-detail .track-list {
51 | margin-top: 50px;
52 | }
53 |
54 | .album-detail .track-list ol {
55 | font-family: 'Muli';
56 | color: #d7dbe2;
57 | font-size: 20px;
58 | }
59 |
60 | .album-detail .track-list li {
61 | -webkit-user-select: none;
62 | -khtml-user-select: none;
63 | -moz-user-select: none;
64 | -ms-user-select: none;
65 | -o-user-select: none;
66 | user-select: none;
67 | margin-right: 20px;
68 | counter-increment: count-me;
69 | padding: 20px;
70 | display: flex;
71 | justify-content: space-between;
72 | }
73 |
74 | .album-detail .track-list li:hover {
75 | cursor: default;
76 | background: rgba(0, 0, 0, 0.3);
77 | }
78 |
79 | .track-list-right-side {
80 | display: flex;
81 | justify-content: space-between;
82 | }
83 |
84 | .track-list-right-side .track-playlist-add-button {
85 | margin-right: 70px;
86 | bottom: 5px;
87 | display: none;
88 | position: relative;
89 | background: none;
90 | color: #d7dbe2;
91 | border: none;
92 | font-size: 16px;
93 | }
94 |
95 | .track-list li:hover .track-playlist-add-button {
96 | display: inline;
97 | }
98 |
99 | .album-detail .play-pause-button {
100 | background: none;
101 | border: none;
102 | padding-right: 10px;
103 | color: gray;
104 | font-size: 18px;
105 | right: 5px;
106 | position: relative;
107 | }
108 |
109 | .track-list li:hover .track-num {
110 | display: none;
111 | }
112 |
113 | .track-list li:hover .play-button:before {
114 | content: '►';
115 | }
116 |
117 | .album-detail .track-list li:hover .play-pause-button {
118 | content: '►';
119 | }
120 |
121 | #album-title {
122 | font-size: 24px;
123 | padding-bottom: 10px;
124 | }
125 |
126 | #album-info {
127 | font-size: 16px;
128 | color: gray;
129 | font-family: 'VegurRegular';
130 | font-weight: lighter;
131 | padding-bottom: 10px;
132 | }
133 |
134 | #album-info a {
135 | text-decoration: none;
136 | color: gray;
137 | }
138 |
139 | #album-info a:hover {
140 | text-decoration: underline;
141 | }
142 |
143 | @media all and (max-width: 700px) {
144 | .album-detail .left-side { display: none; }
145 | }
146 |
147 | .album-playlist-add {
148 | display: none;
149 | }
150 |
151 | .album-playlist-add-show {
152 | border-radius: 3px;
153 | display: flex;
154 | justify-content: center;
155 | color: gray;
156 | height: 150px;
157 | width: 250px;
158 | background-color: #353131;
159 | margin-bottom: 10px;
160 | }
161 |
162 | .album-playlist-add-show div {
163 | display: flex;
164 | flex-wrap: wrap;
165 | flex-direction: row;
166 | justify-content: center;
167 | align-items: center;
168 | height: 100%;
169 | width: 100%;
170 | font-size: 12px;
171 | overflow: auto;
172 | }
173 |
174 | .album-playlist-add-show span {
175 | padding: 5px;
176 | }
177 |
178 | .album-playlist-add-show ul {
179 | align-self: center;
180 | display: flex;
181 | flex-direction: column;
182 | align-items: flex-start;
183 | height: 90%;
184 | justify-content: flex-start;
185 | width: 90%;
186 | }
187 |
188 | .album-playlist-add-show li {
189 | align-self: center;
190 | padding: 5px;
191 | width: 95%;
192 | cursor: pointer;
193 | }
194 |
195 | .album-playlist-add-show li:hover {
196 | background-color: rgba(0, 0, 0, 0.3);
197 | }
198 |
199 | .track-playlist-add {
200 | position: fixed;
201 | display: flex;
202 | right: 210px;
203 | bottom: 400px;
204 | border-radius: 3px;
205 | color: gray;
206 | height: 150px;
207 | width: 250px;
208 | background-color: #353131;
209 | margin-bottom: 10px;
210 | justify-content: flex-start;
211 | align-items: center;
212 | font-family: 'Montserrat';
213 | font-size: 12px;
214 | flex-direction: column;
215 | overflow: auto;
216 | }
217 |
218 | .track-playlist-add span {
219 | padding: 5px;
220 | }
221 |
222 | .track-list .track-playlist-add ul {
223 | display: flex;
224 | align-items: flex-start;
225 | justify-content: flex-start;
226 | flex-direction: column;
227 | height: 90%;
228 | width: 90%;
229 | }
230 |
231 |
232 | .track-list .track-playlist-add ul li {
233 | width: 90%;
234 | padding: 10px;
235 | margin: 0px;
236 | cursor: pointer;
237 | }
238 |
239 | .track-list .track-playlist-add ul li:hover {
240 | background-color: rgba(0, 0, 0, 0.3);
241 | cursor: pointer;
242 | }
243 |
244 | .album-detail .track-list-left-side {
245 | display: flex;
246 | align-items: center;
247 | }
248 |
249 | /*scrollbar*/
250 | .track-playlist-add::-webkit-scrollbar {
251 | width: 6px;
252 | height: 6px;
253 | }
254 | .track-playlist-add::-webkit-scrollbar-button {
255 | width: 0px;
256 | height: 0px;
257 | }
258 | .track-playlist-add::-webkit-scrollbar-thumb {
259 | background: #404040;
260 | border: 0px none #ffffff;
261 | border-radius: 50px;
262 | }
263 | .track-playlist-add::-webkit-scrollbar-track {
264 | background: #000000;
265 | border: 0px none #ffffff;
266 | border-radius: 50px;
267 | }
268 | .track-playlist-add::-webkit-scrollbar-corner {
269 | background: transparent;
270 | }
271 |
272 | .album-playlist-add-show div::-webkit-scrollbar {
273 | width: 6px;
274 | height: 6px;
275 | }
276 | .album-playlist-add-show div::-webkit-scrollbar-button {
277 | width: 0px;
278 | height: 0px;
279 | }
280 | .album-playlist-add-show div::-webkit-scrollbar-thumb {
281 | background: #404040;
282 | border: 0px none #ffffff;
283 | border-radius: 50px;
284 | }
285 | .album-playlist-add-show div::-webkit-scrollbar-track {
286 | background: #000000;
287 | border: 0px none #ffffff;
288 | border-radius: 50px;
289 | }
290 | .album-playlist-add-show div::-webkit-scrollbar-corner {
291 | background: transparent;
292 | }
293 |
--------------------------------------------------------------------------------