├── 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 |
23 | 24 |
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 | 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 | 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 | 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 | 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 | 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 | 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 | 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 | 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 | 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 | 32 |
33 | ); 34 | } 35 | 36 | renderAlbums(artist) { 37 | return ( 38 |
39 | 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 | 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 |
  1. this.handleTrackButton(i)}> 31 |
    32 |
    33 | 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 |
  2. ) 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 |
  1. this.handleTrackButton(i)}> 32 |
    33 |
    34 | 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 |
  2. ) 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 | 43 |
44 | ); 45 | } 46 | } 47 | 48 | render() { 49 | return ( 50 |
51 |
    52 | { 53 | this.props.album.tracks.map((track, i) => 54 |
  1. this.handleTrackButton(i)}> 55 |
    56 | 60 | {track.title} 61 |
    62 |
    63 | 65 | {this.renderLength(track)} 66 | {this.renderTrackDropdown(track, i+1)} 67 |
    68 |
  2. ) 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 | ![image of playbar](./docs/screenshots/playbar.png) 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 | ![image of playlist](./docs/screenshots/playlist.png) 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 | ![image of search](./docs/screenshots/search.png) 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 | 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 | 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 | 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 | 36 | ); 37 | } else { 38 | return ( 39 | 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 | 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 ; 65 | } 66 | } 67 | 68 | renderErrors() { 69 | return( 70 | 77 | ); 78 | } 79 | 80 | render() { 81 | return ( 82 |
83 |
84 |
85 | SSTify 86 |
87 | {this.renderErrors()} 88 |
89 |
90 | 97 |
98 | 105 |
106 | 107 |
108 | {this.renderGuestLogin()} 109 | {this.navLink()} 110 |
111 |
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 | 116 | {this.renderNowPlayingInfo()} 117 |
118 | 119 | 120 |
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 | 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 | Playlists 100 | {this.renderPlaylists()} 101 |
102 |
103 | 104 | 105 |
106 |
107 | CREATE NEW PLAYLIST 108 | 113 |
114 | 115 | 116 |
117 |
118 |
119 | control_point 120 | Add Playlist 121 |
122 | {this.renderUsername()} 123 | 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 | --------------------------------------------------------------------------------