├── public ├── js │ ├── tag.js │ ├── album.js │ ├── artist.js │ ├── meow.js │ ├── alert.js │ ├── modal.js │ ├── share.js │ ├── shuffle.js │ ├── radio.js │ ├── error.js │ ├── welcome.js │ ├── boot.js │ ├── session.js │ ├── mixins.js │ ├── keys.js │ ├── track.js │ └── user.js ├── chart │ └── .gitignore ├── favicon.ico ├── font │ ├── baumans.woff │ ├── fontawesome-webfont.eot │ ├── fontawesome-webfont.ttf │ └── fontawesome-webfont.woff ├── img │ ├── jukesy-16.png │ ├── jukesy-32.png │ ├── jukesy-64.png │ ├── jukesy-128.png │ ├── jukesy-256.png │ ├── jukesy-512.png │ ├── jukesy-play.png │ ├── lastfm_grey_small.gif │ └── lastfm_red_small.gif └── less │ ├── jukesy │ ├── welcome.less │ ├── include.less │ ├── playlist.less │ ├── spinner.less │ ├── controls.less │ ├── results.less │ └── app.less │ └── bootstrap │ ├── grid.less │ ├── utilities.less │ ├── component-animations.less │ ├── close.less │ ├── wells.less │ ├── hero-unit.less │ ├── layouts.less │ ├── breadcrumbs.less │ ├── pager.less │ ├── accordion.less │ ├── scaffolding.less │ ├── thumbnails.less │ ├── tooltip.less │ ├── labels.less │ ├── badges.less │ ├── pagination.less │ ├── popovers.less │ ├── code.less │ ├── alerts.less │ ├── bootstrap.less │ ├── modals.less │ ├── progress-bars.less │ ├── carousel.less │ ├── reset.less │ ├── variables.less │ ├── dropdowns.less │ ├── tables.less │ ├── button-groups.less │ ├── type.less │ └── buttons.less ├── config ├── .gitignore ├── pepper.example.js ├── mongodb.example.js ├── index.js ├── assets.js ├── boot.js └── express.js ├── app ├── views │ ├── home │ │ ├── privacy_policy.jade │ │ ├── terms_of_service.jade │ │ ├── now_playing.jade │ │ ├── about.jade │ │ └── welcome.jade │ ├── layout │ │ ├── alert.jade │ │ ├── search_artist_similar.jade │ │ ├── search_artist_top_albums.jade │ │ ├── search_artist_top_tracks.jade │ │ ├── search_query_album.jade │ │ ├── search_query_artist.jade │ │ ├── search_query_track.jade │ │ ├── search_track.jade │ │ ├── search_album.jade │ │ ├── track_info.jade │ │ ├── search_result_artist.jade │ │ ├── search_artist.jade │ │ ├── search_query.jade │ │ ├── search_result_album.jade │ │ ├── header.jade │ │ ├── footer.jade │ │ ├── share.jade │ │ ├── session_button.jade │ │ ├── track.jade │ │ ├── search_results_albums.jade │ │ ├── search_results_artists.jade │ │ ├── search_result_track.jade │ │ ├── controls.jade │ │ ├── sidebar.jade │ │ ├── search_results_tracks.jade │ │ ├── keyboard_shortcuts.jade │ │ └── meta.jade │ ├── 401.jade │ ├── 404.jade │ ├── 500.jade │ ├── playlist │ │ ├── add.jade │ │ ├── destroy.jade │ │ ├── index.jade │ │ └── show.jade │ ├── user │ │ ├── show.jade │ │ ├── reset.jade │ │ ├── forgot.jade │ │ ├── new.jade │ │ └── edit.jade │ ├── layout.jade │ ├── session │ │ └── new.jade │ └── _templates.jade ├── controllers │ ├── lastfm_controller.js │ ├── application_controller.js │ ├── home_controller.js │ ├── mail_controller.js │ ├── session_controller.js │ ├── playlist_controller.js │ ├── search_controller.js │ └── user_controller.js └── models │ ├── lastfm_cache.js │ ├── playlist.js │ └── user.js ├── test ├── mocha.opts ├── index.js ├── session_controller.test.js ├── search_controller.test.js ├── playlist.test.js └── mongoose_validators.test.js ├── lib ├── mongoose_setters.js ├── bcrypt.js ├── auth.js ├── error.js ├── mongoose_plugins.js ├── mongoose_validators.js └── lastfm_cache.js ├── .gitignore ├── index.js ├── TODO ├── README.md ├── package.json ├── Makefile ├── jobs └── chart.js └── routes.js /public/js/tag.js: -------------------------------------------------------------------------------- 1 | -------------------------------------------------------------------------------- /config/.gitignore: -------------------------------------------------------------------------------- 1 | mongodb.js 2 | pepper.js 3 | -------------------------------------------------------------------------------- /public/chart/.gitignore: -------------------------------------------------------------------------------- 1 | * 2 | !.gitignore 3 | -------------------------------------------------------------------------------- /app/views/home/privacy_policy.jade: -------------------------------------------------------------------------------- 1 | h1 Privacy Policy -------------------------------------------------------------------------------- /app/views/home/terms_of_service.jade: -------------------------------------------------------------------------------- 1 | h1 Terms of Service -------------------------------------------------------------------------------- /config/pepper.example.js: -------------------------------------------------------------------------------- 1 | module.exports = 'pepper' 2 | 3 | -------------------------------------------------------------------------------- /public/js/album.js: -------------------------------------------------------------------------------- 1 | Model.Album = Backbone.Model.extend({ 2 | }) 3 | 4 | 5 | ; -------------------------------------------------------------------------------- /public/js/artist.js: -------------------------------------------------------------------------------- 1 | Model.Artist = Backbone.Model.extend({ 2 | }) 3 | 4 | 5 | ; -------------------------------------------------------------------------------- /app/views/layout/alert.jade: -------------------------------------------------------------------------------- 1 | a.close('data-dismiss'= 'alert') × 2 | != message 3 | -------------------------------------------------------------------------------- /test/mocha.opts: -------------------------------------------------------------------------------- 1 | --require test/ 2 | --reporter spec 3 | --ignore-leaks 4 | --ui bdd 5 | -------------------------------------------------------------------------------- /public/favicon.ico: -------------------------------------------------------------------------------- https://raw.githubusercontent.com/adrianbravo/jukesy/HEAD/public/favicon.ico -------------------------------------------------------------------------------- /app/views/401.jade: -------------------------------------------------------------------------------- 1 | div 2 | h1 401 - unauthorized 3 | p you're not authorized to access that 4 | -------------------------------------------------------------------------------- /public/font/baumans.woff: -------------------------------------------------------------------------------- https://raw.githubusercontent.com/adrianbravo/jukesy/HEAD/public/font/baumans.woff -------------------------------------------------------------------------------- /public/img/jukesy-16.png: -------------------------------------------------------------------------------- https://raw.githubusercontent.com/adrianbravo/jukesy/HEAD/public/img/jukesy-16.png -------------------------------------------------------------------------------- /public/img/jukesy-32.png: -------------------------------------------------------------------------------- https://raw.githubusercontent.com/adrianbravo/jukesy/HEAD/public/img/jukesy-32.png -------------------------------------------------------------------------------- /public/img/jukesy-64.png: -------------------------------------------------------------------------------- https://raw.githubusercontent.com/adrianbravo/jukesy/HEAD/public/img/jukesy-64.png -------------------------------------------------------------------------------- /app/views/404.jade: -------------------------------------------------------------------------------- 1 | div 2 | h1 404 - page not found 3 | p we're having trouble finding what you want 4 | -------------------------------------------------------------------------------- /public/img/jukesy-128.png: -------------------------------------------------------------------------------- https://raw.githubusercontent.com/adrianbravo/jukesy/HEAD/public/img/jukesy-128.png -------------------------------------------------------------------------------- /public/img/jukesy-256.png: -------------------------------------------------------------------------------- https://raw.githubusercontent.com/adrianbravo/jukesy/HEAD/public/img/jukesy-256.png -------------------------------------------------------------------------------- /public/img/jukesy-512.png: -------------------------------------------------------------------------------- https://raw.githubusercontent.com/adrianbravo/jukesy/HEAD/public/img/jukesy-512.png -------------------------------------------------------------------------------- /public/img/jukesy-play.png: -------------------------------------------------------------------------------- https://raw.githubusercontent.com/adrianbravo/jukesy/HEAD/public/img/jukesy-play.png -------------------------------------------------------------------------------- /app/views/500.jade: -------------------------------------------------------------------------------- 1 | div 2 | h1 500 - internal error 3 | p something went horribly wrong. what did you do?! 4 | -------------------------------------------------------------------------------- /app/views/layout/search_artist_similar.jade: -------------------------------------------------------------------------------- 1 | #search 2 | h2 #{artist} 3 | 4 | #search-artists 5 | .clear 6 | -------------------------------------------------------------------------------- /app/views/layout/search_artist_top_albums.jade: -------------------------------------------------------------------------------- 1 | #search 2 | h2 #{artist} 3 | 4 | #search-albums 5 | .clear 6 | -------------------------------------------------------------------------------- /app/views/layout/search_artist_top_tracks.jade: -------------------------------------------------------------------------------- 1 | #search 2 | h2 #{artist} 3 | 4 | #search-tracks 5 | .clear 6 | -------------------------------------------------------------------------------- /public/img/lastfm_grey_small.gif: -------------------------------------------------------------------------------- https://raw.githubusercontent.com/adrianbravo/jukesy/HEAD/public/img/lastfm_grey_small.gif -------------------------------------------------------------------------------- /public/img/lastfm_red_small.gif: -------------------------------------------------------------------------------- https://raw.githubusercontent.com/adrianbravo/jukesy/HEAD/public/img/lastfm_red_small.gif -------------------------------------------------------------------------------- /app/views/layout/search_query_album.jade: -------------------------------------------------------------------------------- 1 | #search 2 | h2 Search #{query} 3 | 4 | #search-albums 5 | .clear 6 | -------------------------------------------------------------------------------- /app/views/layout/search_query_artist.jade: -------------------------------------------------------------------------------- 1 | #search 2 | h2 Search #{query} 3 | 4 | #search-artists 5 | .clear 6 | -------------------------------------------------------------------------------- /app/views/layout/search_query_track.jade: -------------------------------------------------------------------------------- 1 | #search 2 | h2 Search #{query} 3 | 4 | #search-tracks 5 | .clear 6 | 7 | -------------------------------------------------------------------------------- /public/font/fontawesome-webfont.eot: -------------------------------------------------------------------------------- https://raw.githubusercontent.com/adrianbravo/jukesy/HEAD/public/font/fontawesome-webfont.eot -------------------------------------------------------------------------------- /public/font/fontawesome-webfont.ttf: -------------------------------------------------------------------------------- https://raw.githubusercontent.com/adrianbravo/jukesy/HEAD/public/font/fontawesome-webfont.ttf -------------------------------------------------------------------------------- /public/font/fontawesome-webfont.woff: -------------------------------------------------------------------------------- https://raw.githubusercontent.com/adrianbravo/jukesy/HEAD/public/font/fontawesome-webfont.woff -------------------------------------------------------------------------------- /app/views/layout/search_track.jade: -------------------------------------------------------------------------------- 1 | #search 2 | h2 #{track} by 3 | a(href= '/artist/#{encodeURIComponent(artist)}') #{artist} 4 | 5 | #search-tracks 6 | .clear -------------------------------------------------------------------------------- /app/views/layout/search_album.jade: -------------------------------------------------------------------------------- 1 | #search 2 | h2 #{album} by 3 | a(href= '/artist/#{encodeURIComponent(artist)}') #{artist} 4 | 5 | #search-tracks 6 | .clear 7 | -------------------------------------------------------------------------------- /lib/mongoose_setters.js: -------------------------------------------------------------------------------- 1 | module.exports = { 2 | 3 | toLower: function(v) { 4 | return (v instanceof String || typeof v == 'string') && v.toLowerCase() 5 | } 6 | 7 | } 8 | -------------------------------------------------------------------------------- /.gitignore: -------------------------------------------------------------------------------- 1 | node_modules 2 | test/coverage/* 3 | test/coverage.html 4 | *.swp 5 | .DS_Store 6 | public/jukesy.css 7 | public/jukesy.min.js 8 | public/jukesy.js 9 | public/index.html 10 | -------------------------------------------------------------------------------- /app/views/layout/track_info.jade: -------------------------------------------------------------------------------- 1 | a(href= urlTrack(track.get('artist'), track.get('name')))= track.get('name') 2 | | by 3 | a(href= urlArtist(track.get('artist')))= track.get('artist') 4 | 5 | -------------------------------------------------------------------------------- /app/views/layout/search_result_artist.jade: -------------------------------------------------------------------------------- 1 | a.thumbnail(href= urlArtist(artist.get('name'))) 2 | .img-wrap 3 | img(src= artist.get('image')) 4 | .overlay.fade 5 | .overlay-inner #{artist.get('name')} -------------------------------------------------------------------------------- /app/views/layout/search_artist.jade: -------------------------------------------------------------------------------- 1 | #search 2 | h2 #{artist} 3 | 4 | #search-tracks 5 | .clear 6 | hr 7 | 8 | #search-albums 9 | .clear 10 | hr 11 | 12 | #search-artists 13 | .clear 14 | -------------------------------------------------------------------------------- /app/views/layout/search_query.jade: -------------------------------------------------------------------------------- 1 | #search 2 | h2 Search #{query} 3 | 4 | #search-tracks 5 | .clear 6 | hr 7 | 8 | #search-albums 9 | .clear 10 | hr 11 | 12 | #search-artists 13 | .clear 14 | -------------------------------------------------------------------------------- /app/views/layout/search_result_album.jade: -------------------------------------------------------------------------------- 1 | a.thumbnail(href= urlAlbum(album.get('artist'), album.get('name'))) 2 | .img-wrap 3 | img(src= album.get('image')) 4 | .overlay.fade 5 | .overlay-inner 6 | .name #{album.get('name')} 7 | .artist #{album.get('artist')} -------------------------------------------------------------------------------- /public/less/jukesy/welcome.less: -------------------------------------------------------------------------------- 1 | #search.welcome { 2 | .hero-unit { 3 | .alpha-warning { 4 | margin-top: 1em; 5 | color: #666; 6 | text-align: center; 7 | .icon-warning-sign { 8 | margin-right: 0.5em; 9 | } 10 | } 11 | } 12 | } 13 | -------------------------------------------------------------------------------- /public/less/bootstrap/grid.less: -------------------------------------------------------------------------------- 1 | // GRID SYSTEM 2 | // ----------- 3 | 4 | // Fixed (940px) 5 | #gridSystem > .generate(@gridColumns, @gridColumnWidth, @gridGutterWidth); 6 | 7 | // Fluid (940px) 8 | #fluidGridSystem > .generate(@gridColumns, @fluidGridColumnWidth, @fluidGridGutterWidth); 9 | -------------------------------------------------------------------------------- /app/views/home/now_playing.jade: -------------------------------------------------------------------------------- 1 | h1 Now Playing 2 | - if (tracks.length) 3 | .tracks 4 | table.playlist.table.table-striped 5 | thead 6 | tr 7 | th.dd 8 | th.play 9 | th Track 10 | th Artist 11 | tbody 12 | - else 13 | p There are no tracks in this playlist. Try searching for something. 14 | -------------------------------------------------------------------------------- /app/views/layout/header.jade: -------------------------------------------------------------------------------- 1 | .navbar-inner 2 | .container 3 | .row 4 | .span2 5 | a#brand.brand(href= '/') jukesy 6 | .span3#query-wrapper 7 | input#query(type= 'text', spellcheck= 'false', placeholder= 'Search for music', name= 'jukesy-query') 8 | i.icon-search 9 | #session-button 10 | include session_button 11 | -------------------------------------------------------------------------------- /public/less/bootstrap/utilities.less: -------------------------------------------------------------------------------- 1 | // UTILITY CLASSES 2 | // --------------- 3 | 4 | // Quick floats 5 | .pull-right { 6 | float: right; 7 | } 8 | .pull-left { 9 | float: left; 10 | } 11 | 12 | // Toggling content 13 | .hide { 14 | display: none; 15 | } 16 | .show { 17 | display: block; 18 | } 19 | 20 | // Visibility 21 | .invisible { 22 | visibility: hidden; 23 | } 24 | -------------------------------------------------------------------------------- /public/less/bootstrap/component-animations.less: -------------------------------------------------------------------------------- 1 | // COMPONENT ANIMATIONS 2 | // -------------------- 3 | 4 | .fade { 5 | .transition(opacity .15s linear); 6 | opacity: 0; 7 | &.in { 8 | opacity: 1; 9 | } 10 | } 11 | 12 | .collapse { 13 | .transition(height .35s ease); 14 | position:relative; 15 | overflow:hidden; 16 | height: 0; 17 | &.in { height: auto; } 18 | } 19 | -------------------------------------------------------------------------------- /app/views/playlist/add.jade: -------------------------------------------------------------------------------- 1 | .modal-header 2 | h3 Add to Playlist 3 | 4 | .modal-body 5 | p Select a playlist 6 | ul.unstyled 7 | each playlist in playlists 8 | li.playlist('data-cid'= playlist.cid) #{playlist.name} 9 | 10 | .modal-footer 11 | p 12 | button.btn.btn-primary.add 13 | i.icon-plus-sign 14 | | Add 15 | button.btn('data-dismiss'= 'modal') Cancel -------------------------------------------------------------------------------- /app/views/layout/footer.jade: -------------------------------------------------------------------------------- 1 | footer 2 | div.pull-left 3 | a.ll(target= '_blank', href= 'http://twitter.com/jukesyapp') Twitter 4 | div.pull-right 5 | a(href= '/about') About 6 | | • 7 | //a(href= '/privacy-policy') Privacy Policy 8 | //| • 9 | //a(href= '/terms-of-service') Terms of Service 10 | //| • 11 | a#keyboard-shortcuts Keyboard Shortcuts 12 | 13 | -------------------------------------------------------------------------------- /app/views/playlist/destroy.jade: -------------------------------------------------------------------------------- 1 | .modal-header 2 | h3 Delete #{playlist.name}? 3 | 4 | .modal-body 5 | 6 | p 7 | | Are you sure you want to delete #{playlist.name}? Think of all the good times. 8 | p 9 | button.btn.btn-large.go-back 10 | i.icon-arrow-left 11 | | Go back 12 | button.btn.btn-large.btn-danger.destroy-confirm.pull-right 13 | i.icon-trash 14 | | Delete it 15 | -------------------------------------------------------------------------------- /public/less/bootstrap/close.less: -------------------------------------------------------------------------------- 1 | // CLOSE ICONS 2 | // ----------- 3 | 4 | .close { 5 | float: right; 6 | font-size: 20px; 7 | font-weight: bold; 8 | line-height: @baseLineHeight; 9 | color: @black; 10 | text-shadow: 0 1px 0 rgba(255,255,255,1); 11 | .opacity(20); 12 | &:hover { 13 | color: @black; 14 | text-decoration: none; 15 | .opacity(40); 16 | cursor: pointer; 17 | } 18 | } 19 | -------------------------------------------------------------------------------- /public/less/bootstrap/wells.less: -------------------------------------------------------------------------------- 1 | // WELLS 2 | // ----- 3 | 4 | .well { 5 | min-height: 20px; 6 | padding: 19px; 7 | margin-bottom: 20px; 8 | background-color: #f5f5f5; 9 | border: 1px solid #eee; 10 | border: 1px solid rgba(0,0,0,.05); 11 | .border-radius(4px); 12 | .box-shadow(inset 0 1px 1px rgba(0,0,0,.05)); 13 | blockquote { 14 | border-color: #ddd; 15 | border-color: rgba(0,0,0,.15); 16 | } 17 | } 18 | -------------------------------------------------------------------------------- /public/less/bootstrap/hero-unit.less: -------------------------------------------------------------------------------- 1 | // HERO UNIT 2 | // --------- 3 | 4 | .hero-unit { 5 | padding: 60px; 6 | margin-bottom: 30px; 7 | background-color: #f5f5f5; 8 | .border-radius(6px); 9 | h1 { 10 | margin-bottom: 0; 11 | font-size: 60px; 12 | line-height: 1; 13 | letter-spacing: -1px; 14 | } 15 | p { 16 | font-size: 18px; 17 | font-weight: 200; 18 | line-height: @baseLineHeight * 1.5; 19 | } 20 | } 21 | -------------------------------------------------------------------------------- /public/less/bootstrap/layouts.less: -------------------------------------------------------------------------------- 1 | // 2 | // Layouts 3 | // Fixed-width and fluid (with sidebar) layouts 4 | // -------------------------------------------- 5 | 6 | 7 | // Container (centered, fixed-width layouts) 8 | .container { 9 | .container-fixed(); 10 | } 11 | 12 | // Fluid layouts (left aligned, with sidebar, min- & max-width content) 13 | .container-fluid { 14 | padding-left: @gridGutterWidth; 15 | padding-right: @gridGutterWidth; 16 | .clearfix(); 17 | } -------------------------------------------------------------------------------- /app/views/home/about.jade: -------------------------------------------------------------------------------- 1 | div 2 | .hero-unit 3 | h1 About 4 | p Jukesy is a music video player and a mash-up of the Last.fm and YouTube APIs. 5 | 6 | h3 Contact 7 | p 8 | | You can reach me by email at 9 | a.ll(href= 'mailto:me@adrianbravo.net') me@adrianbravo.net 10 | 11 | h3 Code 12 | p 13 | | Jukesy can be forked at 14 | a.ll(target= '_blank', href= 'http://github.com/adrianbravo/jukesy') github 15 | | . You'll find that most of the code is javascript. 16 | -------------------------------------------------------------------------------- /app/views/layout/share.jade: -------------------------------------------------------------------------------- 1 | .modal-header 2 | a.close('data-dismiss'= 'modal') × 3 | h3 Share 4 | .modal-body#share 5 | 6 | p 7 | button.btn.share-facebook 8 | i.icon-facebook-sign 9 | | facebook 10 | button.btn.share-twitter 11 | i.icon-twitter-sign 12 | | twitter 13 | 14 | p You can also copy the url manually: 15 | 16 | p 17 | textarea#share-url #{url} 18 | 19 | .modal-footer 20 | button.btn('data-dismiss'= 'modal') 21 | i.icon-arrow-left 22 | | Go back 23 | -------------------------------------------------------------------------------- /public/less/bootstrap/breadcrumbs.less: -------------------------------------------------------------------------------- 1 | // BREADCRUMBS 2 | // ----------- 3 | 4 | .breadcrumb { 5 | padding: 7px 14px; 6 | margin: 0 0 @baseLineHeight; 7 | #gradient > .vertical(@white, #f5f5f5); 8 | border: 1px solid #ddd; 9 | .border-radius(3px); 10 | .box-shadow(inset 0 1px 0 @white); 11 | li { 12 | display: inline-block; 13 | text-shadow: 0 1px 0 @white; 14 | } 15 | .divider { 16 | padding: 0 5px; 17 | color: @grayLight; 18 | } 19 | .active a { 20 | color: @grayDark; 21 | } 22 | } 23 | -------------------------------------------------------------------------------- /public/js/meow.js: -------------------------------------------------------------------------------- 1 | View.Meow = Backbone.View.extend({ 2 | el: $('#meow'), 3 | 4 | render: function(options) { 5 | var self = this 6 | , alert 7 | 8 | options.className = 'fade in alert alert-' + options.type 9 | options.$prepend = this.$el 10 | 11 | alert = new View.Alert(options) 12 | 13 | _.delay(function() { 14 | alert.$el.removeClass('in') 15 | _.delay(function() { 16 | alert.$el.remove() 17 | }, 500) 18 | }, 3700) 19 | } 20 | 21 | }) 22 | 23 | 24 | ; -------------------------------------------------------------------------------- /index.js: -------------------------------------------------------------------------------- 1 | var express = require('express') 2 | , boot = require('./config/boot') 3 | , app = express.createServer() 4 | 5 | module.exports = app 6 | 7 | boot(app, function(err, results) { 8 | 9 | if (err) { 10 | return console.error('Error in boot process: ', err) 11 | } 12 | 13 | require('./routes')(app) 14 | 15 | app.listen(app.set('port').toString(), function() { 16 | console.log('[' + app.set('env') + '] jukesy is up:', 'http://' + app.set('host') + ':' + app.set('port') + '/') 17 | }) 18 | 19 | }) 20 | -------------------------------------------------------------------------------- /app/views/user/show.jade: -------------------------------------------------------------------------------- 1 | - if (user.fullname) 2 | h1 #{user.fullname} 3 | h3 [#{user.username}] 4 | - else 5 | h1 [#{user.username}] 6 | 7 | p.bio= user.bio 8 | 9 | p 10 | if (user.location) 11 | #{user.location} 12 | if (user.website) 13 | if (user.location) 14 | | · 15 | a.ll(target= '_blank', href= user.website).website #{user.website} 16 | 17 | 18 | - if (currentUser && currentUser.username == user.username) 19 | div 20 | a.btn(href= '/user/#{user.username}/edit') Edit settings 21 | 22 | // Playlists 23 | -------------------------------------------------------------------------------- /lib/bcrypt.js: -------------------------------------------------------------------------------- 1 | /* 2 | * bcrypt.js 3 | * 4 | * Standardizes certain settings we use for hashing via bcrypt, such as 5 | * using a pepper set in config and choosing ten rounds for salt sync. 6 | * 7 | */ 8 | 9 | var bcrypt = require('bcrypt') 10 | , pepper = require('../').pepper 11 | 12 | var BCrypt = function() { 13 | } 14 | 15 | BCrypt.prototype.pepperedHash = function(password, salt, callback) { 16 | bcrypt.hash(password + pepper, salt, callback) 17 | } 18 | 19 | BCrypt.prototype.salt = function(callback) { 20 | bcrypt.genSalt(10, callback) 21 | } 22 | 23 | module.exports = new BCrypt() 24 | -------------------------------------------------------------------------------- /public/less/bootstrap/pager.less: -------------------------------------------------------------------------------- 1 | // PAGER 2 | // ----- 3 | 4 | .pager { 5 | margin-left: 0; 6 | margin-bottom: @baseLineHeight; 7 | list-style: none; 8 | text-align: center; 9 | .clearfix(); 10 | } 11 | .pager li { 12 | display: inline; 13 | } 14 | .pager a { 15 | display: inline-block; 16 | padding: 5px 14px; 17 | background-color: #fff; 18 | border: 1px solid #ddd; 19 | .border-radius(15px); 20 | } 21 | .pager a:hover { 22 | text-decoration: none; 23 | background-color: #f5f5f5; 24 | } 25 | .pager .next a { 26 | float: right; 27 | } 28 | .pager .previous a { 29 | float: left; 30 | } 31 | -------------------------------------------------------------------------------- /TODO: -------------------------------------------------------------------------------- 1 | 2 | Features 3 | - sharing 4 | - playlist.add, playlist.remove endpoints 5 | - tag radio 6 | - favorites 7 | - help / faq 8 | - social video channels 9 | 10 | Technical 11 | - improve view rendering on front-end (speed up dom stuff) 12 | - get rid of Makefile 13 | - separate player from playlists/tracks/etc. on front-end (merge Video and Controls, no need for backbone "model" here) 14 | - require.js 15 | - last.fm caching 16 | - start using images for og:image for better social preview 17 | 18 | Server 19 | - cron jobs 20 | - log rotation 21 | - improve use of nginx proxy cache (mostly for last.fm caching) 22 | 23 | 24 | -------------------------------------------------------------------------------- /app/views/layout/session_button.jade: -------------------------------------------------------------------------------- 1 | ul.nav.pull-right 2 | - if (typeof currentUser == 'undefined') 3 | li 4 | a.sign-up(href= '#') Sign up 5 | li 6 | a.sign-in(href= '#').highlight Login 7 | - else 8 | li.dropdown 9 | a.dropdown-toggle('data-toggle'= 'dropdown', href= '#') Account 10 | i.icon-caret-down 11 | ul.dropdown-menu 12 | li 13 | a(href= '/user/' + currentUser.username) View my profile 14 | li 15 | a(href= '/user/' + currentUser.username + '/edit') Edit settings 16 | li.divider 17 | li 18 | a.ll(href= '/logout') Logout 19 | -------------------------------------------------------------------------------- /app/views/user/reset.jade: -------------------------------------------------------------------------------- 1 | .modal-header 2 | h3 Reset password 3 | .modal-body 4 | form 5 | fieldset 6 | 7 | .control-group 8 | label.control-label(for= 'reset-password') Password 9 | .controls 10 | input#reset-password(type= 'password', name= 'password') 11 | span.help-inline 12 | 13 | .control-group 14 | label.control-label(for= 'reset-password-confirm') Password Confirm 15 | .controls 16 | input#reset-password-confirm(type= 'password', name= 'password-confirm') 17 | span.help-inline 18 | 19 | .modal-footer 20 | button.btn.btn-primary Reset password 21 | -------------------------------------------------------------------------------- /public/less/bootstrap/accordion.less: -------------------------------------------------------------------------------- 1 | // ACCORDION 2 | // --------- 3 | 4 | 5 | // Parent container 6 | .accordion { 7 | margin-bottom: @baseLineHeight; 8 | } 9 | 10 | // Group == heading + body 11 | .accordion-group { 12 | margin-bottom: 2px; 13 | border: 1px solid #e5e5e5; 14 | .border-radius(4px); 15 | } 16 | .accordion-heading { 17 | border-bottom: 0; 18 | } 19 | .accordion-heading .accordion-toggle { 20 | display: block; 21 | padding: 8px 15px; 22 | } 23 | 24 | // Inner needs the styles because you can't animate properly with any styles on the element 25 | .accordion-inner { 26 | padding: 9px 15px; 27 | border-top: 1px solid #e5e5e5; 28 | } 29 | -------------------------------------------------------------------------------- /public/js/alert.js: -------------------------------------------------------------------------------- 1 | View.Alert = Backbone.View.extend({ 2 | className: 'alert fade', 3 | 4 | template: jade.compile($('#alert-template').text()), 5 | 6 | initialize: function() { 7 | this.render() 8 | }, 9 | 10 | render: function() { 11 | this.$el.html(this.template({ message: this.options.message })) 12 | this.options.$prepend.children('.alert').remove() 13 | this.options.$prepend.prepend(this.$el) 14 | 15 | if (!this.$el.hasClass('in')) { 16 | this.show() 17 | } 18 | return this 19 | }, 20 | 21 | show: function() { 22 | var self = this 23 | _.defer(function() { 24 | self.$el.addClass('in') 25 | }) 26 | } 27 | 28 | }) 29 | 30 | 31 | ; -------------------------------------------------------------------------------- /public/less/bootstrap/scaffolding.less: -------------------------------------------------------------------------------- 1 | // Scaffolding 2 | // Basic and global styles for generating a grid system, structural layout, and page templates 3 | // ------------------------------------------------------------------------------------------- 4 | 5 | 6 | // STRUCTURAL LAYOUT 7 | // ----------------- 8 | 9 | body { 10 | margin: 0; 11 | font-family: @baseFontFamily; 12 | font-size: @baseFontSize; 13 | line-height: @baseLineHeight; 14 | color: @textColor; 15 | background-color: @white; 16 | } 17 | 18 | 19 | // LINKS 20 | // ----- 21 | 22 | a { 23 | color: @linkColor; 24 | text-decoration: none; 25 | } 26 | a:hover { 27 | color: @linkColorHover; 28 | text-decoration: underline; 29 | } 30 | -------------------------------------------------------------------------------- /app/views/layout/track.jade: -------------------------------------------------------------------------------- 1 | td.dd 2 | .dropdown 3 | a.dropdown-toggle('data-toggle'= 'dropdown') 4 | i.icon-chevron-down 5 | ul.dropdown-menu 6 | li 7 | a.play-now Play Now 8 | li 9 | a.queue-next Queue Next 10 | li 11 | a.queue-last Queue Last 12 | 13 | li.divider 14 | 15 | li 16 | a.add-to-playlist Add to Playlist 17 | li 18 | a.remove Remove Track 19 | 20 | td.play 21 | a.play-now 22 | .icon-play 23 | 24 | td.name 25 | a(href= urlTrack(track.artist, track.name)) #{track.name} 26 | 27 | td.artist 28 | a(href= urlArtist(track.artist)) #{track.artist} 29 | .pull-right.remove 30 | i.icon-remove 31 | -------------------------------------------------------------------------------- /README.md: -------------------------------------------------------------------------------- 1 | 2 | # Set up 3 | 4 | Make sure to install all npm packages. 5 | 6 | npm install 7 | 8 | Files with protected keys, passwords and other sensitive data are only saved as examples. To finish installation, copy them over and replace any necessary data: 9 | 10 | cp config/mongodb.example.js config/mongodb.js 11 | cp config/pepper.example.js config/pepper.js 12 | 13 | ## Starting the server 14 | 15 | Run the index.js file from the root project folder, for example: 16 | 17 | node . 18 | 19 | # Testing 20 | 21 | Tests are located in /test. They use mocha, chai (with expect-style assertions), and superagent. All tests may be invoked from the project's root directory like this: 22 | 23 | mocha 24 | -------------------------------------------------------------------------------- /test/index.js: -------------------------------------------------------------------------------- 1 | // Run this once. 2 | if (typeof app == 'undefined') { 3 | process.env.NODE_ENV = 'test' 4 | 5 | app = require('../') 6 | expect = require('chai').expect 7 | request = require('superagent') 8 | 9 | // Wrapping superagent's request.get, etc. to prepend 10 | // urls with the host and port for HTTP requests. 11 | app._.each(['del', 'get', 'post', 'put'], function(method) { 12 | request['old' + method] = request[method] 13 | 14 | request[method] = function() { 15 | var args = app._.toArray(arguments).slice(0) 16 | args[0] = 'http://' + app.set('host') + ':' + app.set('port') + args[0] 17 | return request['old' + method].apply(request, args) 18 | } 19 | }) 20 | } 21 | -------------------------------------------------------------------------------- /app/views/layout/search_results_albums.jade: -------------------------------------------------------------------------------- 1 | - if (!albums) 2 | h3 #{displayType} 3 | p Found nothing 4 | 5 | - else if (albums.length) 6 | 7 | - if (showMore) 8 | a.btn.btn-large.show-more.pull-right(href= showMore) Show more » 9 | h3 #{displayType} 10 | - if (showMore) 11 | .clear 12 | 13 | .albums 14 | ul.thumbnails 15 | .clear 16 | 17 | - if (loadMore) 18 | .load-more 19 | a.btn.btn-large('data-loading-text'= 'Loading...') Load more 20 | 21 | - else 22 | h3 #{displayType} 23 | | Loading... 24 | .spinner.pull-right 25 | .bar1 26 | .bar2 27 | .bar3 28 | .bar4 29 | .bar5 30 | .bar6 31 | .bar7 32 | .bar8 33 | .bar9 34 | .bar10 35 | .bar11 36 | .bar12 37 | -------------------------------------------------------------------------------- /app/views/layout/search_results_artists.jade: -------------------------------------------------------------------------------- 1 | - if (!artists) 2 | h3 #{displayType} 3 | p Found nothing 4 | 5 | - else if (artists.length) 6 | 7 | - if (showMore) 8 | a.btn.btn-large.show-more.pull-right(href= showMore) Show more » 9 | h3 #{displayType} 10 | - if (showMore) 11 | .clear 12 | 13 | .artists 14 | ul.thumbnails 15 | .clear 16 | 17 | - if (loadMore) 18 | .load-more 19 | a.btn.btn-large('data-loading-text'= 'Loading...') Load more 20 | 21 | - else 22 | h3 #{displayType} 23 | | Loading... 24 | .spinner.pull-right 25 | .bar1 26 | .bar2 27 | .bar3 28 | .bar4 29 | .bar5 30 | .bar6 31 | .bar7 32 | .bar8 33 | .bar9 34 | .bar10 35 | .bar11 36 | .bar12 37 | -------------------------------------------------------------------------------- /app/views/user/forgot.jade: -------------------------------------------------------------------------------- 1 | .modal-header 2 | a.close('data-dismiss'= 'modal') × 3 | h3 Forgot your password? 4 | .modal-body 5 | form 6 | 7 | .clear 8 | p.pull-right 9 | // a haiku dedicated to the forgotten password 10 | em Relax. Breathe deeply. 11 | br 12 | em Bathe in serene thoughts, user. 13 | br 14 | em Now fill out this form. 15 | fieldset 16 | 17 | .control-group 18 | label.control-label(for= 'forgot-login') Username / Email 19 | .controls 20 | input#forgot-login(type= 'text', name= 'login') 21 | span.help-inline 22 | 23 | .modal-footer 24 | button.btn.btn-primary Email me a reset token 25 | button.btn('data-dismiss'= 'modal') Cancel 26 | -------------------------------------------------------------------------------- /app/controllers/lastfm_controller.js: -------------------------------------------------------------------------------- 1 | module.exports = function(app) { 2 | 3 | return { 4 | get: function(req, res, next) { 5 | var params = {} 6 | app._.each([ 'method', 'page', 'limit', 'artist', 'album', 'track' ], function(param) { 7 | if (req.param(param)) { 8 | params[param] = req.param(param).toLowerCase() 9 | } 10 | }) 11 | 12 | if (!params.method || !params.page || !params.limit) { 13 | return next(new app.Error(403)) 14 | } 15 | 16 | app.lastfmCache.get(params, { 17 | success: function(json) { 18 | res.json(json) 19 | }, 20 | error: function(err) { 21 | next(new app.Error(404)) 22 | } 23 | }) 24 | } 25 | } 26 | } 27 | -------------------------------------------------------------------------------- /app/views/layout/search_result_track.jade: -------------------------------------------------------------------------------- 1 | td.dd 2 | .dropdown 3 | a.dropdown-toggle('data-toggle'= 'dropdown') 4 | i.icon-chevron-down 5 | ul.dropdown-menu 6 | li 7 | a.play-now Play 8 | li 9 | a.queue-next Queue Next 10 | li 11 | a.queue-last Queue Last 12 | 13 | li.divider 14 | 15 | li 16 | a.add-to-playlist Add to Playlist 17 | //li 18 | // a.add-to-favorites Add To Favorites 19 | //li 20 | // a.ban Ban 21 | 22 | td.play 23 | a.play-now 24 | .icon-play 25 | 26 | td.name 27 | a(href= urlTrack(track.get('artist'), track.get('name')))= track.get('name') 28 | 29 | td.artist 30 | a(href= urlArtist(track.get('artist')))= track.get('artist') 31 | -------------------------------------------------------------------------------- /app/views/playlist/index.jade: -------------------------------------------------------------------------------- 1 | h1 Playlists - #{user || 'anonymous'} 2 | 3 | br 4 | ul.unstyled 5 | each playlist in playlists 6 | li 7 | a.playlist(href= playlist.url 8 | class= (playlist._id ? '' : ' unsaved ') + (playlist.active ? ' active ' : '')) #{playlist.name} 9 | 10 | | - #{playlist.tracks_count} #{_.plural(playlist.tracks_count, 'track', 'tracks')} 11 | - if (playlist.nowPlaying) 12 | span.label.label-success listening 13 | - if (playlist.changed) 14 | span.label.label-warning changed 15 | - if (!playlist._id) 16 | span.label.label-important new 17 | 18 | 19 | p 20 | - if (playlist.time) 21 | | Created on #{moment(playlist.time.created).format('dddd, MMMM Do YYYY, h:mm:ss a')} 22 | -------------------------------------------------------------------------------- /app/views/layout.jade: -------------------------------------------------------------------------------- 1 | !!! 5 2 | html 3 | include layout/meta 4 | 5 | body.fade 6 | #modal.modal.hide.fade 7 | #header.navbar 8 | include layout/header 9 | #container.container 10 | .row 11 | #sidebar.sidebar-nav.span2 12 | include layout/sidebar 13 | .span10 14 | #video-wrapper 15 | div 16 | #quality 17 | #video 18 | #main 19 | != body 20 | hr 21 | include layout/footer 22 | .clear 23 | #meow.span4 24 | #controls 25 | 26 | #templates 27 | include _templates 28 | script 29 | window.Charts = !{Charts}; 30 | window.baseUrl = "!{baseUrl}"; 31 | 32 | - each script in assets.js 33 | script(src= script + '?' + gitsha) 34 | -------------------------------------------------------------------------------- /public/js/modal.js: -------------------------------------------------------------------------------- 1 | View.Modal = Backbone.View.extend({ 2 | el: '#modal', 3 | 4 | initialize: function() { 5 | _.bindAll(this, 'hide', 'focusFirstInput', 'hidden') 6 | this.$el.on('hidden', this.hidden) 7 | }, 8 | 9 | render: function(el) { 10 | this.$el.html(el).modal('show') 11 | _.delay(this.focusFirstInput, 500) 12 | }, 13 | 14 | focusFirstInput: function() { 15 | this.$el.find('input:first').focus() 16 | }, 17 | 18 | hide: function() { 19 | if (this.callback) { 20 | this.callback() 21 | } 22 | this.$el.modal('hide') 23 | }, 24 | 25 | hidden: function() { 26 | this.unsetCallback() 27 | }, 28 | 29 | setCallback: function(callback) { 30 | this.callback = callback 31 | }, 32 | 33 | unsetCallback: function() { 34 | this.callback = null 35 | } 36 | }) 37 | -------------------------------------------------------------------------------- /public/less/bootstrap/thumbnails.less: -------------------------------------------------------------------------------- 1 | // THUMBNAILS 2 | // ---------- 3 | 4 | .thumbnails { 5 | margin-left: -@gridGutterWidth; 6 | list-style: none; 7 | .clearfix(); 8 | } 9 | .thumbnails > li { 10 | float: left; 11 | margin: 0 0 @baseLineHeight @gridGutterWidth; 12 | } 13 | .thumbnail { 14 | display: block; 15 | padding: 4px; 16 | line-height: 1; 17 | border: 1px solid #ddd; 18 | .border-radius(4px); 19 | .box-shadow(0 1px 1px rgba(0,0,0,.075)); 20 | } 21 | // Add a hover state for linked versions only 22 | a.thumbnail:hover { 23 | border-color: @linkColor; 24 | .box-shadow(0 1px 4px rgba(0,105,214,.25)); 25 | } 26 | // Images and captions 27 | .thumbnail > img { 28 | display: block; 29 | max-width: 100%; 30 | margin-left: auto; 31 | margin-right: auto; 32 | } 33 | .thumbnail .caption { 34 | padding: 9px; 35 | } 36 | -------------------------------------------------------------------------------- /app/views/session/new.jade: -------------------------------------------------------------------------------- 1 | .modal-header 2 | a.close('data-dismiss'= 'modal') × 3 | h3 Login to jukesy 4 | .modal-body 5 | form 6 | fieldset 7 | 8 | .control-group 9 | label.control-label(for= 'session-new-login') Username / Email 10 | .controls 11 | input#session-new-login(type= 'text', name= 'login') 12 | span.help-inline 13 | 14 | .control-group 15 | label.control-label(for= 'session-new-password') Password 16 | .controls 17 | input#session-new-password(type= 'password', name= 'password') 18 | span.help-inline 19 | 20 | p 21 | a.sign-up(href= '#') Don't have an account yet? Sign up. 22 | p 23 | a.forgot(href= '#') Forgot your password? 24 | 25 | .modal-footer 26 | button.btn.btn-primary Sign in 27 | button.btn('data-dismiss'= 'modal') Cancel 28 | -------------------------------------------------------------------------------- /lib/auth.js: -------------------------------------------------------------------------------- 1 | var app = require('../') 2 | 3 | module.exports = { 4 | 5 | setUser: function(user, req, res) { 6 | req.session.user_id = user._id 7 | }, 8 | 9 | unsetUser: function(req, res) { 10 | delete req.session.user_id 11 | }, 12 | 13 | authorize: function(req, res, next) { 14 | if (req.currentUser && req.currentUser.username == req.params.username) { 15 | return next() 16 | } 17 | return next(new app.Error(401)) 18 | }, 19 | 20 | authenticate: function(req, res, next) { 21 | var User = app.model('User') 22 | 23 | if (!req.session.user_id) { 24 | return next() 25 | } 26 | 27 | User.findOne({ _id: req.session.user_id }, function(err, user) { 28 | if (err || !user) { 29 | app.auth.unsetUser(req, res) 30 | return next() 31 | } 32 | 33 | req.currentUser = user 34 | next() 35 | }) 36 | } 37 | 38 | } 39 | 40 | -------------------------------------------------------------------------------- /lib/error.js: -------------------------------------------------------------------------------- 1 | var errorCodes = { 2 | 400: 'Bad Request' 3 | , 401: 'Unauthorized' 4 | , 403: 'Forbidden' 5 | , 404: 'Not Found' 6 | , 500: 'Internal Error' 7 | , 501: 'Not Implemented' 8 | } 9 | 10 | module.exports = function(err, options) { 11 | if (typeof err == 'number') { 12 | this.code = err 13 | this.type = errorCodes[this.code] || 'Unknown' 14 | } else if (typeof err == 'object') { 15 | 16 | if (err.name != 'ValidationError') { 17 | this.code = 500 18 | this.type = errorCodes[500] 19 | } else { 20 | this.errors = {} 21 | this.code = 400 22 | this.type = 'Validation Error' 23 | 24 | for (var i in err.errors) { 25 | var modelError = err.errors[i] 26 | this.errors[modelError.path] = modelError.type 27 | } 28 | } 29 | } 30 | 31 | if (options) { 32 | this.errors = options 33 | } 34 | 35 | Error.call(this) 36 | } 37 | -------------------------------------------------------------------------------- /app/views/layout/controls.jade: -------------------------------------------------------------------------------- 1 | .container 2 | .row 3 | .span12 4 | .track-info 5 | #time-read 6 | .time-current 7 | | 8 | .time-duration 9 | .row 10 | .span12.controls 11 | .controls-left 12 | .control#prev 13 | .icon-backward 14 | .control#play-pause 15 | .icon-play 16 | .control#next 17 | .icon-forward 18 | .control#volume 19 | .icon-volume-down 20 | .control#volume-bar 21 | .fill.bar 22 | 23 | .controls-middle 24 | .control#timer.progress.progress-success.progress-striped 25 | .track.bar 26 | .fill 27 | 28 | .controls-right 29 | .control#repeat.off 30 | .icon-repeat 31 | .control#shuffle.off 32 | .icon-random 33 | .control#radio.off 34 | .icon-rss 35 | .control#fullscreen 36 | .icon-resize-full 37 | -------------------------------------------------------------------------------- /app/models/lastfm_cache.js: -------------------------------------------------------------------------------- 1 | // Set up as a capped collection 2 | // db.createCollection("lastfm_caches", { capped: true, size: BYTES_TO_USE }); 3 | // db.lastfm_caches.ensureIndex({ method: 1, page: 1, limit: 1, artist: 1, album: 1, track: 1 }) 4 | 5 | var mongoose = require('mongoose') 6 | , Schema = mongoose.Schema 7 | , ObjectId = Schema.ObjectId 8 | , Mixed = Schema.Types.Mixed 9 | , app = require('../../') 10 | 11 | var LastfmCache = module.exports = new Schema({ 12 | method : { type: String }, 13 | page : { type: Number }, 14 | limit : { type: Number }, 15 | artist : { type: String, lowercase: true }, 16 | album : { type: String, lowercase: true }, 17 | track : { type: String, lowercase: true }, 18 | json : {}, 19 | expiry : Date 20 | }) 21 | 22 | LastfmCache.pre('save', function (next) { 23 | this.expiry = new Date(Date.now() + 604800000) // 7 days 24 | next() 25 | }) 26 | 27 | mongoose.model('Lastfm_cache', LastfmCache) 28 | -------------------------------------------------------------------------------- /public/js/share.js: -------------------------------------------------------------------------------- 1 | View.Share = Backbone.View.extend({ 2 | template: jade.compile($('#share-template').text()), 3 | 4 | events: { 5 | 'click .share-twitter': 'twitterPopup', 6 | 'click .share-facebook': 'facebookPopup' 7 | }, 8 | 9 | render: function(options) { 10 | if (options) { 11 | this.url = options.url 12 | this.text = options.text 13 | } 14 | this.$el.html(this.template({ 15 | url: this.url 16 | })) 17 | ModalView.render(this.$el) 18 | this.delegateEvents() 19 | return this 20 | }, 21 | 22 | twitterPopup: function() { 23 | window.open('https://twitter.com/share?url=hack&text=' + encodeURIComponent(this.text + ' ' + this.url + ' #jukesy #music'), 'sharer', 'width=626,height=436') 24 | }, 25 | 26 | facebookPopup: function() { 27 | window.open('https://www.facebook.com/sharer/sharer.php?u=' + encodeURIComponent(this.url), 'sharer', 'width=626,height=436') 28 | } 29 | }) 30 | 31 | 32 | ; 33 | -------------------------------------------------------------------------------- /public/less/bootstrap/tooltip.less: -------------------------------------------------------------------------------- 1 | // TOOLTIP 2 | // ------= 3 | 4 | .tooltip { 5 | position: absolute; 6 | z-index: @zindexTooltip; 7 | display: block; 8 | visibility: visible; 9 | padding: 5px; 10 | font-size: 11px; 11 | .opacity(0); 12 | &.in { .opacity(80); } 13 | &.top { margin-top: -2px; } 14 | &.right { margin-left: 2px; } 15 | &.bottom { margin-top: 2px; } 16 | &.left { margin-left: -2px; } 17 | &.top .tooltip-arrow { #popoverArrow > .top(); } 18 | &.left .tooltip-arrow { #popoverArrow > .left(); } 19 | &.bottom .tooltip-arrow { #popoverArrow > .bottom(); } 20 | &.right .tooltip-arrow { #popoverArrow > .right(); } 21 | } 22 | .tooltip-inner { 23 | max-width: 200px; 24 | padding: 3px 8px; 25 | color: @white; 26 | text-align: center; 27 | text-decoration: none; 28 | background-color: @black; 29 | .border-radius(4px); 30 | } 31 | .tooltip-arrow { 32 | position: absolute; 33 | width: 0; 34 | height: 0; 35 | } 36 | -------------------------------------------------------------------------------- /lib/mongoose_plugins.js: -------------------------------------------------------------------------------- 1 | module.exports = { 2 | 3 | accessible: function(schema, accessible) { 4 | schema.methods.updateAttributes = function(attributes) { 5 | var self = this 6 | accessible.forEach(function(attribute) { 7 | if (typeof attributes[attribute] != 'undefined') { 8 | self[attribute] = attributes[attribute] 9 | } 10 | }) 11 | } 12 | }, 13 | 14 | timestamps: function(schema, options) { 15 | schema.add({ 16 | time: { 17 | created: Date, 18 | updated: Date 19 | } 20 | }) 21 | 22 | schema.pre('save', function (next) { 23 | if (this.isNew) { 24 | this.time.created = new Date 25 | } 26 | next() 27 | }) 28 | 29 | schema.pre('save', function (next) { 30 | this.time.updated = new Date 31 | next() 32 | }) 33 | 34 | if (options && options.index) { 35 | schema.path('time.created').index(options.index) 36 | } 37 | } 38 | 39 | } 40 | -------------------------------------------------------------------------------- /app/views/layout/sidebar.jade: -------------------------------------------------------------------------------- 1 | ul.nav.nav-list 2 | 3 | - if (typeof nowPlayingUrl != 'undefined') 4 | li 5 | a(href= nowPlayingUrl) 6 | i.icon-music 7 | | Now Playing 8 | li 9 | a(href= '/#').create-playlist 10 | i.icon-plus-sign 11 | | Create Playlist 12 | 13 | //li 14 | // a(href= '/favorites') 15 | // i.icon-star 16 | // | Favorites 17 | 18 | li 19 | a(href= '/user/' + ((currentUser && currentUser.username) || 'anonymous') + '/playlist') 20 | i.icon-th-list 21 | | My Playlists 22 | 23 | - if (typeof playlists != 'undefined') 24 | li.divider 25 | each playlist in playlists 26 | li 27 | a.playlist(href= playlist.url, class= (playlist.active ? ' active ' : '') + (playlist.changed ? ' changed ' : '') + (!playlist._id ? ' new ' : '')) 28 | - if (playlist.nowPlaying) 29 | i.icon-music 30 | - else 31 | i.icon-empty 32 | | #{playlist.name} 33 | -------------------------------------------------------------------------------- /app/views/user/new.jade: -------------------------------------------------------------------------------- 1 | .modal-header 2 | a.close('data-dismiss'= 'modal') × 3 | h3 Sign up for jukesy 4 | .modal-body 5 | form 6 | fieldset 7 | 8 | .control-group 9 | label.control-label(for= 'user-new-username') Username 10 | .controls 11 | input#user-new-username(type= 'text', name= 'username') 12 | span.help-inline 13 | 14 | .control-group 15 | label.control-label(for= 'user-new-email') Email 16 | .controls 17 | input#user-new-email(type= 'text', name= 'email') 18 | span.help-inline 19 | 20 | .control-group 21 | label.control-label(for= 'user-new-password') Password 22 | .controls 23 | input#user-new-password(type= 'password', name= 'password') 24 | span.help-inline 25 | 26 | div 27 | a.sign-in(href= '#') Already have an account? Sign in. 28 | .modal-footer 29 | button.btn.btn-primary Sign up 30 | button.btn('data-dismiss'= 'modal') Cancel 31 | -------------------------------------------------------------------------------- /config/mongodb.example.js: -------------------------------------------------------------------------------- 1 | var mongoose = require('mongoose') 2 | , fs = require('fs') 3 | 4 | module.exports = function(app, callback) { 5 | var databaseConfig = { 6 | development: { 7 | host : 'localhost', 8 | database : 'jukesy-development' 9 | }, 10 | test: { 11 | host : 'localhost', 12 | database : 'jukesy-test' 13 | }, 14 | staging: { 15 | host : 'localhost', 16 | database : 'jukesy-staging' 17 | }, 18 | production: { 19 | host : 'localhost', 20 | database : 'jukesy' 21 | } 22 | } 23 | 24 | app.mongodb = databaseConfig[app.set('env')] 25 | app.mongooseSetters = require('../lib/mongoose_setters') 26 | app.mongoosePlugins = require('../lib/mongoose_plugins') 27 | app.mongooseValidators = require('../lib/mongoose_validators') 28 | 29 | var mongodbURL = 'mongodb://' + app.mongodb.host + '/' + app.mongodb.database 30 | app.db = mongoose.connect(mongodbURL) 31 | callback() 32 | } 33 | 34 | -------------------------------------------------------------------------------- /public/less/bootstrap/labels.less: -------------------------------------------------------------------------------- 1 | // LABELS 2 | // ------ 3 | 4 | // Base 5 | .label { 6 | padding: 2px 4px 3px; 7 | font-size: @baseFontSize * .85; 8 | //font-weight: bold; 9 | color: @white; 10 | text-shadow: 0 -1px 0 rgba(0,0,0,.25); 11 | background-color: @grayLight; 12 | .border-radius(3px); 13 | } 14 | 15 | // Hover state 16 | .label:hover { 17 | color: @white; 18 | text-decoration: none; 19 | } 20 | 21 | // Colors 22 | .label-important { background-color: @errorText; } 23 | .label-important:hover { background-color: darken(@errorText, 10%); } 24 | 25 | .label-warning { background-color: @orange; } 26 | .label-warning:hover { background-color: darken(@orange, 10%); } 27 | 28 | .label-success { background-color: @successText; } 29 | .label-success:hover { background-color: darken(@successText, 10%); } 30 | 31 | .label-info { background-color: @infoText; } 32 | .label-info:hover { background-color: darken(@infoText, 10%); } 33 | -------------------------------------------------------------------------------- /public/less/jukesy/include.less: -------------------------------------------------------------------------------- 1 | .clear { clear: both; } 2 | .right { float: right; } 3 | .left { float: left; } 4 | 5 | .no-select { 6 | -webkit-user-select: none; 7 | -khtml-user-select: none; 8 | -moz-user-select: none; 9 | -o-user-select: none; 10 | user-select: none; 11 | } 12 | 13 | .gradient (@top, @bottom) { 14 | background-image: -webkit-gradient(linear, left bottom, left top, color-stop(1, @top), color-stop(0, @bottom)); 15 | background-image: -moz-linear-gradient(center bottom, @top 100%, @bottom 0%); 16 | } 17 | 18 | .transition (@attr, @time) { 19 | -webkit-transition: @attr @time ease-in; 20 | -moz-transition: @attr @time ease-in; 21 | -o-transition: @attr @time ease-in; 22 | transition: @attr @time ease-in; 23 | } 24 | 25 | .fade (@ms) { 26 | -webkit-transition: opacity @ms ease-in @ms; 27 | -moz-transition: opacity @ms ease-in @ms; 28 | -o-transition: opacity @ms ease-in @ms; 29 | transition: opacity @ms ease-in @ms; 30 | } 31 | 32 | .radius (@r) { 33 | border-radius: @r; 34 | -moz-border-radius: @r; 35 | -webkit-border-radius: @r; 36 | } 37 | -------------------------------------------------------------------------------- /public/less/jukesy/playlist.less: -------------------------------------------------------------------------------- 1 | .playlists { 2 | span.label { 3 | margin-left: 5px; 4 | } 5 | } 6 | 7 | .playlist { 8 | .label { 9 | vertical-align: top; 10 | margin-left: 10px; 11 | } 12 | 13 | button.playlist-save .icon-upload { 14 | margin-right: 2px; 15 | } 16 | 17 | .playlist-name { 18 | display: inline-block; 19 | padding: 4px 0 13px 0; 20 | font-size: 24px; 21 | 22 | &.edit { 23 | cursor: pointer; 24 | } 25 | } 26 | } 27 | 28 | .playlists-add { 29 | ul { 30 | max-height: 200px; 31 | overflow-y: scroll; 32 | border: 2px solid @grayDark; 33 | .border-radius(2px); 34 | li { 35 | .no-select(); 36 | border-bottom: 1px solid @grayDark; 37 | background: #1c1c1c; 38 | padding: 8px; 39 | font-size: 12px; 40 | color: @gray; 41 | cursor: pointer; 42 | &:hover { background: #131313; color: @white;} 43 | &.selected { background: @blue; color: @grayLighter; border-bottom-color: @blueDark; } 44 | &.selected:hover { background: @blueDark; } 45 | } 46 | } 47 | } -------------------------------------------------------------------------------- /package.json: -------------------------------------------------------------------------------- 1 | { 2 | "author": "Adrian Bravo ", 3 | "name": "jukesy", 4 | "private": true, 5 | "description": "music video player", 6 | "version": "0.0.2", 7 | "homepage": "http://jukesy.com", 8 | "repository": { 9 | "type": "git", 10 | "url": "https://github.com/adrianbravo/jukesy" 11 | }, 12 | "main": "./", 13 | "engines": { 14 | "node": ">= v0.6.9" 15 | }, 16 | "dependencies": { 17 | "async" : "0.1.15", 18 | "bcrypt" : "0.8", 19 | "connect" : "1.8.5", 20 | "connect-mongo" : "0.1.7", 21 | "express" : "2.5.4", 22 | "jade" : "0.20.0", 23 | "moment" : "1.5.1", 24 | "mongodb" : "0.9.8-5", 25 | "mongoose" : "2.5.6", 26 | "nodemailer" : "0.3.18", 27 | "qs" : "0.5.0", 28 | "request" : "2.9.202", 29 | "underscore" : "1.3.1" 30 | }, 31 | "devDependencies": { 32 | "chai" : "0.2.4", 33 | "less" : "1.3.0", 34 | "mocha" : "0.11.0", 35 | "superagent" : "0.3.0", 36 | "uglify-js" : "1.2.6" 37 | } 38 | } 39 | -------------------------------------------------------------------------------- /public/less/bootstrap/badges.less: -------------------------------------------------------------------------------- 1 | // BADGES 2 | // ------ 3 | 4 | // Base 5 | .badge { 6 | padding: 1px 9px 2px; 7 | font-size: @baseFontSize * .925; 8 | font-weight: bold; 9 | white-space: nowrap; 10 | color: @white; 11 | background-color: @grayLight; 12 | .border-radius(9px); 13 | } 14 | 15 | // Hover state 16 | .badge:hover { 17 | color: @white; 18 | text-decoration: none; 19 | cursor: pointer; 20 | } 21 | 22 | // Colors 23 | .badge-error { background-color: @errorText; } 24 | .badge-error:hover { background-color: darken(@errorText, 10%); } 25 | 26 | .badge-warning { background-color: @orange; } 27 | .badge-warning:hover { background-color: darken(@orange, 10%); } 28 | 29 | .badge-success { background-color: @successText; } 30 | .badge-success:hover { background-color: darken(@successText, 10%); } 31 | 32 | .badge-info { background-color: @infoText; } 33 | .badge-info:hover { background-color: darken(@infoText, 10%); } 34 | 35 | .badge-inverse { background-color: @grayDark; } 36 | .badge-inverse:hover { background-color: darken(@grayDark, 10%); } -------------------------------------------------------------------------------- /app/controllers/application_controller.js: -------------------------------------------------------------------------------- 1 | module.exports = function(app) { 2 | 3 | return { 4 | 5 | status : function(req, res, next) { 6 | res.send(process.memoryUsage(), 200); 7 | }, 8 | 9 | error: function(err, req, res) { 10 | var stack = err && err.stack 11 | 12 | if (!err.code) { 13 | err = new app.Error(500) 14 | } 15 | 16 | if (req.xhr) { 17 | res.json(err, err.code) 18 | } else { 19 | switch(err.code) { 20 | case 500: 21 | res.render('500', { status: 500, meta: app.meta() }) 22 | break 23 | case 404: 24 | res.render('404', { status: 404, meta: app.meta() }) 25 | break 26 | case 401: 27 | res.render('401', { status: 401, meta: app.meta() }) 28 | break 29 | default: 30 | res.json(err, err.code) 31 | } 32 | } 33 | 34 | stack && console.error(stack) 35 | }, 36 | 37 | notFound: function(req, res, next) { 38 | res.render('404', { status: 404, meta: app.meta() }) 39 | } 40 | 41 | } 42 | 43 | } 44 | -------------------------------------------------------------------------------- /public/js/shuffle.js: -------------------------------------------------------------------------------- 1 | Model.Shuffle = Backbone.Model.extend({ 2 | 3 | initialize: function() { 4 | this.active = false 5 | this.history = new Collection.Tracks() 6 | _.bindAll(this, 'trimHistory') 7 | }, 8 | 9 | disable: function() { 10 | this.active = false 11 | }, 12 | 13 | enable: function() { 14 | this.history.reset(Video.track ? [ Video.track ] : []) 15 | this.active = true 16 | }, 17 | 18 | next: function() { 19 | this.trimHistory() 20 | var index = this.history.indexOf(Video.track) 21 | if (index + 1 == this.history.length) { 22 | var track = NowPlaying.tracks.randomWithout(this.history.models) 23 | track.play() 24 | } else { 25 | this.history.at(index + 1).play() 26 | } 27 | }, 28 | 29 | prev: function() { 30 | var index = this.history.indexOf(Video.track) 31 | if (index > 0) { 32 | this.history.at(index - 1).play() 33 | } 34 | _.defer(this.trimHistory) 35 | }, 36 | 37 | trimHistory: function() { 38 | this.history.reset(this.history.last(_.min([Math.floor(NowPlaying.tracks.length / 2), 50]))) 39 | } 40 | 41 | }) 42 | 43 | 44 | ; 45 | -------------------------------------------------------------------------------- /config/index.js: -------------------------------------------------------------------------------- 1 | var _ = require('underscore') 2 | , moment = require('moment') 3 | 4 | module.exports = function(app) { 5 | 6 | _.mixin({ 7 | capitalize: function(string) { 8 | string = '' + string 9 | return string.charAt(0).toUpperCase() + string.substring(1).toLowerCase() 10 | }, 11 | plural: function(count, singular, plural) { 12 | return count == 1 ? singular : plural 13 | } 14 | }) 15 | 16 | // Restrict environments to what we anticipate 17 | if (['development', 'test', 'staging', 'production'].indexOf(app.set('env')) == -1) { 18 | app.set('env', 'development') 19 | } 20 | 21 | app._ = _ 22 | app.moment = moment 23 | app.assets = require('./assets')[app.set('env')] 24 | app.pepper = require('./pepper') 25 | 26 | app.auth = require('../lib/auth') 27 | app.lastfmCache = require('../lib/lastfm_cache') 28 | app.lastfmCache.initQueue() 29 | 30 | app.Error = require('../lib/error') 31 | 32 | app.model = function(modelName) { 33 | return app.db.model(app._.capitalize(modelName)) 34 | } 35 | 36 | app.controller = function(controllerName) { 37 | return app.controllers[app._.capitalize(controllerName)] 38 | } 39 | 40 | } 41 | -------------------------------------------------------------------------------- /lib/mongoose_validators.js: -------------------------------------------------------------------------------- 1 | var app = require('../') 2 | , url = require('url') 3 | 4 | module.exports = { 5 | 6 | required: function(v) { 7 | return (v instanceof String || typeof v == 'string') && v.length 8 | }, 9 | 10 | tooLong: function(max) { 11 | return (function(v) { 12 | return (v instanceof String || typeof v == 'string') && v.length <= max 13 | }) 14 | }, 15 | 16 | match: function(regex) { 17 | return function(v) { 18 | return v.match(regex) 19 | } 20 | }, 21 | 22 | alreadyTaken: function(model, field) { 23 | return function(v, done) { 24 | if (this.isNew || this.isModified(field)) { 25 | var Model = app.model(model) 26 | , query = {} 27 | 28 | query[field] = v 29 | Model.findOne(query, function(err, found) { 30 | done(!err && !found) 31 | }) 32 | } else { 33 | done(true) 34 | } 35 | } 36 | }, 37 | 38 | isURL: function(v) { 39 | if (!app._.isString(v) || v.length == 0) { 40 | return true 41 | } 42 | var parsed = url.parse(v) 43 | return !app._.isUndefined(parsed.protocol) && !app._.isUndefined(parsed.hostname) && true 44 | } 45 | 46 | } 47 | -------------------------------------------------------------------------------- /app/controllers/home_controller.js: -------------------------------------------------------------------------------- 1 | module.exports = function(app) { 2 | 3 | return { 4 | 5 | welcome: function(req, res, next) { 6 | res.render('home/welcome', { 7 | meta: app.meta() 8 | }) 9 | }, 10 | 11 | about: function(req, res, next) { 12 | res.render('home/about', { 13 | meta: app.meta({ 14 | title: 'about jukesy', 15 | url: app.set('base_url') + '/about' 16 | }) 17 | }) 18 | }, 19 | 20 | termsOfService: function(req, res, next) { 21 | return next(new app.Error(501)) 22 | res.render('home/terms_of_service', { 23 | meta: app.meta({ 24 | title: 'jukesy - terms of service', 25 | url: app.set('base_url') + '/terms-of-service' 26 | // description 27 | }) 28 | }) 29 | }, 30 | 31 | privacyPolicy: function(req, res, next) { 32 | return next(new app.Error(501)) 33 | res.render('home/privacy_policy', { 34 | meta: app.meta({ 35 | title: 'jukesy - privacy policy', 36 | url: app.set('base_url') + '/privacy-policy' 37 | // description 38 | }) 39 | }) 40 | } 41 | } 42 | 43 | } 44 | -------------------------------------------------------------------------------- /public/less/bootstrap/pagination.less: -------------------------------------------------------------------------------- 1 | // PAGINATION 2 | // ---------- 3 | 4 | .pagination { 5 | height: @baseLineHeight * 2; 6 | margin: @baseLineHeight 0; 7 | } 8 | .pagination ul { 9 | display: inline-block; 10 | .ie7-inline-block(); 11 | margin-left: 0; 12 | margin-bottom: 0; 13 | .border-radius(3px); 14 | .box-shadow(0 1px 2px rgba(0,0,0,.05)); 15 | } 16 | .pagination li { 17 | display: inline; 18 | } 19 | .pagination a { 20 | float: left; 21 | padding: 0 14px; 22 | line-height: (@baseLineHeight * 2) - 2; 23 | text-decoration: none; 24 | border: 1px solid #ddd; 25 | border-left-width: 0; 26 | } 27 | .pagination a:hover, 28 | .pagination .active a { 29 | background-color: #f5f5f5; 30 | } 31 | .pagination .active a { 32 | color: @grayLight; 33 | cursor: default; 34 | } 35 | .pagination .disabled a, 36 | .pagination .disabled a:hover { 37 | color: @grayLight; 38 | background-color: transparent; 39 | cursor: default; 40 | } 41 | .pagination li:first-child a { 42 | border-left-width: 1px; 43 | .border-radius(3px 0 0 3px); 44 | } 45 | .pagination li:last-child a { 46 | .border-radius(0 3px 3px 0); 47 | } 48 | 49 | // Centered 50 | .pagination-centered { 51 | text-align: center; 52 | } 53 | .pagination-right { 54 | text-align: right; 55 | } 56 | -------------------------------------------------------------------------------- /config/assets.js: -------------------------------------------------------------------------------- 1 | var assets = { 2 | favicon: '/favicon.ico', 3 | js: [ 4 | '/js/lib/json2.js', 5 | '/js/lib/jquery-1.7.2.min.js', 6 | '/js/lib/underscore-min.js', 7 | '/js/lib/backbone.js', 8 | '/js/lib/swfobject.js', 9 | '/js/lib/moment-1.5.0.js', 10 | '/js/lib/less-1.3.0.min.js', 11 | '/js/lib/jade.min.js', 12 | '/js/lib/bootstrap.js', 13 | '/js/boot.js', 14 | '/js/mixins.js', 15 | '/js/error.js', 16 | '/js/modal.js', 17 | 18 | '/js/user.js', 19 | '/js/session.js', 20 | '/js/lastfm.js', 21 | '/js/search.js', 22 | '/js/playlist.js', 23 | '/js/radio.js', 24 | '/js/shuffle.js', 25 | '/js/artist.js', 26 | '/js/album.js', 27 | '/js/track.js', 28 | '/js/tag.js', 29 | '/js/meow.js', 30 | '/js/welcome.js', 31 | 32 | '/js/video.js', 33 | '/js/keys.js', 34 | '/js/share.js', 35 | '/js/alert.js', 36 | '/js/application.js' 37 | ], 38 | less: [ '/less/bootstrap/bootstrap.less' ], 39 | css: [] 40 | } 41 | 42 | exports.development = assets 43 | exports.test = assets 44 | exports.staging = exports.production = { 45 | favicon: 'http://static1.jukesy.com/favicon.ico', 46 | js: [ 'http://static1.jukesy.com/jukesy.min.js' ], 47 | css: [ 'http://static2.jukesy.com/jukesy.css' ], 48 | less: [] 49 | } 50 | 51 | -------------------------------------------------------------------------------- /public/less/bootstrap/popovers.less: -------------------------------------------------------------------------------- 1 | // POPOVERS 2 | // -------- 3 | 4 | .popover { 5 | position: absolute; 6 | top: 0; 7 | left: 0; 8 | z-index: @zindexPopover; 9 | display: none; 10 | padding: 5px; 11 | &.top { margin-top: -5px; } 12 | &.right { margin-left: 5px; } 13 | &.bottom { margin-top: 5px; } 14 | &.left { margin-left: -5px; } 15 | &.top .arrow { #popoverArrow > .top(); } 16 | &.right .arrow { #popoverArrow > .right(); } 17 | &.bottom .arrow { #popoverArrow > .bottom(); } 18 | &.left .arrow { #popoverArrow > .left(); } 19 | .arrow { 20 | position: absolute; 21 | width: 0; 22 | height: 0; 23 | } 24 | } 25 | .popover-inner { 26 | padding: 3px; 27 | width: 280px; 28 | overflow: hidden; 29 | background: @black; // has to be full background declaration for IE fallback 30 | background: rgba(0,0,0,.8); 31 | .border-radius(6px); 32 | .box-shadow(0 3px 7px rgba(0,0,0,0.3)); 33 | } 34 | .popover-title { 35 | padding: 9px 15px; 36 | line-height: 1; 37 | background-color: @grayDark; 38 | border-bottom:1px solid #eee; 39 | .border-radius(3px 3px 0 0); 40 | } 41 | .popover-content { 42 | padding: 14px; 43 | background-color: @white; 44 | .border-radius(0 0 3px 3px); 45 | .background-clip(padding-box); 46 | p, ul, ol { 47 | margin-bottom: 0; 48 | } 49 | } 50 | -------------------------------------------------------------------------------- /app/views/user/edit.jade: -------------------------------------------------------------------------------- 1 | h2 Edit Account 2 | form.form-horizontal 3 | fieldset 4 | 5 | .control-group 6 | label.control-label(for= 'user-edit-fullname') Full Name 7 | .controls 8 | input#user-edit-fullname(type= 'text', name= 'fullname', value= user.fullname) 9 | span.help-inline 10 | 11 | .control-group 12 | label.control-label(for= 'user-edit-bio') Bio 13 | .controls 14 | textarea#user-edit-bio.input-large(rows= 3, name= 'bio') 15 | = escape(user.bio || '') 16 | span.help-inline 17 | 18 | .control-group 19 | label.control-label(for= 'user-edit-location') Location 20 | .controls 21 | input#user-edit-location(type= 'text', name= 'location', value= user.location) 22 | span.help-inline 23 | 24 | .control-group 25 | label.control-label(for= 'user-edit-website') Website 26 | .controls 27 | input#user-edit-website(type= 'text', name= 'website', value= user.website) 28 | span.help-inline 29 | 30 | .control-group 31 | label.control-label(for= 'user-edit-email') Email 32 | .controls 33 | input#user-edit-email(type= 'text', name= 'email', value= user.email) 34 | span.help-inline 35 | 36 | p 37 | button.btn.btn-primary Update 38 | | 39 | a.btn(href= '/user/#{user.username}') View Profile 40 | -------------------------------------------------------------------------------- /public/js/radio.js: -------------------------------------------------------------------------------- 1 | Model.Radio = Backbone.Model.extend({ 2 | initialize: function() { 3 | this.disable() 4 | _.bindAll(this, 'discover', 'disable', 'enable') 5 | }, 6 | 7 | disable: function() { 8 | this.active = false 9 | clearInterval(this.interval) 10 | }, 11 | 12 | enable: function() { 13 | this.active = true 14 | this.interval = setInterval(this.discover, 2000) 15 | Video.repeat = false 16 | Shuffle.disable() 17 | }, 18 | 19 | discover: function() { 20 | var track 21 | 22 | if (!window.NowPlaying || !NowPlaying.tracks.length || this.tracks) { 23 | return 24 | } 25 | if (Video.stopped || Video.track && NowPlaying.tracks.indexOf(Video.track) + 3 < NowPlaying.tracks.length) { 26 | return 27 | } 28 | 29 | track = NowPlaying.tracks.randomWithout([]) 30 | 31 | if (track.similarTracks) { 32 | track.addSimilarTrack() 33 | } else { 34 | this.tracks = new Model.LastFM({ artist: track.get('artist'), track: track.get('name'), method: 'track.getSimilar', limit: 50, hide: true }) 35 | this.tracks.on('queryCallback', function() { 36 | track.similarTracks = new Collection.Tracks(_.map(this.results, function(result) { return result.toJSON() })) 37 | track.addSimilarTrack() 38 | Radio.tracks = null 39 | }, this.tracks) 40 | } 41 | } 42 | 43 | }) 44 | 45 | 46 | ; 47 | -------------------------------------------------------------------------------- /app/views/layout/search_results_tracks.jade: -------------------------------------------------------------------------------- 1 | - if (!tracks) 2 | 3 | h3 #{displayType} 4 | p Found nothing 5 | 6 | - else if (tracks.length) 7 | 8 | - if (showMore) 9 | a.btn.btn-large.show-more.pull-right(href= showMore) Show more » 10 | .btn-group.pull-right 11 | button.btn.btn-primary.btn-large.play-all Play All 12 | button.btn.btn-primary.btn-large.dropdown-toggle('data-toggle'= 'dropdown') 13 | i.icon-caret-down 14 | ul.dropdown-menu 15 | li 16 | a.play-all Play All 17 | li 18 | a.queue-all-next Queue All Next 19 | li 20 | a.queue-all-last Queue All Last 21 | li.divider 22 | li 23 | a.add-to-playlist Add to Playlist 24 | li.divider 25 | li 26 | a.share Share 27 | 28 | h3 #{displayType} 29 | .clear 30 | 31 | .tracks 32 | table.table.table-striped 33 | thead 34 | tr 35 | th.dropdown 36 | th.play 37 | th Track 38 | th Artist 39 | tbody 40 | 41 | - if (loadMore) 42 | .load-more 43 | a.btn.btn-large('data-loading-text'= 'Loading...') Load more 44 | 45 | - else 46 | 47 | h3 #{displayType} 48 | | Loading... 49 | .spinner.pull-right 50 | .bar1 51 | .bar2 52 | .bar3 53 | .bar4 54 | .bar5 55 | .bar6 56 | .bar7 57 | .bar8 58 | .bar9 59 | .bar10 60 | .bar11 61 | .bar12 62 | -------------------------------------------------------------------------------- /Makefile: -------------------------------------------------------------------------------- 1 | GITSHA := $(shell git rev-parse --short HEAD) 2 | 3 | jukesy: 4 | lessc --compress ./public/less/bootstrap/bootstrap.less > ./public/jukesy.css 5 | cat ./public/js/lib/json2.js \ 6 | ./public/js/lib/jquery-1.7.2.min.js \ 7 | ./public/js/lib/underscore-min.js \ 8 | ./public/js/lib/backbone.js \ 9 | ./public/js/lib/swfobject.js \ 10 | ./public/js/lib/moment-1.5.0.js \ 11 | ./public/js/lib/jade.min.js \ 12 | ./public/js/lib/bootstrap.js \ 13 | ./public/js/boot.js \ 14 | ./public/js/mixins.js \ 15 | ./public/js/error.js \ 16 | ./public/js/modal.js \ 17 | ./public/js/user.js \ 18 | ./public/js/session.js \ 19 | ./public/js/lastfm.js \ 20 | ./public/js/search.js \ 21 | ./public/js/playlist.js \ 22 | ./public/js/radio.js \ 23 | ./public/js/shuffle.js \ 24 | ./public/js/artist.js \ 25 | ./public/js/album.js \ 26 | ./public/js/track.js \ 27 | ./public/js/tag.js \ 28 | ./public/js/meow.js \ 29 | ./public/js/welcome.js \ 30 | ./public/js/video.js \ 31 | ./public/js/keys.js \ 32 | ./public/js/share.js \ 33 | ./public/js/alert.js \ 34 | ./public/js/application.js > ./public/jukesy.js 35 | uglifyjs -nc -nm -nmf ./public/jukesy.js > ./public/jukesy.min.js 36 | 37 | clean: 38 | rm -f ./public/jukesy.min.js ./public/jukesy.js ./public/jukesy.css 39 | 40 | .PHONY: clean 41 | -------------------------------------------------------------------------------- /public/less/bootstrap/code.less: -------------------------------------------------------------------------------- 1 | // Code.less 2 | // Code typography styles for the and
 elements
 3 | // --------------------------------------------------------
 4 | 
 5 | // Inline and block code styles
 6 | code,
 7 | pre {
 8 |   padding: 0 3px 2px;
 9 |   #font > #family > .monospace;
10 |   font-size: @baseFontSize - 1;
11 |   color: @grayDark;
12 |   .border-radius(3px);
13 | }
14 | 
15 | // Inline code
16 | code {
17 |   padding: 3px 4px;
18 |   color: #d14;
19 |   background-color: #f7f7f9;
20 |   border: 1px solid #e1e1e8;
21 | }
22 | 
23 | // Blocks of code
24 | pre {
25 |   display: block;
26 |   padding: (@baseLineHeight - 1) / 2;
27 |   margin: 0 0 @baseLineHeight / 2;
28 |   font-size: 12px;
29 |   line-height: @baseLineHeight;
30 |   background-color: #f5f5f5;
31 |   border: 1px solid #ccc; // fallback for IE7-8
32 |   border: 1px solid rgba(0,0,0,.15);
33 |   .border-radius(4px);
34 |   white-space: pre;
35 |   white-space: pre-wrap;
36 |   word-break: break-all;
37 |   word-wrap: break-word;
38 | 
39 |   // Make prettyprint styles more spaced out for readability
40 |   &.prettyprint {
41 |     margin-bottom: @baseLineHeight;
42 |   }
43 | 
44 |   // Account for some code outputs that place code tags in pre tags
45 |   code {
46 |     padding: 0;
47 |     color: inherit;
48 |     background-color: transparent;
49 |     border: 0;
50 |   }
51 | }
52 | 
53 | // Enable scrollable blocks of code
54 | .pre-scrollable {
55 |   max-height: 340px;
56 |   overflow-y: scroll;
57 | }


--------------------------------------------------------------------------------
/public/less/bootstrap/alerts.less:
--------------------------------------------------------------------------------
 1 | // ALERT STYLES
 2 | // ------------
 3 | 
 4 | // Base alert styles
 5 | .alert {
 6 |   padding: 8px 35px 8px 14px;
 7 |   margin-bottom: @baseLineHeight;
 8 |   text-shadow: 0 1px 0 rgba(255,255,255,.5);
 9 |   background-color: @warningBackground;
10 |   border: 1px solid @warningBorder;
11 |   .border-radius(4px);
12 | }
13 | .alert,
14 | .alert-heading {
15 |   color: @warningText;
16 | }
17 | 
18 | // Adjust close link position
19 | .alert .close {
20 |   position: relative;
21 |   top: -2px;
22 |   right: -21px;
23 |   line-height: 18px;
24 | }
25 | 
26 | // Alternate styles
27 | // ----------------
28 | 
29 | .alert-success {
30 |   background-color: @successBackground;
31 |   border-color: @successBorder;  
32 | }
33 | .alert-success,
34 | .alert-success .alert-heading {
35 |   color: @successText;
36 | }
37 | .alert-danger,
38 | .alert-error {
39 |   background-color: @errorBackground;
40 |   border-color: @errorBorder;
41 | }
42 | .alert-danger,
43 | .alert-error,
44 | .alert-danger .alert-heading,
45 | .alert-error .alert-heading {
46 |   color: @errorText;
47 | }
48 | .alert-info {
49 |   background-color: @infoBackground;
50 |   border-color: @infoBorder;
51 | }
52 | .alert-info,
53 | .alert-info .alert-heading {
54 |   color: @infoText;
55 | }
56 | 
57 | 
58 | // Block alerts
59 | // ------------------------
60 | .alert-block {
61 |   padding-top: 14px;
62 |   padding-bottom: 14px;
63 | }
64 | .alert-block > p,
65 | .alert-block > ul {
66 |   margin-bottom: 0;
67 | }
68 | .alert-block p + p {
69 |   margin-top: 5px;
70 | }
71 | 


--------------------------------------------------------------------------------
/app/views/layout/keyboard_shortcuts.jade:
--------------------------------------------------------------------------------
 1 | .modal-header
 2 |   a.close('data-dismiss'= 'modal') ×
 3 |   h3 Keyboard shortcuts
 4 | .modal-body#keyboard-shortcuts-modal
 5 |   .third
 6 |     ul.unstyled
 7 |       li
 8 |         .key /
 9 |         | search
10 |       li
11 |         .key SPACE
12 |         | pause / play
13 |       li
14 |         .key ESC
15 |         | exit fullscreen
16 |       li
17 |         .key.big F
18 |         | toggle fullscreen
19 |       li
20 |         .key.big V
21 |         | go to video
22 |       li
23 |         .key.big P
24 |         | go to now playing
25 |         
26 |   .third
27 |     ul.unstyled
28 |       li
29 |         .key.big 1 - 9
30 |         | switch YouTube videos
31 |       li
32 |         .key
33 |           .icon.icon-arrow-up
34 |         | increase volume
35 |       li
36 |         .key
37 |           .icon.icon-arrow-down
38 |         | decrease volume
39 |       li
40 |         .key
41 |           .icon.icon-arrow-left
42 |         | previous song
43 |       li
44 |         .key
45 |           .icon.icon-arrow-right
46 |         | next song
47 |       li
48 |         .key.big X
49 |         | remove current song
50 |         
51 |   .third
52 |     ul.unstyled
53 |       li
54 |         .key.big M
55 |         | toggle mute
56 |       li
57 |         .key.big R
58 |         | toggle repeat
59 |       li
60 |         .key.big S
61 |         | toggle shuffle
62 |       li
63 |         .key.big D
64 |         | toggle discovery
65 |       li
66 |         .key.big Q
67 |         | toggle quality
68 | 
69 |   .clear
70 | 


--------------------------------------------------------------------------------
/app/controllers/mail_controller.js:
--------------------------------------------------------------------------------
 1 | module.exports = function(app) {
 2 | 
 3 |   var nodemailer = require('nodemailer')
 4 |   nodemailer.SMTP = { 
 5 |     host: 'localhost'
 6 |   }
 7 | 
 8 |   return {
 9 | 
10 |     resetToken: function(user, next) {
11 |       var link = app.set('base_url') + '/user/' + user.username + '/reset/' + user.reset.token
12 | 
13 |       if (app.set('env') == 'production' || app.set('env') == 'staging') {
14 |         nodemailer.send_mail({
15 |             sender  : '"Jukesy" '
16 |           , to      : user.email
17 |           , subject : 'Reset your password'
18 |           , html    : "

Hello, " + user.username + ".

" 19 | + "

Jukesy received a request to reset the password for your account.

" 20 | + "

If you still wish to reset your password, you may use this link:
" 21 | + "" + link + "

" 22 | + "

If your password does not need to be reset, simply ignore this message.

" 23 | , body : "Hello, " + user.username + ". \n\n" 24 | + "Jukesy received a request to reset the password for your account. \n\n" 25 | + "If you still wish to reset your password, you may use this link: \n" 26 | + link + "\n\n" 27 | + "If your password does not need to be reset, simply ignore this message. \n\n" 28 | }, next) 29 | } else { 30 | next(false, true) 31 | } 32 | } 33 | } 34 | } 35 | 36 | -------------------------------------------------------------------------------- /app/views/home/welcome.jade: -------------------------------------------------------------------------------- 1 | 2 | #search.welcome 3 | .hero-unit 4 | .span6 5 | h2 Watch music videos on jukesy! 6 | p Get started above by searching Last.fm for an artist, album, or track. Or browse from lists of popular artists and tracks below. 7 | .span3 8 | h2 Coming Soon 9 | ul 10 | li tag/genre radio 11 | li Last.fm scrobbling 12 | li favorites 13 | //p.alpha-warning.span9 14 | // i.icon-warning-sign 15 | // | Jukesy is a beta release. Features may change. 16 | .clear 17 | 18 | #search-artists 19 | - if (typeof artists != 'undefined' && artists.length) 20 | h3 Top Artists on Last.fm 21 | .clear 22 | 23 | .artists 24 | ul.thumbnails 25 | .clear 26 | 27 | .clear 28 | hr 29 | 30 | #search-tracks 31 | - if (typeof tracks != 'undefined' && tracks.length) 32 | .btn-group.pull-right 33 | button.btn.btn-primary.btn-large.play-all Play All 34 | button.btn.btn-primary.btn-large.dropdown-toggle('data-toggle'= 'dropdown') 35 | i.icon-caret-down 36 | ul.dropdown-menu 37 | li 38 | a.play-all Play All 39 | li 40 | a.queue-all-next Queue All Next 41 | li 42 | a.queue-all-last Queue All Last 43 | 44 | h3 Top Tracks on Last.fm 45 | .clear 46 | 47 | .tracks 48 | table.table.table-striped 49 | thead 50 | tr 51 | th.dropdown 52 | th.play 53 | th Track 54 | th Artist 55 | tbody 56 | -------------------------------------------------------------------------------- /app/controllers/session_controller.js: -------------------------------------------------------------------------------- 1 | module.exports = function(app) { 2 | var User = app.model('User') 3 | 4 | return { 5 | 6 | create: function(req, res, next) { 7 | User.findByLogin(req.body.login || '', function(err, user) { 8 | if (err || !user) { 9 | return next(err || new app.Error(401, { $: 'bad_credentials' })) 10 | } 11 | 12 | user.checkPassword(req.body.password || '', function(err, valid) { 13 | if (err || !valid) { 14 | return next(err || new app.Error(401, { $: 'bad_credentials' })) 15 | } 16 | 17 | app.auth.setUser(user, req, res) 18 | user.findPlaylists(function(err, playlists) { 19 | user.playlists = playlists 20 | res.json(user.exposeJSON(req.currentUser)) 21 | }) 22 | }) 23 | }) 24 | }, 25 | 26 | delete: function(req, res, next) { 27 | app.auth.unsetUser(req, res) 28 | res.redirect('/') 29 | }, 30 | 31 | // primary session check, used once on frontend loading to set the session up. 32 | // allows untying of session logic from "/" route for better caching of front page. 33 | refresh: function(req, res, next) { 34 | // grab playlists 35 | if (req.currentUser) { 36 | app.auth.setUser(req.currentUser, req, res) 37 | req.currentUser.findPlaylists(function(err, playlists) { 38 | req.currentUser.playlists = playlists 39 | res.json(req.currentUser.exposeJSON(req.currentUser)) 40 | }) 41 | 42 | } else { 43 | app.auth.unsetUser(req, res) 44 | res.json(0, 401) // not logged in 45 | } 46 | } 47 | 48 | } 49 | 50 | } 51 | 52 | 53 | -------------------------------------------------------------------------------- /public/js/error.js: -------------------------------------------------------------------------------- 1 | window.parseError = function(field, error) { 2 | var meta 3 | if (typeof error == 'object') { 4 | meta = error[1] 5 | error = error[0] 6 | } 7 | 8 | switch (error) { 9 | case 'not_logged_in_save': 10 | return 'You must log in to save a playlist.' 11 | case 'not_logged_in_delete': 12 | return 'You must log in to delete a playlist.' 13 | case 'unauthorized': 14 | return 'You\'re not authorized to access that.' 15 | case 'no_connection': 16 | return 'There was an error connecting to the server. Please try again in a few minutes.' 17 | case 'bad_credentials': 18 | return 'Sorry, those credentials do not match any accounts on jukesy.' 19 | case 'no_user_or_email': 20 | return 'That user does not exist on jukesy.' 21 | case 'reset_token_expired': 22 | return 'That reset token is invalid or has expired.' 23 | case 'reset_password_required': 24 | return 'You cannot reset your password without a password.' 25 | case 'reset_password_unconfirmed': 26 | return 'The confirmed password does not match.' 27 | case 'required': 28 | return _.capitalize(field) + ' is required.' 29 | case 'too_long': 30 | return _.capitalize(field) + ' is too long (maxlength: ' + meta.maxlength + ').' 31 | case 'bad_format': 32 | return _.capitalize(field) + ' is not in a recognizable format.' 33 | case 'bad_characters': 34 | return _.capitalize(field) + ' only accepts alphanumeric characters (' + meta.characters + ').' 35 | case 'already_taken': 36 | return _.capitalize(field) + ' is already in use.' 37 | } 38 | return 'Sorry, there was an unknown error. If this persists, please contact us.' 39 | } 40 | 41 | 42 | ; -------------------------------------------------------------------------------- /app/views/layout/meta.jade: -------------------------------------------------------------------------------- 1 | head 2 | title jukesy - watch music videos 3 | 4 | meta(content= 'text/html; charset=utf-8', 'http-equiv'= 'content-type') 5 | meta(content= meta.description, name= 'description') 6 | meta(content= 'Adrian Bravo', name= 'author') 7 | meta(content= 'jukesy, music, music videos, youtube music', name= 'keywords') 8 | meta(content= 'width=device-width, initial-scale=1', name= 'viewport') 9 | 10 | meta(property= 'og:site_name', content= 'jukesy') 11 | meta(property= 'og:title', content= meta.title) 12 | meta(property= 'og:description', content= meta.description) 13 | meta(property= 'og:image', content= meta.image) 14 | meta(property= 'og:type', content= meta.type) 15 | meta(property= 'og:url', content= meta.url) 16 | meta(property= 'fb:app_id', content= '150725614969142') 17 | 18 | link(rel= 'icon', href= assets.favicon + '?' + gitsha, type= 'image/png') 19 | 20 | - each stylesheet in assets.css 21 | link(rel='stylesheet', href= stylesheet + '?' + gitsha, type='text/css') 22 | 23 | - each stylesheet in assets.less 24 | link(rel='stylesheet/less', href= stylesheet + '?' + gitsha, type='text/css') 25 | 26 | script(type= 'text/javascript') 27 | var _gaq = _gaq || []; 28 | _gaq.push(['_setAccount', 'UA-2224441-5']); 29 | _gaq.push(['_trackPageview']); 30 | - if (env == 'production') 31 | script(type= 'text/javascript') 32 | (function() { 33 | var ga = document.createElement('script'); ga.type = 'text/javascript'; ga.async = true; 34 | ga.src = ('https:' == document.location.protocol ? 'https://ssl' : 'http://www') + '.google-analytics.com/ga.js'; 35 | var s = document.getElementsByTagName('script')[0]; s.parentNode.insertBefore(ga, s); 36 | })(); 37 | -------------------------------------------------------------------------------- /public/js/welcome.js: -------------------------------------------------------------------------------- 1 | View.Welcome = Backbone.View.extend({ 2 | template: jade.compile($('#welcome-template').text()), 3 | 4 | events: { 5 | 'click .play-all' : 'playAll', 6 | 'click .queue-all-next' : 'queueNext', 7 | 'click .queue-all-last' : 'queueLast' 8 | }, 9 | 10 | initialize: function(options) { 11 | this.tracks = _.map(Charts.tracks, function(track) { 12 | return new Model.SearchResultTrack(track) 13 | }) 14 | this.artists = _.map(Charts.artists, function(artist) { 15 | return new Model.SearchResultArtist(artist) 16 | }) 17 | }, 18 | 19 | render: function() { 20 | var $tracks, $artists 21 | 22 | this.$el.html(this.template({ tracks: this.tracks, artists: this.artists })) 23 | $tracks = this.$el.find('#search-tracks table tbody') 24 | $artists = this.$el.find('#search-artists ul.thumbnails') 25 | 26 | _.each(this.tracks, function(track) { 27 | track.view.render() 28 | $tracks.append(track.view.$el) 29 | }) 30 | 31 | _.each(this.artists, function(artist) { 32 | artist.view.render() 33 | $artists.append(artist.view.$el) 34 | }) 35 | 36 | return this 37 | }, 38 | 39 | playAll: function() { 40 | newNowPlaying() 41 | NowPlaying.tracks.add(this.cloneTracks()) 42 | NowPlaying.tracks.play() 43 | NowPlaying.navigateTo() 44 | }, 45 | 46 | queueNext: function() { 47 | NowPlaying.tracks.add(this.cloneTracks(), { at: _.indexOf(NowPlaying.tracks.models, Video.track) + 1 }) 48 | }, 49 | 50 | queueLast: function() { 51 | NowPlaying.tracks.add(this.cloneTracks()) 52 | }, 53 | 54 | cloneTracks: function() { 55 | return _.map(this.tracks, function(track) { return track.clone().toJSON() }) 56 | } 57 | }) 58 | 59 | 60 | ; -------------------------------------------------------------------------------- /public/less/bootstrap/bootstrap.less: -------------------------------------------------------------------------------- 1 | /*! 2 | * Bootstrap v2.0.1 3 | * 4 | * Copyright 2012 Twitter, Inc 5 | * Licensed under the Apache License v2.0 6 | * http://www.apache.org/licenses/LICENSE-2.0 7 | * 8 | * Designed and built with all the love in the world @twitter by @mdo and @fat. 9 | */ 10 | 11 | // CSS Reset 12 | @import "reset.less"; 13 | 14 | // Core variables and mixins 15 | @import "variables.less"; // Modify this for custom colors, font-sizes, etc 16 | @import "mixins.less"; 17 | 18 | // Grid system and page structure 19 | @import "scaffolding.less"; 20 | @import "grid.less"; 21 | @import "layouts.less"; 22 | 23 | // Base CSS 24 | @import "type.less"; 25 | @import "code.less"; 26 | @import "forms.less"; 27 | @import "tables.less"; 28 | 29 | // Components: common 30 | //@import "sprites.less"; 31 | @import "font-awesome.less"; 32 | @import "dropdowns.less"; 33 | @import "wells.less"; 34 | @import "component-animations.less"; 35 | @import "close.less"; 36 | 37 | // Components: Buttons & Alerts 38 | @import "buttons.less"; 39 | @import "button-groups.less"; 40 | @import "alerts.less"; // Note: alerts share common CSS with buttons and thus have styles in buttons.less 41 | 42 | // Components: Nav 43 | @import "navs.less"; 44 | @import "navbar.less"; 45 | @import "breadcrumbs.less"; 46 | @import "pagination.less"; 47 | @import "pager.less"; 48 | 49 | // Components: Popovers 50 | @import "modals.less"; 51 | @import "tooltip.less"; 52 | @import "popovers.less"; 53 | 54 | // Components: Misc 55 | @import "thumbnails.less"; 56 | @import "labels.less"; 57 | @import "badges.less"; 58 | @import "progress-bars.less"; 59 | @import "accordion.less"; 60 | @import "carousel.less"; 61 | @import "hero-unit.less"; 62 | 63 | // Utility classes 64 | @import "utilities.less"; // Has to be last to override when necessary 65 | 66 | // Bootswatch 67 | @import "bootswatch.less"; 68 | 69 | // Jukesy 70 | @import "../jukesy/app.less"; 71 | 72 | -------------------------------------------------------------------------------- /public/less/jukesy/spinner.less: -------------------------------------------------------------------------------- 1 | .spinner(@ith, @total, @time) { 2 | -webkit-transform: rotate(@ith * (360deg / @total)) translate(0, -142%); -webkit-animation-delay: (@ith * (@time / @total)); 3 | -moz-transform: rotate(@ith * (360deg / @total)) translate(0, -142%); -moz-animation-delay: (@ith * (@time / @total)); 4 | -ms-transform: rotate(@ith * (360deg / @total)) translate(0, -142%); -ms-animation-delay: (@ith * (@time / @total)); 5 | -o-transform: rotate(@ith * (360deg / @total)) translate(0, -142%); -o-animation-delay: (@ith * (@time / @total)); 6 | transform: rotate(@ith * (360deg / @total)) translate(0, -142%); animation-delay: (@ith * (@time / @total)); 7 | } 8 | 9 | .spinner { 10 | position: relative; 11 | width: 54px; 12 | height: 54px; 13 | display: inline-block; 14 | //background: #222; 15 | border-radius: 10px; 16 | margin: 30px 0; 17 | 18 | div { 19 | width: 12%; 20 | height: 26%; 21 | background: @blueDark; 22 | position: absolute; 23 | left: 44.5%; 24 | top: 37%; 25 | opacity: 0; 26 | -webkit-animation: fade 1s linear infinite; 27 | -moz-animation: fade 1s linear infinite; 28 | .border-radius(50px); 29 | .box-shadow(0 0 3px rgba(0,0,0,0.2)); 30 | } 31 | 32 | .bar1 { .spinner(0, 12, 1s); } 33 | .bar2 { .spinner(1, 12, 1s); } 34 | .bar3 { .spinner(2, 12, 1s); } 35 | .bar4 { .spinner(3, 12, 1s); } 36 | .bar5 { .spinner(4, 12, 1s); } 37 | .bar6 { .spinner(5, 12, 1s); } 38 | .bar7 { .spinner(6, 12, 1s); } 39 | .bar8 { .spinner(7, 12, 1s); } 40 | .bar9 { .spinner(8, 12, 1s); } 41 | .bar10 { .spinner(9, 12, 1s); } 42 | .bar11 { .spinner(10, 12, 1s); } 43 | .bar12 { .spinner(11, 12, 1s); } 44 | 45 | @-webkit-keyframes fade { 46 | from {opacity: .8;} 47 | to {opacity: 0.0;} 48 | } 49 | @-moz-keyframes fade { 50 | from {opacity: .8;} 51 | to {opacity: 0.0;} 52 | } 53 | } 54 | -------------------------------------------------------------------------------- /test/session_controller.test.js: -------------------------------------------------------------------------------- 1 | describe('Session Controller', function() { 2 | 3 | var User = app.model('user') 4 | 5 | beforeEach(function(done) { 6 | User.find().remove() 7 | done() 8 | }) 9 | 10 | describe('POST /session (#create)', function() { 11 | var user 12 | 13 | beforeEach(function(done) { 14 | User.create({ 15 | username: 'test', 16 | password: 'test', 17 | email: 'test@test.test' 18 | }, function(err, u) { 19 | user = u 20 | expect(user).to.exist 21 | done() 22 | }) 23 | }) 24 | 25 | it('returns 200 when passed valid username and password', function(done) { 26 | request.post('/session', { 27 | login: 'test', 28 | password: 'test' 29 | }, function(res) { 30 | expect(res).status(200) 31 | done() 32 | }) 33 | }) 34 | 35 | it('returns 200 when passed valid email and password', function(done) { 36 | request.post('/session', { 37 | login: 'test@test.test', 38 | password: 'test' 39 | }, function(res) { 40 | expect(res).status(200) 41 | done() 42 | }) 43 | }) 44 | 45 | it('returns 401 when passed invalid login and password', function(done) { 46 | request.post('/session', { 47 | login: 'invalid', 48 | password: 'test' 49 | }, function(res) { 50 | expect(res).status(401) 51 | done() 52 | }) 53 | }) 54 | 55 | it('returns 401 when passed no login or password', function(done) { 56 | request.post('/session', { 57 | }, function(res) { 58 | expect(res).status(401) 59 | done() 60 | }) 61 | }) 62 | 63 | }) 64 | 65 | describe('GET /session/refresh (#refresh)', function() { 66 | // returns 200 when logged in 67 | // returns 400 when not logged in 68 | // use cookie with _remember token 69 | }) 70 | 71 | describe('DEL /session (#delete)', function() { 72 | // logs out user 73 | // should redirect and unset user cookie 74 | }) 75 | 76 | }) 77 | -------------------------------------------------------------------------------- /lib/lastfm_cache.js: -------------------------------------------------------------------------------- 1 | var app = require('../') 2 | , qs = require('qs') 3 | , request = require('request') 4 | , util = require('util') 5 | 6 | module.exports = { 7 | queueSize: 25, 8 | queue: [], 9 | 10 | initQueue: function() { 11 | var self = this 12 | setInterval(function() { 13 | self.dequeue() 14 | }, 200) 15 | }, 16 | 17 | dequeue: function() { 18 | if (this.queue.length) { 19 | var key = this.queue.pop() 20 | this.fetchFromAPI(key) 21 | } 22 | }, 23 | 24 | enqueue: function(key) { 25 | if (this.queue.length < this.queueSize) { 26 | this.queue.push(key) 27 | } 28 | }, 29 | 30 | get: function(key, options) { 31 | var self = this 32 | app.model('Lastfm_cache').findOne(key, function(err, value) { 33 | if (err || !value) { 34 | options.error(err) 35 | self.enqueue(key) 36 | return 37 | } 38 | 39 | options.success(value.json) 40 | if (value.expiry < new Date) { 41 | self.enqueue(key) 42 | } 43 | }) 44 | }, 45 | 46 | fetchFromAPI: function(params) { 47 | var extraParams = { 48 | api_key: '75c8c3065db32d805a292ec1af5631a3', 49 | autocorrect: 1, 50 | format: 'json' 51 | } 52 | 53 | request.get({ 54 | url: 'http://ws.audioscrobbler.com/2.0/?' + qs.stringify(params) + '&' + qs.stringify(extraParams), 55 | headers: { 56 | 'User-Agent': 'jukesy.com' 57 | } 58 | }, this.fetchComplete(params)) 59 | }, 60 | 61 | fetchComplete: function(params) { 62 | return function(err, res, body) { 63 | var cache = app.model('Lastfm_cache') 64 | if (err) { 65 | return 66 | } 67 | 68 | try { 69 | var index = app._.clone(params) 70 | params.json = JSON.parse(body) 71 | cache.create(params, function(err, value) { 72 | if (value) { 73 | console.log('Cached', index) 74 | } 75 | }) 76 | } catch(e) { 77 | console.log('Cache error', e, "\n\n", 'on index', index) 78 | } 79 | } 80 | } 81 | 82 | } 83 | 84 | -------------------------------------------------------------------------------- /app/models/playlist.js: -------------------------------------------------------------------------------- 1 | var mongoose = require('mongoose') 2 | , Schema = mongoose.Schema 3 | , ObjectId = Schema.ObjectId 4 | , Mixed = Schema.Types.Mixed 5 | , app = require('../../') 6 | , validators = app.mongooseValidators 7 | 8 | var Playlist = module.exports = new Schema({ 9 | user : { type: String, set: app.mongooseSetters.toLower, index: true }, 10 | name : { type: String, default: 'Untitled Playlist' }, 11 | sidebar : { type: Boolean, default: false }, 12 | autosave : { type: Boolean, default: true }, 13 | tracks_count : { type: Number }, 14 | tracks : { type: Array, default: [] } 15 | }, { strict: true }) 16 | 17 | Playlist.plugin(app.mongoosePlugins.timestamps) 18 | Playlist.plugin(app.mongoosePlugins.accessible, [ 'name', 'tracks', 'sidebar', 'autosave' ]) 19 | 20 | Playlist.method({ 21 | exposeJSON: function() { 22 | var json = { 23 | _id : this._id, 24 | user : this.user, 25 | name : this.name, 26 | sidebar : this.sidebar, 27 | autosave : this.autosave, 28 | tracks : this.tracks, 29 | tracks_count : this.tracks_count, 30 | time : this.time, 31 | url : this.url() 32 | } 33 | return json 34 | }, 35 | 36 | url: function() { 37 | return app.set('base_url') + '/user/' + this.user + '/playlist/' + this.id 38 | }, 39 | 40 | addTracks: function(tracks, next) { 41 | tracks.unshift(this.tracks.length, 0) 42 | this.tracks.splice.apply(this.tracks, tracks) 43 | this.save(next) 44 | } 45 | }) 46 | 47 | Playlist.static({ 48 | }) 49 | 50 | Playlist.pre('validate', function(next) { 51 | if (this.isNew) { 52 | this.user = this.user || '' 53 | } 54 | next() 55 | }) 56 | 57 | Playlist.pre('save', function(next) { 58 | this.tracks_count = this.tracks.length 59 | next() 60 | }) 61 | 62 | // 63 | // Validators 64 | // 65 | 66 | // User 67 | Playlist.path('user').validate(validators.required, [ 'required' ]) 68 | 69 | // Name 70 | Playlist.path('name').validate(validators.required, [ 'required' ]) 71 | Playlist.path('name').validate(validators.tooLong(50), [ 'too_long', { maxlength: 50 } ]) 72 | 73 | mongoose.model('Playlist', Playlist) 74 | -------------------------------------------------------------------------------- /public/js/boot.js: -------------------------------------------------------------------------------- 1 | // Underscore mixins 2 | _.mixin({ 3 | capitalize: function(string) { 4 | string = '' + string; 5 | return string.charAt(0).toUpperCase() + string.substring(1).toLowerCase(); 6 | }, 7 | plural: function(count, singular, plural) { 8 | return Math.abs(count) == 1 ? singular : plural; 9 | }, 10 | clean: function(str) { 11 | return _s.strip((''+str).replace(/\s+/g, ' ')); 12 | } 13 | }); 14 | 15 | window.Collection = {}; 16 | window.Model = {}; 17 | window.View = {}; 18 | window.Mixins = {}; 19 | 20 | // Called when youtube chromeless player is ready 21 | function onYouTubePlayerReady(e) { 22 | // Set the video player elements and bind its change and error events. 23 | Video.player = e.target; 24 | Video.volume(50); 25 | } 26 | 27 | function onYouTubeIframeAPIReady() { 28 | var player = new YT.Player('video', { 29 | height: '270', 30 | width: '480', 31 | playerVars: { 32 | controls: 0 33 | }, 34 | events: { 35 | onReady: onYouTubePlayerReady, 36 | onStateChange: function() { window.Video.onStateChange(); }, 37 | onError: function() { window.Video.onError(); } 38 | } 39 | }); 40 | } 41 | 42 | // Redraws on resize 43 | var windowResized = function() { 44 | if (window.Controls) { 45 | //Controls.updateTimer() 46 | } 47 | 48 | if (window.Video && Video.fullscreen) { 49 | var $video = $('#video'); 50 | $video.height($(window).height() - parseInt($('#controls').height(), 10)); 51 | $video.width($(window).width()); 52 | $body.width($video.width()); 53 | $body.height($video.height()); 54 | Video.player.setSize($video.width(), $video.height()); 55 | } 56 | }; 57 | 58 | function urlTrack(artist, track) { 59 | return '/artist/' + encodeURIComponent(artist) + '/track/' + encodeURIComponent(track) 60 | } 61 | 62 | function urlAlbum(artist, album) { 63 | return '/artist/' + encodeURIComponent(artist) + '/album/' + encodeURIComponent(album) 64 | } 65 | 66 | function urlArtist(artist) { 67 | return '/artist/' + encodeURIComponent(artist) 68 | } 69 | 70 | function newNowPlaying() { 71 | var playlist = new Model.Playlist() 72 | Playlists.add([ playlist ]) 73 | playlist.setNowPlaying() 74 | } 75 | 76 | 77 | ; 78 | -------------------------------------------------------------------------------- /public/less/bootstrap/modals.less: -------------------------------------------------------------------------------- 1 | // MODALS 2 | // ------ 3 | 4 | // Recalculate z-index where appropriate 5 | .modal-open { 6 | .modal { 7 | .dropdown-menu { z-index: @zindexDropdown + @zindexModal; } 8 | .dropdown.open { *z-index: @zindexDropdown + @zindexModal; } 9 | .popover { z-index: @zindexPopover + @zindexModal; } 10 | .tooltip { z-index: @zindexTooltip + @zindexModal; } 11 | } 12 | } 13 | 14 | // Background 15 | .modal-backdrop { 16 | position: fixed; 17 | top: 0; 18 | right: 0; 19 | bottom: 0; 20 | left: 0; 21 | z-index: @zindexModalBackdrop; 22 | background-color: @black; 23 | // Fade for backdrop 24 | &.fade { opacity: 0; } 25 | } 26 | 27 | .modal-backdrop, 28 | .modal-backdrop.fade.in { 29 | .opacity(80); 30 | } 31 | 32 | // Base modal 33 | .modal { 34 | position: fixed; 35 | top: 50%; 36 | left: 50%; 37 | z-index: @zindexModal; 38 | max-height: 500px; 39 | overflow: auto; 40 | width: 560px; 41 | margin: -250px 0 0 -280px; 42 | background-color: @grayDarker; 43 | border: 1px solid #999; 44 | border: 1px solid rgba(0,0,0,.3); 45 | *border: 1px solid #999; /* IE6-7 */ 46 | .border-radius(6px); 47 | .box-shadow(0 3px 7px rgba(0,0,0,0.3)); 48 | .background-clip(padding-box); 49 | &.fade { 50 | .transition(e('opacity .3s linear, top .3s ease-out')); 51 | top: -25%; 52 | } 53 | &.fade.in { top: 50%; } 54 | } 55 | .modal-header { 56 | padding: 9px 15px; 57 | border-bottom: 1px solid @grayDarker; 58 | background-color: @grayDark; 59 | color: @blue; 60 | // Close icon 61 | .close { margin-top: 2px; } 62 | } 63 | 64 | // Body (where all modal content resises) 65 | .modal-body { 66 | padding: 15px; 67 | background-color: #222; 68 | } 69 | // Remove bottom margin if need be 70 | .modal-body .modal-form { 71 | margin-bottom: 0; 72 | } 73 | 74 | // Footer (for actions) 75 | .modal-footer { 76 | padding: 14px 15px 15px; 77 | margin-bottom: 0; 78 | background-color: @grayDark; 79 | //border-top: 1px solid #ddd; 80 | .border-radius(0 0 6px 6px); 81 | //.box-shadow(inset 0 1px 0 @white); 82 | .clearfix(); 83 | .btn { 84 | float: right; 85 | margin-left: 5px; 86 | margin-bottom: 0; // account for input[type="submit"] which gets the bottom margin like all other inputs 87 | } 88 | } 89 | -------------------------------------------------------------------------------- /public/less/bootstrap/progress-bars.less: -------------------------------------------------------------------------------- 1 | // PROGRESS BARS 2 | // ------------- 3 | 4 | 5 | // ANIMATIONS 6 | // ---------- 7 | 8 | // Webkit 9 | @-webkit-keyframes progress-bar-stripes { 10 | from { background-position: 0 0; } 11 | to { background-position: 40px 0; } 12 | } 13 | 14 | // Firefox 15 | @-moz-keyframes progress-bar-stripes { 16 | from { background-position: 0 0; } 17 | to { background-position: 40px 0; } 18 | } 19 | 20 | // Spec 21 | @keyframes progress-bar-stripes { 22 | from { background-position: 0 0; } 23 | to { background-position: 40px 0; } 24 | } 25 | 26 | 27 | 28 | // THE BARS 29 | // -------- 30 | 31 | // Outer container 32 | .progress { 33 | overflow: hidden; 34 | height: 18px; 35 | margin-bottom: 18px; 36 | #gradient > .vertical(#f5f5f5, #f9f9f9); 37 | .box-shadow(inset 0 1px 2px rgba(0,0,0,.1)); 38 | .border-radius(4px); 39 | } 40 | 41 | // Bar of progress 42 | .progress .bar { 43 | width: 0%; 44 | height: 18px; 45 | color: @white; 46 | font-size: 12px; 47 | text-align: center; 48 | text-shadow: 0 -1px 0 rgba(0,0,0,.25); 49 | #gradient > .vertical(#149bdf, #0480be); 50 | .box-shadow(inset 0 -1px 0 rgba(0,0,0,.15)); 51 | .box-sizing(border-box); 52 | .transition(width .6s ease); 53 | } 54 | 55 | // Striped bars 56 | .progress-striped .bar { 57 | #gradient > .striped(#62c462); 58 | .background-size(40px 40px); 59 | } 60 | 61 | // Call animation for the active one 62 | .progress.active .bar { 63 | -webkit-animation: progress-bar-stripes 2s linear infinite; 64 | -moz-animation: progress-bar-stripes 2s linear infinite; 65 | animation: progress-bar-stripes 2s linear infinite; 66 | } 67 | 68 | 69 | 70 | // COLORS 71 | // ------ 72 | 73 | // Danger (red) 74 | .progress-danger .bar { 75 | #gradient > .vertical(#ee5f5b, #c43c35); 76 | } 77 | .progress-danger.progress-striped .bar { 78 | #gradient > .striped(#ee5f5b); 79 | } 80 | 81 | // Success (green) 82 | .progress-success .bar { 83 | #gradient > .vertical(#62c462, #57a957); 84 | } 85 | .progress-success.progress-striped .bar { 86 | #gradient > .striped(#62c462); 87 | } 88 | 89 | // Info (teal) 90 | .progress-info .bar { 91 | #gradient > .vertical(#5bc0de, #339bb9); 92 | } 93 | .progress-info.progress-striped .bar { 94 | #gradient > .striped(#5bc0de); 95 | } 96 | -------------------------------------------------------------------------------- /public/less/jukesy/controls.less: -------------------------------------------------------------------------------- 1 | #controls { 2 | .no-select(); 3 | z-index: @zindexControls; 4 | position: fixed; 5 | background: #111; 6 | height: 50px; 7 | top: auto; 8 | bottom: 0; 9 | left: 0; 10 | right: 0; 11 | margin: 0; 12 | font-size: 12px; 13 | border-top: 1px solid #222; 14 | 15 | .control { 16 | display: inline-block; 17 | padding: 3px 8px; 18 | width: 10px; 19 | margin: 3px 0; 20 | cursor: pointer; 21 | color: @blueDark; 22 | 23 | .icon-resize-full, 24 | &.off { 25 | opacity: 0.33; 26 | } 27 | .icon-resize-small { 28 | opacity: 1; 29 | } 30 | &.disabled { 31 | opacity: 0.1; 32 | } 33 | &:hover { 34 | color: @blue; 35 | } 36 | } 37 | 38 | #volume-bar .bar, 39 | #timer .track { 40 | background-color: @blueDark; 41 | } 42 | 43 | #volume-bar, #timer { 44 | padding: 0; 45 | margin: 0; 46 | cursor: default; 47 | background-color: #1c1c1c; 48 | height: 10px; 49 | .fill, 50 | .track { 51 | height: 10px; 52 | radius:(0px); 53 | } 54 | } 55 | 56 | #timer { 57 | width: 575px; 58 | .fill, .track { 59 | position: absolute; 60 | } 61 | .fill { 62 | background-color: @grayDark; 63 | } 64 | .track { 65 | z-index: @zindexControls + 1; 66 | } 67 | } 68 | 69 | #volume-bar { 70 | width: 100px; 71 | .fill { 72 | width: 50px; 73 | } 74 | } 75 | 76 | .span12 { 77 | position: absolute; 78 | &.controls { 79 | bottom: 30px; 80 | } 81 | 82 | #time-read { 83 | position: absolute; 84 | right: 120px; 85 | top: 7px; 86 | font-size: 11px; 87 | color: @grayLight; 88 | 89 | .time-current { text-align: right; } 90 | .time-current, 91 | .time-duration { 92 | display: inline-block; 93 | width: 40px; 94 | } 95 | } 96 | .track-info { 97 | padding: 5px 0; 98 | text-align: center; 99 | width: 100%; 100 | } 101 | } 102 | 103 | .controls-left, .controls-right, .controls-middle { 104 | position: absolute; 105 | height: 30px; 106 | } 107 | 108 | .controls-middle { 109 | left: 240px; 110 | top: 6px; 111 | } 112 | 113 | .controls-right { 114 | right: 0; 115 | } 116 | } 117 | -------------------------------------------------------------------------------- /public/less/bootstrap/carousel.less: -------------------------------------------------------------------------------- 1 | // CAROUSEL 2 | // -------- 3 | 4 | .carousel { 5 | position: relative; 6 | margin-bottom: @baseLineHeight; 7 | line-height: 1; 8 | } 9 | 10 | .carousel-inner { 11 | overflow: hidden; 12 | width: 100%; 13 | position: relative; 14 | } 15 | 16 | .carousel { 17 | 18 | .item { 19 | display: none; 20 | position: relative; 21 | .transition(.6s ease-in-out left); 22 | } 23 | 24 | // Account for jankitude on images 25 | .item > img { 26 | display: block; 27 | line-height: 1; 28 | } 29 | 30 | .active, 31 | .next, 32 | .prev { display: block; } 33 | 34 | .active { 35 | left: 0; 36 | } 37 | 38 | .next, 39 | .prev { 40 | position: absolute; 41 | top: 0; 42 | width: 100%; 43 | } 44 | 45 | .next { 46 | left: 100%; 47 | } 48 | .prev { 49 | left: -100%; 50 | } 51 | .next.left, 52 | .prev.right { 53 | left: 0; 54 | } 55 | 56 | .active.left { 57 | left: -100%; 58 | } 59 | .active.right { 60 | left: 100%; 61 | } 62 | 63 | } 64 | 65 | // Left/right controls for nav 66 | // --------------------------- 67 | 68 | .carousel-control { 69 | position: absolute; 70 | top: 40%; 71 | left: 15px; 72 | width: 40px; 73 | height: 40px; 74 | margin-top: -20px; 75 | font-size: 60px; 76 | font-weight: 100; 77 | line-height: 30px; 78 | color: @white; 79 | text-align: center; 80 | background: @grayDarker; 81 | border: 3px solid @white; 82 | .border-radius(23px); 83 | .opacity(50); 84 | 85 | // we can't have this transition here 86 | // because webkit cancels the carousel 87 | // animation if you trip this while 88 | // in the middle of another animation 89 | // ;_; 90 | // .transition(opacity .2s linear); 91 | 92 | // Reposition the right one 93 | &.right { 94 | left: auto; 95 | right: 15px; 96 | } 97 | 98 | // Hover state 99 | &:hover { 100 | color: @white; 101 | text-decoration: none; 102 | .opacity(90); 103 | } 104 | } 105 | 106 | // Caption for text below images 107 | // ----------------------------- 108 | 109 | .carousel-caption { 110 | position: absolute; 111 | left: 0; 112 | right: 0; 113 | bottom: 0; 114 | padding: 10px 15px 5px; 115 | background: @grayDark; 116 | background: rgba(0,0,0,.75); 117 | } 118 | .carousel-caption h4, 119 | .carousel-caption p { 120 | color: @white; 121 | } 122 | -------------------------------------------------------------------------------- /config/boot.js: -------------------------------------------------------------------------------- 1 | Error.stackTraceLimit = Infinity; 2 | 3 | var fs = require('fs') 4 | , exec = require('child_process').exec 5 | , async = require('async') 6 | 7 | module.exports = function(app, bootCallback) { 8 | var config = require('./')(app) 9 | 10 | async.series([ 11 | 12 | // Set gitsha 13 | function(next) { 14 | if (app.set('env') == 'test') { 15 | return next() 16 | } 17 | 18 | exec('git rev-parse --short HEAD', function(err, gitsha) { 19 | if (err) { 20 | console.error('Failed to get gitsha') 21 | return 22 | } 23 | app.gitsha = gitsha 24 | next() 25 | }) 26 | }, 27 | 28 | // Connect to mongodb 29 | function(next) { 30 | require('./mongodb')(app, next) 31 | }, 32 | 33 | // Load models 34 | function(next) { 35 | var ext = '.js' 36 | fs.readdirSync(__dirname + '/../app/models').forEach(function(filename) { 37 | if (!filename.match(ext + '$')) { 38 | console.error('Failed to load model:', filename) 39 | return 40 | } 41 | require(__dirname + '/../app/models/' + filename) 42 | }) 43 | next() 44 | }, 45 | 46 | // Load controllers 47 | function(next) { 48 | app.controllers = {} 49 | 50 | var ext = '_controller.js' 51 | fs.readdirSync(__dirname + '/../app/controllers').forEach(function(filename) { 52 | if (!filename.match(ext + '$')) { 53 | return 54 | } 55 | var controller = require(__dirname + '/../app/controllers/' + filename)(app) 56 | , controllerName = '' 57 | filename.replace(new RegExp(ext + '$'), '').split('_').forEach(function(token) { 58 | controllerName += app._.capitalize(token) 59 | }) 60 | app.controllers[controllerName] = controller 61 | }) 62 | next() 63 | }, 64 | 65 | // Express 66 | function(next) { 67 | require('./express')(app) 68 | next() 69 | }, 70 | 71 | // Bootstrap charts JSON 72 | function(next) { 73 | var artists, tracks 74 | try { 75 | artists = JSON.parse(fs.readFileSync(__dirname + '/../public/chart/topartists.json')) 76 | tracks = JSON.parse(fs.readFileSync(__dirname + '/../public/chart/toptracks.json')) 77 | } catch (e) { 78 | console.error('Error: Could not bootstrap top charts JSON.') 79 | } 80 | app.charts = JSON.stringify({ artists: artists, tracks: tracks }) 81 | next() 82 | } 83 | 84 | ], bootCallback) 85 | 86 | } 87 | -------------------------------------------------------------------------------- /app/views/playlist/show.jade: -------------------------------------------------------------------------------- 1 | - if (!playlist._id || (currentUser && currentUser.username == playlist.user)) 2 | .btn-group.pull-right 3 | button.btn.btn-large.btn-info.playlist-save 4 | i.icon-upload-alt 5 | | Save 6 | button.btn.btn-large.btn-info.dropdown-toggle('data-toggle'= 'dropdown') 7 | i.icon-caret-down 8 | ul.dropdown-menu 9 | li 10 | a.playlist-save 11 | i.icon-upload-alt 12 | | Save 13 | - if (playlist._id) 14 | li 15 | a.playlist-autosave 16 | i.icon-refresh 17 | - if (playlist.autosave) 18 | | Disable Autosave 19 | - else 20 | | Enable Autosave 21 | li 22 | a.playlist-sidebar 23 | - if (playlist.sidebar) 24 | i.icon-star-empty 25 | | Remove from Sidebar 26 | - else 27 | i.icon-star 28 | | Add to Sidebar 29 | li 30 | a.playlist-delete 31 | i.icon-trash 32 | | Delete 33 | 34 | 35 | - if (!playlist.nowPlaying) 36 | .btn-group.pull-right 37 | button.btn.btn-primary.btn-large.play-all Play All 38 | button.btn.btn-primary.btn-large.dropdown-toggle('data-toggle'= 'dropdown') 39 | i.icon-caret-down 40 | ul.dropdown-menu 41 | li 42 | a.play-all Play All 43 | li 44 | a.queue-all-next Queue All Next 45 | li 46 | a.queue-all-last Queue All Last 47 | - if (playlist._id) 48 | li.divider 49 | li 50 | a.add-to-playlist Add to Playlist 51 | //li.divider 52 | //li 53 | // a.share Share 54 | - else if (playlist._id) 55 | //button.btn.btn-primary.btn-large.pull-right.share 56 | // i.icon-share 57 | // | Share 58 | 59 | - if (editName) 60 | input.playlist-name-edit(maxlength= '50', name= 'playlist-name', value= playlist.name) 61 | - else 62 | div.playlist-name #{playlist.name} 63 | 64 | - if (playlist.nowPlaying) 65 | span.label.label-success listening 66 | - if (playlist.changed) 67 | span.label.label-warning changed 68 | - if (!playlist._id) 69 | span.label.label-important new 70 | 71 | - if (playlist._id) 72 | p Created by 73 | a(href= '/user/' + playlist.user) #{playlist.user} 74 | | , #{playlist.tracks_count} #{_.plural(playlist.tracks_count, 'track', 'tracks')} 75 | 76 | .clear 77 | 78 | - if (playlist.tracks_count) 79 | .tracks 80 | table.playlist.table.table-striped 81 | thead 82 | tr 83 | th.dd 84 | th.play 85 | th Track 86 | th Artist 87 | tbody 88 | - else 89 | br 90 | .hero-unit 91 | h2 This playlist is empty. 92 | -------------------------------------------------------------------------------- /config/express.js: -------------------------------------------------------------------------------- 1 | var _ = require('underscore') 2 | , moment = require('moment') 3 | , fs = require('fs') 4 | , express = require('express') 5 | , MongoStore = require('connect-mongo') 6 | 7 | module.exports = function(app) { 8 | 9 | app.set('base_url', { 10 | development : 'http://jukesy.local', 11 | test : 'http://jukesy.test:7357', 12 | staging : 'http://staging.jukesy.com', 13 | production : 'http://jukesy.com' 14 | }[app.set('env')]) 15 | 16 | app.set('port', { 17 | development : '80', 18 | test : '7357', 19 | staging : '4000', 20 | production : '3000' 21 | }[app.set('env')]) 22 | 23 | app.meta = function(meta) { 24 | return app._.extend({ 25 | title : 'jukesy - watch music videos', 26 | description : 'Jukesy is an application that helps you watch music videos from YouTube. ' + 27 | 'With Jukesy, you can create playlists, discover new music, listen to your favorite albums, and more.', 28 | image : 'http://static2.jukesy.com/img/jukesy-256.png', 29 | type : 'website', 30 | url : app.set('base_url') 31 | }, meta) 32 | } 33 | 34 | app.configure('development', 'staging', 'production', function() { 35 | app.use(express.logger('dev')) 36 | }) 37 | 38 | app.configure(function() { 39 | app.dynamicHelpers({ 40 | jadeLiteral: function(req, res) { 41 | return function(filename) { 42 | return fs.readFileSync(__dirname + '/../app/views/' + filename + '.jade', 'utf8') 43 | } 44 | }, 45 | // let's just put all the hacks in one place 46 | env: function() { return app.set('env') }, 47 | assets: function() { return app.assets }, 48 | gitsha: function() { return app.gitsha }, 49 | currentUser: function(req, res) { return req.currentUser }, 50 | moment: function() { return app.moment }, 51 | _: function() { return app._ }, 52 | Charts: function() { return app.charts }, 53 | baseUrl: function() { return app.set('base_url') } 54 | }) 55 | 56 | app 57 | .set('host', 'localhost') 58 | .set('views', __dirname + '/../app/views') 59 | .set('view engine', 'jade') 60 | 61 | app 62 | .use(express.static(__dirname + '/../public')) 63 | .use(express.bodyParser()) 64 | .use(express.cookieParser()) 65 | .use(express.session({ 66 | secret: 'jukesy', 67 | cookie: { 68 | expires: new Date(Date.now() + 86400000) 69 | }, 70 | store: new MongoStore({ 71 | auto_reconnect: true, 72 | host: app.mongodb.host, 73 | db: 'jukesy-sessions' 74 | }) 75 | })) 76 | .use(express.errorHandler({ dumpExceptions: true, showStack: true })) 77 | .use(express.methodOverride()) 78 | .use(app.router) 79 | }) 80 | 81 | } 82 | 83 | -------------------------------------------------------------------------------- /app/controllers/playlist_controller.js: -------------------------------------------------------------------------------- 1 | module.exports = function(app) { 2 | var User = app.model('User') 3 | , Playlist = app.model('Playlist') 4 | 5 | return { 6 | 7 | index: function(req, res, next) { 8 | req.paramUser.findPlaylists(function(err, playlists) { 9 | if (err || !playlists) { 10 | return next(new app.Error(err || 500)) 11 | } 12 | 13 | if (req.xhr) { 14 | res.json( 15 | app._.map(playlists, function(playlist) { 16 | return playlist.exposeJSON() 17 | }) 18 | ) 19 | } else { 20 | res.render('playlist/index', { 21 | user: req.paramUser.username, 22 | playlists: app._.map(playlists, function(playlist) { 23 | return playlist.exposeJSON() 24 | }), 25 | meta: app.meta({ 26 | title: req.paramUser.username + '\'s playlists', 27 | url: req.paramUser.url() + '/playlist' 28 | }) 29 | }) 30 | } 31 | }) 32 | }, 33 | 34 | read: function(req, res, next) { 35 | if (req.xhr) { 36 | res.json(req.paramPlaylist) 37 | } else { 38 | res.render('playlist/show', { 39 | playlist: req.paramPlaylist.exposeJSON(), 40 | nowPlaying: false, 41 | editName: false, 42 | meta: app.meta({ 43 | title: req.paramPlaylist.name + ' - a playlist by ' + req.paramUser.username, 44 | image: app.set('base_url') + '/img/jukesy-play.png', 45 | url: null 46 | }) 47 | 48 | }) 49 | } 50 | }, 51 | 52 | create: function(req, res, next) { 53 | req.body.user = req.currentUser.username 54 | Playlist.create(req.body, function(err, playlist) { 55 | if (err || !playlist) { 56 | return next(new app.Error(err || 500)) 57 | } 58 | res.json(playlist.exposeJSON()) 59 | }) 60 | }, 61 | 62 | update: function(req, res, next) { 63 | req.paramPlaylist.updateAttributes(req.body) 64 | req.paramPlaylist.save(function(err, playlist) { 65 | if (err || !playlist) { 66 | return next(new app.Error(err || 500)) 67 | } 68 | res.json(playlist.exposeJSON()) 69 | }) 70 | }, 71 | 72 | delete: function(req, res, next) { 73 | req.paramPlaylist.remove(function(err) { 74 | if (err) { 75 | return next(new app.Error(err || 500)) 76 | } 77 | res.json(1) 78 | }) 79 | }, 80 | 81 | add: function(req, res, next) { 82 | req.paramPlaylist.addTracks(req.body.tracks || [], function(err, playlist) { 83 | if (err || !playlist) { 84 | return next(new app.Error(err || 500)) 85 | } 86 | res.json(playlist.exposeJSON()) 87 | }) 88 | } 89 | 90 | } 91 | 92 | } 93 | 94 | -------------------------------------------------------------------------------- /public/less/bootstrap/reset.less: -------------------------------------------------------------------------------- 1 | // Reset.less 2 | // Adapted from Normalize.css http://github.com/necolas/normalize.css 3 | // ------------------------------------------------------------------------ 4 | 5 | // Display in IE6-9 and FF3 6 | // ------------------------- 7 | 8 | article, 9 | aside, 10 | details, 11 | figcaption, 12 | figure, 13 | footer, 14 | header, 15 | hgroup, 16 | nav, 17 | section { 18 | display: block; 19 | } 20 | 21 | // Display block in IE6-9 and FF3 22 | // ------------------------- 23 | 24 | audio, 25 | canvas, 26 | video { 27 | display: inline-block; 28 | *display: inline; 29 | *zoom: 1; 30 | } 31 | 32 | // Prevents modern browsers from displaying 'audio' without controls 33 | // ------------------------- 34 | 35 | audio:not([controls]) { 36 | display: none; 37 | } 38 | 39 | // Base settings 40 | // ------------------------- 41 | 42 | html { 43 | font-size: 100%; 44 | -webkit-text-size-adjust: 100%; 45 | -ms-text-size-adjust: 100%; 46 | } 47 | // Focus states 48 | a:focus { 49 | .tab-focus(); 50 | } 51 | // Hover & Active 52 | a:hover, 53 | a:active { 54 | outline: 0; 55 | } 56 | 57 | // Prevents sub and sup affecting line-height in all browsers 58 | // ------------------------- 59 | 60 | sub, 61 | sup { 62 | position: relative; 63 | font-size: 75%; 64 | line-height: 0; 65 | vertical-align: baseline; 66 | } 67 | sup { 68 | top: -0.5em; 69 | } 70 | sub { 71 | bottom: -0.25em; 72 | } 73 | 74 | // Img border in a's and image quality 75 | // ------------------------- 76 | 77 | img { 78 | max-width: 100%; 79 | height: auto; 80 | border: 0; 81 | -ms-interpolation-mode: bicubic; 82 | } 83 | 84 | // Forms 85 | // ------------------------- 86 | 87 | // Font size in all browsers, margin changes, misc consistency 88 | button, 89 | input, 90 | select, 91 | textarea { 92 | margin: 0; 93 | font-size: 100%; 94 | vertical-align: middle; 95 | } 96 | button, 97 | input { 98 | *overflow: visible; // Inner spacing ie IE6/7 99 | line-height: normal; // FF3/4 have !important on line-height in UA stylesheet 100 | } 101 | button::-moz-focus-inner, 102 | input::-moz-focus-inner { // Inner padding and border oddities in FF3/4 103 | padding: 0; 104 | border: 0; 105 | } 106 | button, 107 | input[type="button"], 108 | input[type="reset"], 109 | input[type="submit"] { 110 | cursor: pointer; // Cursors on all buttons applied consistently 111 | -webkit-appearance: button; // Style clickable inputs in iOS 112 | } 113 | input[type="search"] { // Appearance in Safari/Chrome 114 | -webkit-appearance: textfield; 115 | -webkit-box-sizing: content-box; 116 | -moz-box-sizing: content-box; 117 | box-sizing: content-box; 118 | } 119 | input[type="search"]::-webkit-search-decoration, 120 | input[type="search"]::-webkit-search-cancel-button { 121 | -webkit-appearance: none; // Inner-padding issues in Chrome OSX, Safari 5 122 | } 123 | textarea { 124 | overflow: auto; // Remove vertical scrollbar in IE6-9 125 | vertical-align: top; // Readability and alignment cross-browser 126 | } 127 | -------------------------------------------------------------------------------- /app/controllers/search_controller.js: -------------------------------------------------------------------------------- 1 | module.exports = function(app) { 2 | 3 | return { 4 | 5 | query: function(req, res, next) { 6 | res.render('home/welcome', { 7 | meta: app.meta({ 8 | title: 'Search: ' + req.params.query, 9 | url: null 10 | }) 11 | }) 12 | }, 13 | 14 | queryTrack: function(req, res, next) { 15 | res.render('home/welcome', { 16 | meta: app.meta({ 17 | title: 'Search for Tracks: ' + req.params.query, 18 | image: app.set('base_url') + '/img/jukesy-play.png', 19 | url: null 20 | }) 21 | }) 22 | }, 23 | queryAlbum: function(req, res, next) { 24 | res.render('home/welcome', { 25 | meta: app.meta({ 26 | title: 'Search for Albums: ' + req.params.query, 27 | url: null 28 | }) 29 | }) 30 | }, 31 | queryArtist: function(req, res, next) { 32 | res.render('home/welcome', { 33 | meta: app.meta({ 34 | title: 'Search for Artists: ' + req.params.query, 35 | url: null 36 | }) 37 | }) 38 | }, 39 | 40 | artistTrack: function(req, res, next) { 41 | res.render('home/welcome', { 42 | meta: app.meta({ 43 | title: req.params.artist + ' - ' + req.params.track + ' (and similar tracks)', 44 | image: app.set('base_url') + '/img/jukesy-play.png', 45 | url: null 46 | }) 47 | }) 48 | }, 49 | 50 | artistAlbum: function(req, res, next) { 51 | res.render('home/welcome', { 52 | meta: app.meta({ 53 | title: req.params.artist + ' - ' + req.params.album, 54 | image: app.set('base_url') + '/img/jukesy-play.png', 55 | url: null 56 | }) 57 | }) 58 | }, 59 | 60 | artistTopTracks: function(req, res, next) { 61 | res.render('home/welcome', { 62 | meta: app.meta({ 63 | title: 'Top Tracks by ' + req.params.artist, 64 | image: app.set('base_url') + '/img/jukesy-play.png', 65 | url: null 66 | }) 67 | }) 68 | }, 69 | 70 | artistTopAlbums: function(req, res, next) { 71 | res.render('home/welcome', { 72 | meta: app.meta({ 73 | title: 'Top Albums by ' + req.params.artist, 74 | url: null 75 | }) 76 | }) 77 | }, 78 | 79 | artistSimilar: function(req, res, next) { 80 | res.render('home/welcome', { 81 | meta: app.meta({ 82 | title: 'Artists similar to ' + req.params.artist, 83 | url: null 84 | }) 85 | }) 86 | }, 87 | 88 | artist: function(req, res, next) { 89 | res.render('home/welcome', { 90 | meta: app.meta({ 91 | title: req.params.artist, 92 | url: null 93 | //type 94 | }) 95 | }) 96 | } 97 | } 98 | } 99 | 100 | -------------------------------------------------------------------------------- /public/js/session.js: -------------------------------------------------------------------------------- 1 | Model.Session = Backbone.Model.extend({ 2 | urlRoot: '/session', 3 | 4 | initialize: function() { 5 | _.bindAll(this, 'logout', 'login', 'refresh', 'userJSON') 6 | 7 | this.viewButton = new View.SessionButton({ model: this }) 8 | this.initialAttempt = true 9 | $.ajax({ 10 | url: '/session/refresh', 11 | dataType: 'json', 12 | success: this.login, 13 | error: this.logout 14 | }) 15 | }, 16 | 17 | userJSON: function() { 18 | return this.user && this.user.toJSON() 19 | }, 20 | 21 | refresh: function() { 22 | if (this.initialAttempt) { 23 | this.initialAttempt = false 24 | Backbone.history.start({ pushState: true }) 25 | } else { 26 | Backbone.history.refresh() 27 | } 28 | SidebarView.render() 29 | this.viewButton.render() 30 | }, 31 | 32 | logout: function() { 33 | delete this.user 34 | _.defer(this.refresh) 35 | }, 36 | 37 | login: function (model) { 38 | var user = model 39 | if (user) { 40 | user.id = user.username 41 | user = new Model.User(user) 42 | } 43 | if (model.playlists) { 44 | Playlists.add(model.playlists) 45 | } 46 | Playlists.user = user.get('username') 47 | this.user = user 48 | _.defer(this.refresh) 49 | this.trigger('login') 50 | } 51 | }) 52 | 53 | View.SessionButton = Backbone.View.extend({ 54 | el: $('#session-button'), 55 | 56 | events: { 57 | 'click a.sign-in': 'newSession', 58 | 'click a.sign-up': 'newUser' 59 | }, 60 | 61 | template: jade.compile($('#session-button-template').text()), 62 | 63 | newSession: function() { 64 | loginModal.render() 65 | return false 66 | }, 67 | 68 | newUser: function() { 69 | signupModal.render() 70 | return false 71 | }, 72 | 73 | render: function() { 74 | this.$el.html(this.template({ currentUser: Session.userJSON() })) 75 | if (this.model.user) { 76 | this.$el.addClass('dropdown') 77 | } else { 78 | this.$el.removeClass('dropdown') 79 | } 80 | } 81 | }) 82 | 83 | View.SessionCreate = View.Form.extend({ 84 | template: jade.compile($('#session-new-template').text()), 85 | 86 | elAlert: '.modal-body', 87 | elFocus: '#session-new-password', 88 | 89 | events: { 90 | 'click button.btn-primary' : 'submit', 91 | 'keypress input' : 'keyDown', 92 | 'click a.sign-up' : 'newUser', 93 | 'click a.forgot' : 'forgot' 94 | }, 95 | 96 | initialize: function() { 97 | _.bindAll(this, 'submit', 'keyDown', 'submitSuccess', 'submitError') 98 | }, 99 | 100 | render: function() { 101 | this.$el.html(this.template()) 102 | ModalView.render(this.$el) 103 | this.delegateEvents() 104 | return this 105 | }, 106 | 107 | newUser: function() { 108 | signupModal.render() 109 | return false 110 | }, 111 | 112 | forgot: function() { 113 | forgotModal.render() 114 | return false 115 | }, 116 | 117 | submitSuccess: function(user, response) { 118 | Session.login(response) 119 | ModalView.hide() 120 | } 121 | }) 122 | -------------------------------------------------------------------------------- /app/views/_templates.jade: -------------------------------------------------------------------------------- 1 | #401-template 2 | != jadeLiteral('401') 3 | 4 | #404-template 5 | != jadeLiteral('404') 6 | 7 | #500-template 8 | != jadeLiteral('500') 9 | 10 | #welcome-template 11 | != jadeLiteral('home/welcome') 12 | 13 | #about-template 14 | != jadeLiteral('home/about') 15 | 16 | #terms-of-service-template 17 | != jadeLiteral('home/terms_of_service') 18 | 19 | #privacy-policy-template 20 | != jadeLiteral('home/privacy_policy') 21 | 22 | #now-playing-template 23 | != jadeLiteral('home/now_playing') 24 | 25 | #playlist-index-template 26 | != jadeLiteral('playlist/index') 27 | 28 | #playlist-show-template 29 | != jadeLiteral('playlist/show') 30 | 31 | #playlist-destroy-template 32 | != jadeLiteral('playlist/destroy') 33 | 34 | #playlist-add-template 35 | != jadeLiteral('playlist/add') 36 | 37 | #user-new-template 38 | != jadeLiteral('user/new') 39 | 40 | #user-show-template 41 | != jadeLiteral('user/show') 42 | 43 | #user-edit-template 44 | != jadeLiteral('user/edit') 45 | 46 | #user-forgot-template 47 | != jadeLiteral('user/forgot') 48 | 49 | #user-reset-template 50 | != jadeLiteral('user/reset') 51 | 52 | #session-new-template 53 | != jadeLiteral('session/new') 54 | 55 | #controls-template 56 | != jadeLiteral('layout/controls') 57 | 58 | #keyboard-shortcuts-template 59 | != jadeLiteral('layout/keyboard_shortcuts') 60 | 61 | #share-template 62 | != jadeLiteral('layout/share') 63 | 64 | #alert-template 65 | != jadeLiteral('layout/alert') 66 | 67 | #sidebar-template 68 | != jadeLiteral('layout/sidebar') 69 | 70 | #session-button-template 71 | != jadeLiteral('layout/session_button') 72 | 73 | #track-template 74 | != jadeLiteral('layout/track') 75 | 76 | #track-info-template 77 | != jadeLiteral('layout/track_info') 78 | 79 | #search-query-track-template 80 | != jadeLiteral('layout/search_query_track') 81 | 82 | #search-query-album-template 83 | != jadeLiteral('layout/search_query_album') 84 | 85 | #search-query-artist-template 86 | != jadeLiteral('layout/search_query_artist') 87 | 88 | #search-query-template 89 | != jadeLiteral('layout/search_query') 90 | 91 | #search-track-template 92 | != jadeLiteral('layout/search_track') 93 | 94 | #search-album-template 95 | != jadeLiteral('layout/search_album') 96 | 97 | #search-artist-template 98 | != jadeLiteral('layout/search_artist') 99 | 100 | #search-artist-top-tracks-template 101 | != jadeLiteral('layout/search_artist_top_tracks') 102 | 103 | #search-artist-top-albums-template 104 | != jadeLiteral('layout/search_artist_top_albums') 105 | 106 | #search-artist-similar-template 107 | != jadeLiteral('layout/search_artist_similar') 108 | 109 | #search-results-tracks-template 110 | != jadeLiteral('layout/search_results_tracks') 111 | 112 | #search-results-albums-template 113 | != jadeLiteral('layout/search_results_albums') 114 | 115 | #search-results-artists-template 116 | != jadeLiteral('layout/search_results_artists') 117 | 118 | #search-result-track-template 119 | != jadeLiteral('layout/search_result_track') 120 | 121 | #search-result-album-template 122 | != jadeLiteral('layout/search_result_album') 123 | 124 | #search-result-artist-template 125 | != jadeLiteral('layout/search_result_artist') 126 | 127 | -------------------------------------------------------------------------------- /app/controllers/user_controller.js: -------------------------------------------------------------------------------- 1 | module.exports = function(app) { 2 | var User = app.model('User') 3 | 4 | return { 5 | 6 | index: function(req, res, next) { 7 | next(new app.Error(501)) 8 | }, 9 | 10 | create: function(req, res, next) { 11 | User.create(req.body, function(err, user) { 12 | if (err || !user) { 13 | return next(new app.Error(err || 500)) 14 | } 15 | 16 | app.auth.setUser(user, req, res) 17 | res.json(user.exposeJSON(user)) 18 | }) 19 | }, 20 | 21 | forgot: function(req, res, next) { 22 | User.findByLogin(req.body.login || '', function(err, user) { 23 | if (err || !user) { 24 | return next(err || new app.Error(400, { $: 'no_user_or_email' })) 25 | } 26 | 27 | user.generateResetToken(function(err, user) { 28 | if (err || !user) { 29 | return next(err || 500) 30 | } 31 | 32 | app.controller('Mail').resetToken(user, function(err, success) { 33 | if (err) { 34 | return next(err || 500) 35 | } 36 | res.json(1) 37 | }) 38 | }) 39 | }) 40 | }, 41 | 42 | reset: function(req, res, next) { 43 | req.paramUser.resetPassword(req.body, function(err, user) { 44 | if (err || !user) { 45 | return next(err || new app.Error(500)) 46 | } 47 | res.json(1) 48 | }) 49 | }, 50 | 51 | resetCheck: function(req, res, next) { 52 | if (!req.xhr) { 53 | res.render('home/welcome', { meta: app.meta() }) 54 | } else { 55 | if (!req.paramUser.validResetToken(req.param('token'))) { 56 | return next(new app.Error(401, { $: 'reset_token_expired' })) 57 | } 58 | res.json(1) 59 | } 60 | }, 61 | 62 | read: function(req, res, next) { 63 | var userJSON = req.paramUser.exposeJSON(req.currentUser) 64 | if (req.xhr) { 65 | res.json(userJSON) 66 | } else { 67 | res.render('user/show', { 68 | user: userJSON, 69 | meta: app.meta({ 70 | title: req.paramUser.username + '\'s profile', 71 | url: req.paramUser.url() 72 | }) 73 | }) 74 | } 75 | }, 76 | 77 | edit: function(req, res, next) { 78 | var userJSON = req.paramUser.exposeJSON(req.currentUser) 79 | if (req.xhr) { 80 | res.json(userJSON) 81 | } else { 82 | res.render('user/edit', { 83 | user: userJSON, 84 | meta: app.meta({ 85 | title: 'edit ' + userJSON.username + '\'s profile', 86 | url: req.paramUser.url() + '/edit' 87 | }) 88 | }) 89 | } 90 | }, 91 | 92 | update: function(req, res, next) { 93 | req.paramUser.updateAttributes(req.body) 94 | req.paramUser.save(function(err, user) { 95 | if (err || !user) { 96 | return next(new app.Error(err || 500)) 97 | } 98 | 99 | res.json(user.exposeJSON(req.currentUser)) 100 | }) 101 | }, 102 | 103 | delete: function(req, res, next) { 104 | next(new app.Error(501)) 105 | } 106 | 107 | } 108 | 109 | } 110 | 111 | -------------------------------------------------------------------------------- /public/less/bootstrap/variables.less: -------------------------------------------------------------------------------- 1 | // Variables.less 2 | // Variables to customize the look and feel of Bootstrap 3 | // Swatch: Cyborg 4 | // ----------------------------------------------------- 5 | 6 | // CUSTOM VALUES 7 | // -------------------------------------------------- 8 | 9 | 10 | // GLOBAL VALUES 11 | // -------------------------------------------------- 12 | 13 | // Links 14 | @linkColor: @blue; 15 | @linkColorHover: @white; 16 | 17 | // Grays 18 | @black: #000; 19 | @grayDarker: #020202; 20 | @grayDark: #282828; 21 | @gray: #999; 22 | @grayLight: #ADAFAE; 23 | @grayLighter: #eee; 24 | @white: #fff; 25 | 26 | // Accent colors 27 | @blue: #33B5E5; 28 | @blueDark: #0099CC; 29 | @green: #669900; 30 | @red: #CC0000; 31 | @yellow: #ECBB13; 32 | @orange: #FF8800; 33 | @pink: #FF4444; 34 | @purple: #9933CC; 35 | 36 | // Typography 37 | @baseFontSize: 13px; 38 | @baseFontFamily: 'Baumans', sans-serif; 39 | @baseLineHeight: 18px; 40 | @textColor: @gray; 41 | 42 | // Buttons 43 | @primaryButtonBackground: @blue; 44 | 45 | 46 | 47 | // COMPONENT VARIABLES 48 | // -------------------------------------------------- 49 | 50 | // Z-index master list 51 | // Used for a bird's eye view of components dependent on the z-axis 52 | // Try to avoid customizing these :) 53 | @zindexDropdown: 1000; 54 | @zindexPopover: 1010; 55 | @zindexTooltip: 1020; 56 | @zindexFixedNavbar: 1030; 57 | @zindexFullscreenVideo: 1040; 58 | @zindexModalBackdrop: 1050; 59 | @zindexModal: 1060; 60 | @zindexControls: 1070; 61 | 62 | // Sprite icons path 63 | @iconSpritePath: "../img/glyphicons-halflings.png"; 64 | @iconWhiteSpritePath: "../img/glyphicons-halflings-white.png"; 65 | 66 | // Input placeholder text color 67 | @placeholderText: @grayLight; 68 | 69 | // Hr border color 70 | @hrBorder: @grayDark; 71 | 72 | // Navbar 73 | @navbarHeight: 40px; 74 | @navbarBackground: @grayDarker; 75 | @navbarBackgroundHighlight: @grayDarker; 76 | @navbarLinkBackgroundHover: transparent; 77 | 78 | @navbarText: @grayLight; 79 | @navbarLinkColor: @grayLight; 80 | @navbarLinkColorHover: @white; 81 | 82 | // Form states and alerts 83 | @warningText: darken(#c09853, 10%); 84 | @warningBackground: @grayLighter; 85 | @warningBorder: transparent; 86 | 87 | @errorText: #b94a48; 88 | @errorBackground: @grayLighter; 89 | @errorBorder: darken(spin(@errorBackground, -10), 3%); 90 | 91 | @successText: #468847; 92 | @successBackground: @grayLighter; 93 | @successBorder: darken(spin(@successBackground, -10), 5%); 94 | 95 | @infoText: @blueDark; 96 | @infoBackground: @grayLighter; 97 | @infoBorder: darken(spin(@infoBackground, -10), 7%); 98 | 99 | 100 | 101 | // GRID 102 | // -------------------------------------------------- 103 | 104 | // Default 940px grid 105 | @gridColumns: 12; 106 | @gridColumnWidth: 60px; 107 | @gridGutterWidth: 20px; 108 | @gridRowWidth: (@gridColumns * @gridColumnWidth) + (@gridGutterWidth * (@gridColumns - 1)); 109 | 110 | // Fluid grid 111 | @fluidGridColumnWidth: 6.382978723%; 112 | @fluidGridGutterWidth: 2.127659574%; 113 | -------------------------------------------------------------------------------- /public/js/mixins.js: -------------------------------------------------------------------------------- 1 | Mixins.TrackViewEvents = { 2 | 3 | removeTrack: function() { 4 | this.model.collection.remove([ this.model ]) 5 | }, 6 | 7 | playNow: function() { 8 | var clone = new Model.Track(this.model.toJSON()) 9 | NowPlaying.tracks.add([ clone ], { at: _.indexOf(NowPlaying.tracks.models, Video.track) + 1 }) 10 | clone.play(true) 11 | }, 12 | 13 | queueNext: function() { 14 | NowPlaying.tracks.add([ new Model.Track(this.model.toJSON()) ], { at: _.indexOf(NowPlaying.tracks.models, Video.track) + 1 }) 15 | }, 16 | 17 | queueLast: function() { 18 | NowPlaying.tracks.add([ new Model.Track(this.model.toJSON()) ]) 19 | }, 20 | 21 | addToPlaylist: function() { 22 | Playlists.addToView.render({ tracks: [ new Model.Track(this.model.toJSON()) ] }) 23 | }, 24 | 25 | dropdown: function() { 26 | this.$el.find('.dropdown-toggle').dropdown('toggle') 27 | return false 28 | } 29 | } 30 | 31 | View.Form = Backbone.View.extend({ 32 | 33 | keyDown: function(event) { 34 | if (event.keyCode == 13) { 35 | this.submit() 36 | $(event.target).blur() 37 | return false 38 | } 39 | }, 40 | 41 | submit: function() { 42 | var sendJSON = {} 43 | _.each(this.$el.find('[name]'), function(inputEl) { 44 | var $input = $(inputEl) 45 | sendJSON[$input.attr('name')] = $input.val() 46 | }) 47 | this.model.save(sendJSON, { 48 | success: this.submitSuccess, 49 | error: this.submitError 50 | }) 51 | return false 52 | }, 53 | 54 | submitError: function(model, error) { 55 | this.removeErrors() 56 | var errorJSON = {} 57 | try { 58 | errorJSON = JSON.parse(error.responseText) 59 | } catch(e) {} 60 | 61 | if (error.status == 401 && !errorJSON.errors) { 62 | this.addAlert('unauthorized') 63 | } else if (error.status) { 64 | this.addErrors(errorJSON.errors) 65 | } else { 66 | this.addAlert() 67 | } 68 | this.clearPasswords() 69 | this.focusInput() 70 | }, 71 | 72 | addErrors: function(errors) { 73 | var self = this 74 | _.each(errors, function(error, field) { 75 | if (field == '$') { 76 | self.addAlert(error) 77 | } else { 78 | var $group = self.$el.find('.controls [name=' + field + ']').parents('.control-group') 79 | $group.addClass('error') 80 | $group.find('span.help-inline').html(parseError(field, error)) 81 | } 82 | }) 83 | }, 84 | 85 | removeErrors: function() { 86 | this.$el.find('.error').removeClass('error') 87 | this.$el.find('.alert').remove() 88 | this.$el.find('span.help-inline').html('') 89 | }, 90 | 91 | addAlert: function(message) { 92 | new View.Alert({ 93 | className: 'alert-error alert fade', 94 | message: parseError(null, message || 'no_connection'), 95 | $prepend: this.elAlertFind() 96 | }) 97 | }, 98 | 99 | focusInput: function() { 100 | var self = this 101 | _.defer(function() { 102 | self.elFocusFind().focus() 103 | }) 104 | }, 105 | 106 | clearPasswords: function() { 107 | this.$el.find('input[type=password]').val('') 108 | }, 109 | 110 | elAlertFind: function() { 111 | return this.elAlert ? this.$el.find(this.elAlert) : this.$el 112 | }, 113 | 114 | elFocusFind: function() { 115 | var $input 116 | if (this.elFocus) { 117 | $input = this.$el.find(this.elFocus) 118 | } else { 119 | $input = this.$el.find('.error:first [name]') 120 | if (!$input.length) { 121 | $input = this.$el.find('[name]:first') 122 | } 123 | } 124 | return $input 125 | } 126 | 127 | }) 128 | -------------------------------------------------------------------------------- /public/less/bootstrap/dropdowns.less: -------------------------------------------------------------------------------- 1 | // DROPDOWN MENUS 2 | // -------------- 3 | 4 | // Use the .menu class on any
  • element within the topbar or ul.tabs and you'll get some superfancy dropdowns 5 | .dropdown { 6 | position: relative; 7 | } 8 | .dropdown-toggle { 9 | // The caret makes the toggle a bit too tall in IE7 10 | *margin-bottom: -3px; 11 | } 12 | .dropdown-toggle:active, 13 | .open .dropdown-toggle { 14 | outline: 0; 15 | } 16 | // Dropdown arrow/caret 17 | .caret { 18 | display: inline-block; 19 | width: 0; 20 | height: 0; 21 | text-indent: -99999px; 22 | // IE7 won't do the border trick if there's a text indent, but it doesn't 23 | // do the content that text-indent is hiding, either, so we're ok. 24 | *text-indent: 0; 25 | vertical-align: top; 26 | border-left: 4px solid transparent; 27 | border-right: 4px solid transparent; 28 | border-top: 4px solid @black; 29 | .opacity(30); 30 | content: "\2193"; 31 | } 32 | .dropdown .caret { 33 | margin-top: 8px; 34 | margin-left: 2px; 35 | } 36 | .dropdown:hover .caret, 37 | .open.dropdown .caret { 38 | .opacity(100); 39 | } 40 | // The dropdown menu (ul) 41 | .dropdown-menu { 42 | position: absolute; 43 | top: 100%; 44 | left: 0; 45 | z-index: @zindexDropdown; 46 | float: left; 47 | display: none; // none by default, but block on "open" of the menu 48 | min-width: 160px; 49 | _width: 160px; 50 | padding: 4px 0; 51 | margin: 0; // override default ul 52 | list-style: none; 53 | background-color: @white; 54 | border-color: #ccc; 55 | border-color: rgba(0,0,0,.2); 56 | border-style: solid; 57 | border-width: 1px; 58 | .border-radius(0 0 5px 5px); 59 | .box-shadow(0 5px 10px rgba(0,0,0,.2)); 60 | -webkit-background-clip: padding-box; 61 | -moz-background-clip: padding; 62 | background-clip: padding-box; 63 | *border-right-width: 2px; 64 | *border-bottom-width: 2px; 65 | 66 | // Allow for dropdowns to go bottom up (aka, dropup-menu) 67 | &.bottom-up { 68 | top: auto; 69 | bottom: 100%; 70 | margin-bottom: 2px; 71 | } 72 | 73 | // Dividers (basically an hr) within the dropdown 74 | .divider { 75 | height: 1px; 76 | margin: 5px 1px; 77 | overflow: hidden; 78 | background-color: #e5e5e5; 79 | border-bottom: 1px solid @white; 80 | 81 | // IE7 needs a set width since we gave a height. Restricting just 82 | // to IE7 to keep the 1px left/right space in other browsers. 83 | // It is unclear where IE is getting the extra space that we need 84 | // to negative-margin away, but so it goes. 85 | *width: 100%; 86 | *margin: -5px 0 5px; 87 | } 88 | 89 | // Links within the dropdown menu 90 | a { 91 | display: block; 92 | padding: 3px 15px; 93 | clear: both; 94 | font-weight: normal; 95 | line-height: @baseLineHeight; 96 | color: @gray; 97 | white-space: nowrap; 98 | } 99 | } 100 | 101 | // Hover state 102 | .dropdown-menu li > a:hover, 103 | .dropdown-menu .active > a, 104 | .dropdown-menu .active > a:hover { 105 | color: @white; 106 | text-decoration: none; 107 | background-color: @linkColor; 108 | } 109 | 110 | // Open state for the dropdown 111 | .dropdown.open { 112 | // IE7's z-index only goes to the nearest positioned ancestor, which would 113 | // make the menu appear below buttons that appeared later on the page 114 | *z-index: @zindexDropdown; 115 | 116 | .dropdown-toggle { 117 | color: @white; 118 | background: #ccc; 119 | background: rgba(0,0,0,.3); 120 | } 121 | .dropdown-menu { 122 | display: block; 123 | } 124 | } 125 | 126 | // Typeahead 127 | .typeahead { 128 | margin-top: 2px; // give it some space to breathe 129 | .border-radius(4px); 130 | } 131 | -------------------------------------------------------------------------------- /jobs/chart.js: -------------------------------------------------------------------------------- 1 | var request = require('superagent') 2 | , fs = require('fs') 3 | , _ = require('underscore') 4 | , apiKey = '75c8c3065db32d805a292ec1af5631a3' 5 | , getChartTopArtists 6 | , getChartTopTracks 7 | , getChartTopTags 8 | 9 | function chartTopArtists() { 10 | var attempt = 0 11 | return function() { 12 | console.log('requesting chart.getTopArtists, attempt #' + (++attempt)) 13 | request 14 | .get('http://ws.audioscrobbler.com/2.0/') 15 | .send({ method: 'chart.getTopArtists', api_key: apiKey, limit: '25', format: 'json' }) 16 | .set('User-Agent', 'jukesy.com') 17 | .end(function(res) { 18 | var artists = null 19 | try { 20 | artists = JSON.parse(res.res.text).artists.artist 21 | } catch (e) {} 22 | 23 | if (!artists) { 24 | setTimeout(getChartTopArtists, 1000) 25 | return 26 | } 27 | 28 | artists = _.map(artists, function(artist) { 29 | return { 30 | name: artist.name, 31 | image: grabImage(artist) 32 | } 33 | }) 34 | 35 | fs.writeFileSync(__dirname + '/../public/chart/topartists.json', JSON.stringify(artists)) 36 | }) 37 | } 38 | } 39 | 40 | function chartTopTracks() { 41 | var attempt = 0 42 | return function() { 43 | console.log('requesting chart.getTopTracks, attempt #' + (++attempt)) 44 | request 45 | .get('http://ws.audioscrobbler.com/2.0/') 46 | .send({ method: 'chart.getTopTracks', api_key: apiKey, limit: '50', format: 'json' }) 47 | .set('User-Agent', 'jukesy.com') 48 | .end(function(res) { 49 | var tracks = null 50 | try { 51 | tracks = JSON.parse(res.res.text).tracks.track 52 | } catch (e) {} 53 | 54 | if (!tracks) { 55 | setTimeout(getChartTopTracks, 1000) 56 | return 57 | } 58 | 59 | tracks = _.map(tracks, function(track) { 60 | return { 61 | artist: track.artist.name, 62 | name: track.name 63 | } 64 | }) 65 | 66 | fs.writeFileSync(__dirname + '/../public/chart/toptracks.json', JSON.stringify(tracks)) 67 | }) 68 | } 69 | } 70 | 71 | function chartTopTags() { 72 | var attempt = 0 73 | return function() { 74 | console.log('requesting chart.getTopTags, attempt #' + (++attempt)) 75 | request 76 | .get('http://ws.audioscrobbler.com/2.0/') 77 | .send({ method: 'chart.getTopTags', api_key: apiKey, limit: '50', format: 'json' }) 78 | .set('User-Agent', 'jukesy.com') 79 | .end(function(res) { 80 | var tags = null 81 | try { 82 | tags = JSON.parse(res.res.text).tags.tag 83 | } catch (e) {} 84 | 85 | if (!tags) { 86 | setTimeout(getChartTopTags, 1000) 87 | return 88 | } 89 | 90 | tags = _.map(tags, function(tag) { 91 | return { 92 | name: tag.name, 93 | reach: tag.reach 94 | } 95 | }) 96 | 97 | fs.writeFileSync(__dirname + '/../public/chart/toptags.json', JSON.stringify(tags)) 98 | }) 99 | } 100 | } 101 | 102 | function grabImage(result) { 103 | var src = '', size = 'extralarge' 104 | 105 | if (_.isArray(result.image)) { 106 | _.each(result.image, function(image) { 107 | if (image.size == size) { 108 | src = image['#text'] 109 | } 110 | }) 111 | } else if (!_.isUndefined(result.image)) { 112 | src = result.image 113 | } 114 | return src 115 | } 116 | 117 | getChartTopArtists = chartTopArtists() 118 | getChartTopArtists() 119 | getChartTopTracks = chartTopTracks() 120 | getChartTopTracks() 121 | getChartTopTags = chartTopTags() 122 | getChartTopTags() 123 | 124 | 125 | 126 | -------------------------------------------------------------------------------- /public/less/jukesy/results.less: -------------------------------------------------------------------------------- 1 | 2 | #main { 3 | 4 | #search hr { 5 | border-top: 1px solid #111; 6 | border-bottom: 1px solid #333; 7 | } 8 | 9 | .load-more { 10 | text-align: center; 11 | } 12 | 13 | .tracks { 14 | 15 | .artist { 16 | width: 300px; 17 | .remove { 18 | color: @grayDark; 19 | cursor: pointer; 20 | &:hover { 21 | color: @gray; 22 | } 23 | } 24 | } 25 | 26 | .artist a, .name a { 27 | display: inline-block; 28 | max-height: 18px; 29 | overflow: hidden; 30 | } 31 | 32 | td.dd { 33 | .no-select(); 34 | width: 14px; 35 | height: 18px; 36 | 37 | .dropdown { 38 | .dropdown-toggle { 39 | padding: 10px 8px; 40 | margin-left: -6px; 41 | &:hover { text-decoration: none; } 42 | } 43 | 44 | &.open .dropdown-toggle { 45 | background: transparent; 46 | color: @blueDark; 47 | } 48 | } 49 | } 50 | 51 | td.play { 52 | .no-select(); 53 | width: 22px; 54 | 55 | a { 56 | .radius(2px); 57 | padding: 5px 8px; 58 | color: @blueDark; 59 | opacity: 1; 60 | 61 | &:hover { 62 | text-decoration: none; 63 | background: @blueDark; 64 | color: #fff; 65 | opacity: 1; 66 | } 67 | } 68 | } 69 | } 70 | 71 | table.playlist { 72 | .error { 73 | td { 74 | &.artist a, &.name a { 75 | color: @red; 76 | } 77 | } 78 | td.play { 79 | a { 80 | color: @red; 81 | cursor: default; 82 | &:hover { 83 | background: transparent; 84 | } 85 | } 86 | } 87 | td.dd { 88 | .dropdown { 89 | li a:hover { 90 | background: @red; 91 | } 92 | .dropdown-toggle { 93 | color: @red; 94 | } 95 | .play-now, .divider { 96 | display: none; 97 | } 98 | } 99 | } 100 | } 101 | 102 | .playing { 103 | td { 104 | //background: @blueDark; 105 | &.artist a, &.name a { 106 | color: #fff; 107 | } 108 | } 109 | td.play { 110 | a { 111 | color: #fff; 112 | background: transparent; 113 | cursor: default; 114 | } 115 | } 116 | td.dd .dropdown .dropdown-toggle { 117 | color: #fff; 118 | } 119 | } 120 | } 121 | 122 | 123 | 124 | .artists, 125 | .albums { 126 | padding-top: 15px; 127 | 128 | .thumbnail { 129 | position: relative; 130 | overflow: hidden; 131 | 132 | .img-wrap { 133 | height: 100px; 134 | line-height: 100px; 135 | overflow: hidden; 136 | img { 137 | width: 100%; 138 | vertical-align: middle; 139 | } 140 | } 141 | .overlay { 142 | height: 100px; 143 | width: 130px; 144 | padding: 0; 145 | opacity: 0.85; 146 | overflow: hidden; 147 | background: @grayDark; 148 | color: @white; 149 | position: absolute; 150 | text-align: center; 151 | line-height: 100px; 152 | top: 4px; 153 | .artist { 154 | color: @blueDark; 155 | } 156 | .overlay-inner { 157 | display: inline-block; 158 | vertical-align: middle; 159 | font-size: 18px; 160 | line-height: 22px; 161 | text-shadow: 0 0 0 1px @white; 162 | } 163 | 164 | &:hover { 165 | opacity: 0.0; 166 | } 167 | } 168 | } 169 | } 170 | .albums .thumbnail .img-wrap, 171 | .albums .thumbnail .overlay { 172 | height: 130px; 173 | line-height: 130px; 174 | } 175 | } 176 | 177 | -------------------------------------------------------------------------------- /public/less/bootstrap/tables.less: -------------------------------------------------------------------------------- 1 | // 2 | // Tables.less 3 | // Tables for, you guessed it, tabular data 4 | // ---------------------------------------- 5 | 6 | 7 | // BASE TABLES 8 | // ----------------- 9 | 10 | table { 11 | max-width: 100%; 12 | border-collapse: collapse; 13 | border-spacing: 0; 14 | } 15 | 16 | // BASELINE STYLES 17 | // --------------- 18 | 19 | .table { 20 | width: 100%; 21 | margin-bottom: @baseLineHeight; 22 | // Cells 23 | th, 24 | td { 25 | padding: 8px; 26 | line-height: @baseLineHeight; 27 | text-align: left; 28 | vertical-align: top; 29 | border-top: 1px solid #ddd; 30 | } 31 | th { 32 | font-weight: bold; 33 | } 34 | // Bottom align for column headings 35 | thead th { 36 | vertical-align: bottom; 37 | } 38 | // Remove top border from thead by default 39 | thead:first-child tr th, 40 | thead:first-child tr td { 41 | border-top: 0; 42 | } 43 | // Account for multiple tbody instances 44 | tbody + tbody { 45 | border-top: 2px solid #ddd; 46 | } 47 | } 48 | 49 | 50 | 51 | // CONDENSED TABLE W/ HALF PADDING 52 | // ------------------------------- 53 | 54 | .table-condensed { 55 | th, 56 | td { 57 | padding: 4px 5px; 58 | } 59 | } 60 | 61 | 62 | // BORDERED VERSION 63 | // ---------------- 64 | 65 | .table-bordered { 66 | border: 1px solid #ddd; 67 | border-collapse: separate; // Done so we can round those corners! 68 | *border-collapse: collapsed; // IE7 can't round corners anyway 69 | .border-radius(4px); 70 | th + th, 71 | td + td, 72 | th + td, 73 | td + th { 74 | border-left: 1px solid #ddd; 75 | } 76 | // Prevent a double border 77 | thead:first-child tr:first-child th, 78 | tbody:first-child tr:first-child th, 79 | tbody:first-child tr:first-child td { 80 | border-top: 0; 81 | } 82 | // For first th or td in the first row in the first thead or tbody 83 | thead:first-child tr:first-child th:first-child, 84 | tbody:first-child tr:first-child td:first-child { 85 | .border-radius(4px 0 0 0); 86 | } 87 | thead:first-child tr:first-child th:last-child, 88 | tbody:first-child tr:first-child td:last-child { 89 | .border-radius(0 4px 0 0); 90 | } 91 | // For first th or td in the first row in the first thead or tbody 92 | thead:last-child tr:last-child th:first-child, 93 | tbody:last-child tr:last-child td:first-child { 94 | .border-radius(0 0 0 4px); 95 | } 96 | thead:last-child tr:last-child th:last-child, 97 | tbody:last-child tr:last-child td:last-child { 98 | .border-radius(0 0 4px 0); 99 | } 100 | } 101 | 102 | 103 | // ZEBRA-STRIPING 104 | // -------------- 105 | 106 | // Default zebra-stripe styles (alternating gray and transparent backgrounds) 107 | .table-striped { 108 | tbody { 109 | tr:nth-child(odd) td, 110 | tr:nth-child(odd) th { 111 | background-color: #f9f9f9; 112 | } 113 | } 114 | } 115 | 116 | 117 | // HOVER EFFECT 118 | // ------------ 119 | // Placed here since it has to come after the potential zebra striping 120 | .table { 121 | tbody tr:hover td, 122 | tbody tr:hover th { 123 | background-color: #f5f5f5; 124 | } 125 | } 126 | 127 | 128 | // TABLE CELL SIZING 129 | // ----------------- 130 | 131 | // Change the columns 132 | .tableColumns(@columnSpan: 1) { 133 | float: none; 134 | width: ((@gridColumnWidth) * @columnSpan) + (@gridGutterWidth * (@columnSpan - 1)) - 16; 135 | margin-left: 0; 136 | } 137 | table { 138 | .span1 { .tableColumns(1); } 139 | .span2 { .tableColumns(2); } 140 | .span3 { .tableColumns(3); } 141 | .span4 { .tableColumns(4); } 142 | .span5 { .tableColumns(5); } 143 | .span6 { .tableColumns(6); } 144 | .span7 { .tableColumns(7); } 145 | .span8 { .tableColumns(8); } 146 | .span9 { .tableColumns(9); } 147 | .span10 { .tableColumns(10); } 148 | .span11 { .tableColumns(11); } 149 | .span12 { .tableColumns(12); } 150 | } 151 | -------------------------------------------------------------------------------- /test/search_controller.test.js: -------------------------------------------------------------------------------- 1 | describe('Search Controller', function() { 2 | 3 | describe('GET /search/:query (#query)', function() { 4 | it('returns a 200 with proper metadata', function(done) { 5 | request.get('/search/' + encodeURIComponent('Röyksopp'), function(res) { 6 | expect(res).status(200) 7 | expect(res.text).to.match(/Search: Röyksopp/) 8 | done() 9 | }) 10 | }) 11 | }) 12 | 13 | describe('GET /search/:query/track (#queryTrack)', function() { 14 | it('returns a 200 with proper metadata', function(done) { 15 | request.get('/search/' + encodeURIComponent('Röyksopp') + '/track', function(res) { 16 | expect(res).status(200) 17 | expect(res.text).to.match(/Search for Tracks: Röyksopp/) 18 | done() 19 | }) 20 | }) 21 | }) 22 | 23 | describe('GET /search/:query/album (#queryAlbum)', function() { 24 | it('returns a 200 with proper metadata', function(done) { 25 | request.get('/search/' + encodeURIComponent('Röyksopp') + '/album', function(res) { 26 | expect(res).status(200) 27 | expect(res.text).to.match(/Search for Albums: Röyksopp/) 28 | done() 29 | }) 30 | }) 31 | }) 32 | 33 | describe('GET /search/:query/artist (#queryArtist)', function() { 34 | it('returns a 200 with proper metadata', function(done) { 35 | request.get('/search/' + encodeURIComponent('Röyksopp') + '/artist', function(res) { 36 | expect(res).status(200) 37 | expect(res.text).to.match(/Search for Artists: Röyksopp/) 38 | done() 39 | }) 40 | }) 41 | }) 42 | 43 | describe('GET /artist/:artist/track/:track (#artistTrack)', function() { 44 | it('returns a 200 with proper metadata', function(done) { 45 | request.get('/artist/' + encodeURIComponent('Röyksopp') + '/track/' + encodeURIComponent('Happy Up Here'), function(res) { 46 | expect(res).status(200) 47 | expect(res.text).to.match(/Röyksopp - Happy Up Here \(and similar tracks\)/) 48 | done() 49 | }) 50 | }) 51 | }) 52 | 53 | describe('GET /artist/:artist (#artistAlbum)', function() { 54 | it('returns a 200 with proper metadata', function(done) { 55 | request.get('/artist/mud/album/dirt', function(res) { 56 | expect(res).status(200) 57 | expect(res.text).to.match(/mud - dirt/) 58 | done() 59 | }) 60 | }) 61 | }) 62 | 63 | describe('GET /artist/:artist (#artistTopTracks)', function() { 64 | it('returns a 200 with proper metadata', function(done) { 65 | request.get('/artist/' + encodeURIComponent('Röyksopp') + '/top-tracks', function(res) { 66 | expect(res).status(200) 67 | expect(res.text).to.match(/Top Tracks by Röyksopp/) 68 | done() 69 | }) 70 | }) 71 | }) 72 | 73 | describe('GET /artist/:artist (#artistTopAlbums)', function() { 74 | it('returns a 200 with proper metadata', function(done) { 75 | request.get('/artist/' + encodeURIComponent('Röyksopp') + '/top-albums', function(res) { 76 | expect(res).status(200) 77 | expect(res.text).to.match(/Top Albums by Röyksopp/) 78 | done() 79 | }) 80 | }) 81 | }) 82 | 83 | describe('GET /artist/:artist (#artistSimilar)', function() { 84 | it('returns a 200 with proper metadata', function(done) { 85 | request.get('/artist/' + encodeURIComponent('Röyksopp') + '/similar', function(res) { 86 | expect(res).status(200) 87 | expect(res.text).to.match(/Artists similar to Röyksopp/) 88 | done() 89 | }) 90 | }) 91 | }) 92 | 93 | describe('GET /artist/:artist (#artist)', function() { 94 | it('returns a 200 with proper metadata', function(done) { 95 | request.get('/artist/' + encodeURIComponent('Röyksopp'), function(res) { 96 | expect(res).status(200) 97 | expect(res.text).to.match(/Röyksopp/) 98 | done() 99 | }) 100 | }) 101 | }) 102 | 103 | }) 104 | -------------------------------------------------------------------------------- /routes.js: -------------------------------------------------------------------------------- 1 | module.exports = function(app) { 2 | 3 | var ApplicationController = app.controller('Application') 4 | , HomeController = app.controller('Home') 5 | , SearchController = app.controller('Search') 6 | , UserController = app.controller('User') 7 | , PlaylistController = app.controller('Playlist') 8 | , SessionController = app.controller('Session') 9 | , LastfmController = app.controller('Lastfm') 10 | , User = app.model('User') 11 | , Playlist = app.model('Playlist') 12 | 13 | app.nocache = function(req, res, next) { 14 | res.header('Cache-Control', 'no-cache') 15 | next() 16 | } 17 | 18 | app.param('username', function(req, res, next, username) { 19 | User.findOne({ username: (username || '').toLowerCase() }, function(err, user) { 20 | if (err || !user) { 21 | return next(new app.Error(err || 404)) 22 | } 23 | req.paramUser = user 24 | next() 25 | }) 26 | }) 27 | 28 | app.param('playlist', function(req, res, next, _id) { 29 | // user in this query is superfluous as id is unique 30 | Playlist.findOne({ _id: (_id || ''), user: req.paramUser.username }, function(err, playlist) { 31 | if (err || !playlist) { 32 | return next(new app.Error(err || 404)) 33 | } 34 | req.paramPlaylist = playlist 35 | next() 36 | }) 37 | }) 38 | 39 | app.get('/status', app.nocache, ApplicationController.status) 40 | app.get('/', HomeController.welcome) 41 | app.get('/about', HomeController.about) 42 | app.get('/terms-of-service', HomeController.termsOfService) 43 | app.get('/privacy-policy', HomeController.privacyPolicy) 44 | 45 | app.get('/user', app.nocache, UserController.index) 46 | app.post('/user/forgot', UserController.forgot) 47 | app.post('/user', UserController.create) 48 | app.get('/user/:username', app.nocache, app.auth.authenticate, UserController.read) 49 | app.get('/user/:username/reset', app.nocache, UserController.resetCheck) 50 | app.post('/user/:username/reset', UserController.reset) 51 | app.get('/user/:username/edit', app.nocache, app.auth.authenticate, app.auth.authorize, UserController.edit) 52 | app.put('/user/:username', app.auth.authenticate, app.auth.authorize, UserController.update) 53 | app.del('/user/:username', app.auth.authenticate, app.auth.authorize, UserController.delete) 54 | 55 | app.get('/user/:username/playlist', app.nocache, PlaylistController.index) 56 | app.get('/user/:username/playlist/:playlist', app.nocache, PlaylistController.read) 57 | app.post('/user/:username/playlist', app.auth.authenticate, app.auth.authorize, PlaylistController.create) 58 | app.put('/user/:username/playlist/:playlist', app.auth.authenticate, app.auth.authorize, PlaylistController.update) 59 | app.del('/user/:username/playlist/:playlist', app.auth.authenticate, app.auth.authorize, PlaylistController.delete) 60 | app.put('/user/:username/playlist/:playlist/tracks/add', app.auth.authenticate, app.auth.authorize, PlaylistController.add) 61 | 62 | app.get('/session/refresh', app.nocache, app.auth.authenticate, SessionController.refresh) 63 | app.post('/session', SessionController.create) 64 | app.del('/session', SessionController.delete) 65 | app.get('/logout', app.nocache, SessionController.delete) 66 | 67 | app.get('/lastfm_cache', LastfmController.get) 68 | 69 | app.get('/artist/:artist/track/:track', SearchController.artistTrack) 70 | app.get('/artist/:artist/album/:album', SearchController.artistAlbum) 71 | app.get('/artist/:artist/top-tracks', SearchController.artistTopTracks) 72 | app.get('/artist/:artist/top-albums', SearchController.artistTopAlbums) 73 | app.get('/artist/:artist/similar', SearchController.artistSimilar) 74 | app.get('/artist/:artist', SearchController.artist) 75 | app.get('/search/:query/track', SearchController.queryTrack) 76 | app.get('/search/:query/album', SearchController.queryAlbum) 77 | app.get('/search/:query/artist', SearchController.queryArtist) 78 | app.get('/search/:query', SearchController.query) 79 | 80 | app.error(ApplicationController.error) 81 | app.all('*', ApplicationController.notFound) 82 | 83 | } 84 | -------------------------------------------------------------------------------- /test/playlist.test.js: -------------------------------------------------------------------------------- 1 | describe('Playlist Model', function() { 2 | 3 | var User = app.model('user') 4 | , Playlist = app.model('playlist') 5 | 6 | beforeEach(function(done) { 7 | User.find().remove() 8 | //Playlist.find().remove() 9 | done() 10 | }) 11 | 12 | describe('#create', function() { 13 | 14 | describe('too_long error', function() { 15 | var createString = function(length) { 16 | var string = '' 17 | for (i = 0; i < length; i++) { 18 | string += 'a' 19 | } 20 | return string 21 | } 22 | it('occurs when playlist.name is greater than 50', function(done) { 23 | Playlist.create({ user: 'test', name: createString(51) }, function(err, playlist) { 24 | expect(err.errors.name.type[0]).to.equal('too_long') 25 | expect(err.errors.name.type[1].maxlength).to.equal(50) 26 | done() 27 | }) 28 | }) 29 | }) 30 | 31 | describe('required error', function() { 32 | it('occurs when user is blank', function(done) { 33 | Playlist.create({}, function(err, playlist) { 34 | expect(playlist).to.not.exist 35 | expect(err.errors.user.type[0]).to.equal('required') 36 | expect(err.errors.user.type[1]).to.not.exist 37 | done() 38 | }) 39 | }) 40 | }) 41 | 42 | // json parse errors? 43 | // non-existant user errors? 44 | 45 | describe('successfully', function() { 46 | var user, playlist 47 | 48 | beforeEach(function(done) { 49 | User.create({ 50 | username : 'adrian', 51 | email : 'adrian@test.test', 52 | password : 'pw' 53 | }, function(err, u) { 54 | user = u 55 | expect(user).to.exist 56 | Playlist.create({ 57 | user: u.username, 58 | tracks: [ 0, 1, 2, 3 ] 59 | }, function(err, p) { 60 | playlist = p 61 | expect(playlist).to.exist 62 | done() 63 | }) 64 | }) 65 | }) 66 | 67 | it('adds time data', function(done) { 68 | expect(playlist.time).to.exist 69 | expect(playlist.time.created).to.exist 70 | expect(playlist.time.updated).to.exist 71 | done() 72 | }) 73 | 74 | it('adds default name and tracks', function(done) { 75 | expect(playlist.name).to.equal('Untitled Playlist') 76 | expect(playlist.tracks).to.be.an.instanceof(Array) 77 | expect(playlist.tracks).to.have.length(4) 78 | done() 79 | }) 80 | 81 | it('sets tracks_count based on tracks size', function(done) { 82 | expect(parseInt(playlist.tracks_count)).to.eql(4) 83 | done() 84 | }) 85 | 86 | }) 87 | }) 88 | 89 | describe('#updateAttributes (accessible plugin)', function() { 90 | var user, playlist 91 | 92 | beforeEach(function(done) { 93 | User.create({ 94 | username: 'tester', 95 | email: 'test@test.test', 96 | password: 'test' 97 | }, function(err, u) { 98 | user = u 99 | expect(user).to.exist 100 | Playlist.create({ user: user.username }, function(err, p) { 101 | playlist = p 102 | expect(playlist).to.exist 103 | done() 104 | }) 105 | }) 106 | }) 107 | 108 | it('removes attributes that are not in Playlist.accessible', function(done) { 109 | playlist.updateAttributes({ 110 | nowPlaying: 'blerg' 111 | }) 112 | expect(playlist.nowPlaying).to.not.exist 113 | done() 114 | }) 115 | 116 | it('allows attributes that are in Playlist.accessible', function(done) { 117 | playlist.updateAttributes({ 118 | name: 'hello', 119 | tracks: [1, 2, 3], 120 | sidebar: true 121 | }) 122 | expect(playlist.name).to.equal('hello') 123 | expect(playlist.tracks).to.have.length(3) 124 | expect(playlist.sidebar).to.be.true 125 | done() 126 | }) 127 | 128 | }) 129 | 130 | }) 131 | -------------------------------------------------------------------------------- /public/less/bootstrap/button-groups.less: -------------------------------------------------------------------------------- 1 | // BUTTON GROUPS 2 | // ------------- 3 | 4 | 5 | // Make the div behave like a button 6 | .btn-group { 7 | position: relative; 8 | .clearfix(); // clears the floated buttons 9 | .ie7-restore-left-whitespace(); 10 | } 11 | 12 | // Space out series of button groups 13 | .btn-group + .btn-group { 14 | margin-left: 5px; 15 | } 16 | 17 | // Optional: Group multiple button groups together for a toolbar 18 | .btn-toolbar { 19 | margin-top: @baseLineHeight / 2; 20 | margin-bottom: @baseLineHeight / 2; 21 | .btn-group { 22 | display: inline-block; 23 | .ie7-inline-block(); 24 | } 25 | } 26 | 27 | // Float them, remove border radius, then re-add to first and last elements 28 | .btn-group .btn { 29 | position: relative; 30 | float: left; 31 | margin-left: -1px; 32 | .border-radius(0); 33 | } 34 | // Set corners individual because sometimes a single button can be in a .btn-group and we need :first-child and :last-child to both match 35 | .btn-group .btn:first-child { 36 | margin-left: 0; 37 | -webkit-border-top-left-radius: 4px; 38 | -moz-border-radius-topleft: 4px; 39 | border-top-left-radius: 4px; 40 | -webkit-border-bottom-left-radius: 4px; 41 | -moz-border-radius-bottomleft: 4px; 42 | border-bottom-left-radius: 4px; 43 | } 44 | .btn-group .btn:last-child, 45 | .btn-group .dropdown-toggle { 46 | -webkit-border-top-right-radius: 4px; 47 | -moz-border-radius-topright: 4px; 48 | border-top-right-radius: 4px; 49 | -webkit-border-bottom-right-radius: 4px; 50 | -moz-border-radius-bottomright: 4px; 51 | border-bottom-right-radius: 4px; 52 | } 53 | // Reset corners for large buttons 54 | .btn-group .btn.large:first-child { 55 | margin-left: 0; 56 | -webkit-border-top-left-radius: 6px; 57 | -moz-border-radius-topleft: 6px; 58 | border-top-left-radius: 6px; 59 | -webkit-border-bottom-left-radius: 6px; 60 | -moz-border-radius-bottomleft: 6px; 61 | border-bottom-left-radius: 6px; 62 | } 63 | .btn-group .btn.large:last-child, 64 | .btn-group .large.dropdown-toggle { 65 | -webkit-border-top-right-radius: 6px; 66 | -moz-border-radius-topright: 6px; 67 | border-top-right-radius: 6px; 68 | -webkit-border-bottom-right-radius: 6px; 69 | -moz-border-radius-bottomright: 6px; 70 | border-bottom-right-radius: 6px; 71 | } 72 | 73 | // On hover/focus/active, bring the proper btn to front 74 | .btn-group .btn:hover, 75 | .btn-group .btn:focus, 76 | .btn-group .btn:active, 77 | .btn-group .btn.active { 78 | z-index: 2; 79 | } 80 | 81 | // On active and open, don't show outline 82 | .btn-group .dropdown-toggle:active, 83 | .btn-group.open .dropdown-toggle { 84 | outline: 0; 85 | } 86 | 87 | 88 | 89 | // Split button dropdowns 90 | // ---------------------- 91 | 92 | // Give the line between buttons some depth 93 | .btn-group .dropdown-toggle { 94 | padding-left: 8px; 95 | padding-right: 8px; 96 | @shadow: inset 1px 0 0 rgba(255,255,255,.125), inset 0 1px 0 rgba(255,255,255,.2), 0 1px 2px rgba(0,0,0,.05); 97 | .box-shadow(@shadow); 98 | *padding-top: 5px; 99 | *padding-bottom: 5px; 100 | } 101 | 102 | .btn-group.open { 103 | // IE7's z-index only goes to the nearest positioned ancestor, which would 104 | // make the menu appear below buttons that appeared later on the page 105 | *z-index: @zindexDropdown; 106 | 107 | // Reposition menu on open and round all corners 108 | .dropdown-menu { 109 | display: block; 110 | margin-top: 1px; 111 | .border-radius(5px); 112 | } 113 | 114 | .dropdown-toggle { 115 | background-image: none; 116 | @shadow: inset 0 1px 6px rgba(0,0,0,.15), 0 1px 2px rgba(0,0,0,.05); 117 | .box-shadow(@shadow); 118 | } 119 | } 120 | 121 | // Reposition the caret 122 | .btn .caret { 123 | margin-top: 7px; 124 | margin-left: 0; 125 | } 126 | .btn:hover .caret, 127 | .open.btn-group .caret { 128 | .opacity(100); 129 | } 130 | 131 | 132 | // Account for other colors 133 | .btn-primary, 134 | .btn-danger, 135 | .btn-info, 136 | .btn-success, 137 | .btn-inverse { 138 | .caret { 139 | border-top-color: @white; 140 | .opacity(75); 141 | } 142 | } 143 | 144 | // Small button dropdowns 145 | .btn-small .caret { 146 | margin-top: 4px; 147 | } 148 | 149 | -------------------------------------------------------------------------------- /public/less/bootstrap/type.less: -------------------------------------------------------------------------------- 1 | // Typography.less 2 | // Headings, body text, lists, code, and more for a versatile and durable typography system 3 | // ---------------------------------------------------------------------------------------- 4 | 5 | 6 | // BODY TEXT 7 | // --------- 8 | 9 | p { 10 | margin: 0 0 @baseLineHeight / 2; 11 | font-family: @baseFontFamily; 12 | font-size: @baseFontSize; 13 | line-height: @baseLineHeight; 14 | small { 15 | font-size: @baseFontSize - 2; 16 | color: @grayLight; 17 | } 18 | } 19 | .lead { 20 | margin-bottom: @baseLineHeight; 21 | font-size: 20px; 22 | font-weight: 200; 23 | line-height: @baseLineHeight * 1.5; 24 | } 25 | 26 | // HEADINGS 27 | // -------- 28 | 29 | h1, h2, h3, h4, h5, h6 { 30 | margin: 0; 31 | font-weight: bold; 32 | color: @grayDark; 33 | text-rendering: optimizelegibility; // Fix the character spacing for headings 34 | small { 35 | font-weight: normal; 36 | color: @grayLight; 37 | } 38 | } 39 | h1 { 40 | font-size: 30px; 41 | line-height: @baseLineHeight * 2; 42 | small { 43 | font-size: 18px; 44 | } 45 | } 46 | h2 { 47 | font-size: 24px; 48 | line-height: @baseLineHeight * 2; 49 | small { 50 | font-size: 18px; 51 | } 52 | } 53 | h3 { 54 | line-height: @baseLineHeight * 1.5; 55 | font-size: 18px; 56 | small { 57 | font-size: 14px; 58 | } 59 | } 60 | h4, h5, h6 { 61 | line-height: @baseLineHeight; 62 | } 63 | h4 { 64 | font-size: 14px; 65 | small { 66 | font-size: 12px; 67 | } 68 | } 69 | h5 { 70 | font-size: 12px; 71 | } 72 | h6 { 73 | font-size: 11px; 74 | color: @grayLight; 75 | text-transform: uppercase; 76 | } 77 | 78 | // Page header 79 | .page-header { 80 | padding-bottom: @baseLineHeight - 1; 81 | margin: @baseLineHeight 0; 82 | border-bottom: 1px solid @grayLighter; 83 | } 84 | .page-header h1 { 85 | line-height: 1; 86 | } 87 | 88 | 89 | 90 | // LISTS 91 | // ----- 92 | 93 | // Unordered and Ordered lists 94 | ul, ol { 95 | padding: 0; 96 | margin: 0 0 @baseLineHeight / 2 25px; 97 | } 98 | ul ul, 99 | ul ol, 100 | ol ol, 101 | ol ul { 102 | margin-bottom: 0; 103 | } 104 | ul { 105 | list-style: disc; 106 | } 107 | ol { 108 | list-style: decimal; 109 | } 110 | li { 111 | line-height: @baseLineHeight; 112 | } 113 | ul.unstyled, 114 | ol.unstyled { 115 | margin-left: 0; 116 | list-style: none; 117 | } 118 | 119 | // Description Lists 120 | dl { 121 | margin-bottom: @baseLineHeight; 122 | } 123 | dt, 124 | dd { 125 | line-height: @baseLineHeight; 126 | } 127 | dt { 128 | font-weight: bold; 129 | } 130 | dd { 131 | margin-left: @baseLineHeight / 2; 132 | } 133 | 134 | // MISC 135 | // ---- 136 | 137 | // Horizontal rules 138 | hr { 139 | margin: @baseLineHeight 0; 140 | border: 0; 141 | border-top: 1px solid @hrBorder; 142 | border-bottom: 1px solid @white; 143 | } 144 | 145 | // Emphasis 146 | strong { 147 | font-weight: bold; 148 | } 149 | em { 150 | font-style: italic; 151 | } 152 | .muted { 153 | color: @grayLight; 154 | } 155 | 156 | // Abbreviations and acronyms 157 | abbr { 158 | font-size: 90%; 159 | text-transform: uppercase; 160 | border-bottom: 1px dotted #ddd; 161 | cursor: help; 162 | } 163 | 164 | // Blockquotes 165 | blockquote { 166 | padding: 0 0 0 15px; 167 | margin: 0 0 @baseLineHeight; 168 | border-left: 5px solid @grayLighter; 169 | p { 170 | margin-bottom: 0; 171 | #font > .shorthand(16px,300,@baseLineHeight * 1.25); 172 | } 173 | small { 174 | display: block; 175 | line-height: @baseLineHeight; 176 | color: @grayLight; 177 | &:before { 178 | content: '\2014 \00A0'; 179 | } 180 | } 181 | 182 | // Float right with text-align: right 183 | &.pull-right { 184 | float: right; 185 | padding-left: 0; 186 | padding-right: 15px; 187 | border-left: 0; 188 | border-right: 5px solid @grayLighter; 189 | p, 190 | small { 191 | text-align: right; 192 | } 193 | } 194 | } 195 | 196 | // Quotes 197 | q:before, 198 | q:after, 199 | blockquote:before, 200 | blockquote:after { 201 | content: ""; 202 | } 203 | 204 | // Addresses 205 | address { 206 | display: block; 207 | margin-bottom: @baseLineHeight; 208 | line-height: @baseLineHeight; 209 | font-style: normal; 210 | } 211 | 212 | // Misc 213 | small { 214 | font-size: 100%; 215 | } 216 | cite { 217 | font-style: normal; 218 | } 219 | -------------------------------------------------------------------------------- /test/mongoose_validators.test.js: -------------------------------------------------------------------------------- 1 | describe('MongooseValidators Library', function() { 2 | 3 | var validators = app.mongooseValidators 4 | 5 | describe('#required', function() { 6 | it('returns false for non-strings', function() { 7 | expect(validators.required([])).to.be.false 8 | expect(validators.required({})).to.be.false 9 | expect(validators.required(0)).to.be.false 10 | expect(validators.required(null)).to.be.false 11 | expect(validators.required(true)).to.be.false 12 | expect(validators.required()).to.be.false 13 | }) 14 | 15 | it('returns length of a string', function() { 16 | expect(validators.required('')).to.equal(0) 17 | expect(validators.required('123456')).to.equal(6) 18 | }) 19 | }) 20 | 21 | describe('#tooLong', function() { 22 | var tooLong = validators.tooLong(16) 23 | 24 | it('returns a function', function() { 25 | expect(tooLong).to.be.a('function') 26 | }) 27 | 28 | describe('the function returned by #too_long(16)', function() { 29 | it('returns true if passed a string with length equal to 16', function () { 30 | expect(tooLong('1234567890123456')).to.be.true 31 | }) 32 | 33 | it('returns true if passed a string with length equal to 0', function () { 34 | expect(tooLong('')).to.be.true 35 | }) 36 | 37 | it('returns false if passed a string with length equal to 17', function () { 38 | expect(tooLong('12345678901234567')).to.be.false 39 | }) 40 | 41 | it('returns false if passed a non-string', function () { 42 | expect(tooLong()).to.be.false 43 | expect(tooLong(0)).to.be.false 44 | }) 45 | }) 46 | }) 47 | 48 | describe('#match', function() { 49 | var match = validators.match(/[a]+/) 50 | 51 | it('returns a function', function() { 52 | expect(match).to.be.a('function') 53 | }) 54 | 55 | describe('the function returned by #match(/[a]+/)', function() { 56 | it('returns non-null if passed a string of a\'s', function () { 57 | expect(match('aaaaaaa')).to.not.equal(null) 58 | }) 59 | 60 | it('returns null if passed a string with characters other than a', function () { 61 | expect(match('b')).to.equal(null) 62 | }) 63 | }) 64 | }) 65 | 66 | describe('#alreadyTaken', function() { 67 | var alreadyTaken = validators.alreadyTaken('User', 'username') 68 | , User = app.model('User') 69 | 70 | it('returns a function', function() { 71 | expect(alreadyTaken).to.be.a('function') 72 | }) 73 | 74 | describe('the function returned by #alreadyTaken("User", "username")', function() { 75 | 76 | beforeEach(function(done) { 77 | User.find().remove() 78 | User.create({ 79 | username: 'username', 80 | email: 'a@b.c', 81 | password: 'test' 82 | }, function(err, user) { 83 | expect(user).to.exist 84 | done() 85 | }) 86 | }) 87 | 88 | it('passes true to callback if a username is not taken', function (done) { 89 | var user = new User({ username: 'otherusername' }) 90 | 91 | alreadyTaken.apply(user, [ 'otherusername', function(pass) { 92 | expect(pass).to.be.true 93 | done() 94 | } ]) 95 | }) 96 | 97 | it('passes false to callback if a username is taken', function (done) { 98 | var user = new User({ username: 'username' }) 99 | alreadyTaken.apply(user, [ 'username', function(pass) { 100 | expect(pass).to.be.false 101 | done() 102 | }]) 103 | }) 104 | 105 | }) 106 | }) 107 | 108 | describe('#isURL', function() { 109 | it('returns true for non-strings', function() { 110 | expect(validators.isURL([])).to.be.true 111 | expect(validators.isURL()).to.be.true 112 | }) 113 | 114 | it('returns true for an empty string', function() { 115 | expect(validators.isURL('')).to.be.true 116 | }) 117 | 118 | it('returns false for an non-url string', function() { 119 | expect(validators.isURL('osjfda')).to.be.false 120 | }) 121 | 122 | it('returns true for a string that is a url', function() { 123 | expect(validators.isURL('http://twitter.com/')).to.be.true 124 | }) 125 | }) 126 | 127 | }) -------------------------------------------------------------------------------- /public/less/bootstrap/buttons.less: -------------------------------------------------------------------------------- 1 | // BUTTON STYLES 2 | // ------------- 3 | 4 | 5 | // Base styles 6 | // -------------------------------------------------- 7 | 8 | // Core 9 | .btn { 10 | display: inline-block; 11 | padding: 4px 10px 4px; 12 | margin-bottom: 0; // For input.btn 13 | font-size: @baseFontSize; 14 | line-height: @baseLineHeight; 15 | color: @grayDark; 16 | text-align: center; 17 | text-shadow: 0 1px 1px rgba(255,255,255,.75); 18 | vertical-align: middle; 19 | .buttonBackground(@white, darken(@white, 10%)); 20 | border: 1px solid #ccc; 21 | border-bottom-color: #bbb; 22 | .border-radius(4px); 23 | @shadow: inset 0 1px 0 rgba(255,255,255,.2), 0 1px 2px rgba(0,0,0,.05); 24 | .box-shadow(@shadow); 25 | cursor: pointer; 26 | 27 | // Give IE7 some love 28 | .reset-filter(); 29 | .ie7-restore-left-whitespace(); 30 | } 31 | 32 | // Hover state 33 | .btn:hover { 34 | color: @grayDark; 35 | text-decoration: none; 36 | background-color: darken(@white, 10%); 37 | background-position: 0 -15px; 38 | 39 | // transition is only when going to hover, otherwise the background 40 | // behind the gradient (there for IE<=9 fallback) gets mismatched 41 | .transition(background-position .1s linear); 42 | } 43 | 44 | // Focus state for keyboard and accessibility 45 | .btn:focus { 46 | .tab-focus(); 47 | } 48 | 49 | // Active state 50 | .btn.active, 51 | .btn:active { 52 | background-image: none; 53 | @shadow: inset 0 2px 4px rgba(0,0,0,.15), 0 1px 2px rgba(0,0,0,.05); 54 | .box-shadow(@shadow); 55 | background-color: darken(@white, 10%); 56 | background-color: darken(@white, 15%) e("\9"); 57 | outline: 0; 58 | } 59 | 60 | // Disabled state 61 | .btn.disabled, 62 | .btn[disabled] { 63 | cursor: default; 64 | background-image: none; 65 | background-color: darken(@white, 10%); 66 | .opacity(65); 67 | .box-shadow(none); 68 | } 69 | 70 | 71 | // Button Sizes 72 | // -------------------------------------------------- 73 | 74 | // Large 75 | .btn-large { 76 | padding: 9px 14px; 77 | font-size: @baseFontSize + 2px; 78 | line-height: normal; 79 | .border-radius(5px); 80 | } 81 | .btn-large [class^="icon-"] { 82 | margin-top: 1px; 83 | } 84 | 85 | // Small 86 | .btn-small { 87 | padding: 5px 9px; 88 | font-size: @baseFontSize - 2px; 89 | line-height: @baseLineHeight - 2px; 90 | } 91 | .btn-small [class^="icon-"] { 92 | margin-top: -1px; 93 | } 94 | 95 | // Mini 96 | .btn-mini { 97 | padding: 2px 6px; 98 | font-size: @baseFontSize - 2px; 99 | line-height: @baseLineHeight - 4px; 100 | } 101 | 102 | 103 | // Alternate buttons 104 | // -------------------------------------------------- 105 | 106 | // Set text color 107 | // ------------------------- 108 | .btn-primary, 109 | .btn-primary:hover, 110 | .btn-warning, 111 | .btn-warning:hover, 112 | .btn-danger, 113 | .btn-danger:hover, 114 | .btn-success, 115 | .btn-success:hover, 116 | .btn-info, 117 | .btn-info:hover, 118 | .btn-inverse, 119 | .btn-inverse:hover { 120 | text-shadow: 0 -1px 0 rgba(0,0,0,.25); 121 | color: @white; 122 | } 123 | // Provide *some* extra contrast for those who can get it 124 | .btn-primary.active, 125 | .btn-warning.active, 126 | .btn-danger.active, 127 | .btn-success.active, 128 | .btn-info.active, 129 | .btn-dark.active { 130 | color: rgba(255,255,255,.75); 131 | } 132 | 133 | // Set the backgrounds 134 | // ------------------------- 135 | .btn-primary { 136 | .buttonBackground(@primaryButtonBackground, spin(@primaryButtonBackground, 20)); 137 | } 138 | // Warning appears are orange 139 | .btn-warning { 140 | .buttonBackground(lighten(@orange, 15%), @orange); 141 | } 142 | // Danger and error appear as red 143 | .btn-danger { 144 | .buttonBackground(#ee5f5b, #bd362f); 145 | } 146 | // Success appears as green 147 | .btn-success { 148 | .buttonBackground(#62c462, #51a351); 149 | } 150 | // Info appears as a neutral blue 151 | .btn-info { 152 | .buttonBackground(#5bc0de, #2f96b4); 153 | } 154 | // Inverse appears as dark gray 155 | .btn-inverse { 156 | .buttonBackground(#454545, #262626); 157 | } 158 | 159 | 160 | // Cross-browser Jank 161 | // -------------------------------------------------- 162 | 163 | button.btn, 164 | input[type="submit"].btn { 165 | 166 | // Firefox 3.6 only I believe 167 | &::-moz-focus-inner { 168 | padding: 0; 169 | border: 0; 170 | } 171 | 172 | // IE7 has some default padding on button controls 173 | *padding-top: 2px; 174 | *padding-bottom: 2px; 175 | &.large { 176 | *padding-top: 7px; 177 | *padding-bottom: 7px; 178 | } 179 | &.small { 180 | *padding-top: 3px; 181 | *padding-bottom: 3px; 182 | } 183 | } 184 | -------------------------------------------------------------------------------- /app/models/user.js: -------------------------------------------------------------------------------- 1 | var mongoose = require('mongoose') 2 | , bcrypt = require('../../lib/bcrypt') // for bcrypt 3 | , crypto = require('crypto') // for md5 4 | , Schema = mongoose.Schema 5 | , ObjectId = Schema.ObjectId 6 | , Mixed = Schema.Types.Mixed 7 | , app = require('../../') 8 | , validators = app.mongooseValidators 9 | 10 | var User = module.exports = new Schema({ 11 | username : { type: String, unique: true, set: app.mongooseSetters.toLower }, 12 | email : { type: String, unique: true, set: app.mongooseSetters.toLower }, 13 | password : { type: String }, 14 | salt : { type: String }, 15 | bio : { type: String }, 16 | fullname : { type: String }, 17 | location : { type: String }, 18 | website : { type: String }, 19 | admin : { type: Boolean }, 20 | reset : { 21 | token: { type: String }, 22 | expire: { type: Date } 23 | } 24 | }, { strict: true }) 25 | 26 | User.plugin(app.mongoosePlugins.timestamps, { index: true }) 27 | User.plugin(app.mongoosePlugins.accessible, [ 'email', 'password', 'fullname', 'location', 'website', 'bio' ]) 28 | 29 | User.method({ 30 | exposeJSON: function(user) { 31 | var json = { 32 | username : this.username, 33 | fullname : this.fullname, 34 | bio : this.bio, 35 | location : this.location, 36 | website : this.website, 37 | url : this.url() 38 | } 39 | 40 | if (user && (user.id == this.id || user.admin)) { 41 | json.email = this.email 42 | json._id = this._id 43 | } 44 | if (this.playlists) { 45 | json.playlists = app._.map(this.playlists, function(playlist) { 46 | return playlist.exposeJSON() 47 | }) 48 | } 49 | return json 50 | }, 51 | 52 | url: function() { 53 | return app.set('base_url') + '/user/' + this.user 54 | }, 55 | 56 | findPlaylists: function(next) { 57 | app.model('Playlist') 58 | .find({ user: this.username }) 59 | .select('user', 'name', 'autosave', 'sidebar', 'tracks_count', 'time') 60 | .run(next) 61 | }, 62 | 63 | checkPassword: function(password, next) { 64 | var self = this 65 | bcrypt.pepperedHash(password, this.salt, function(err, hash) { 66 | if (err || hash != self.password) { 67 | return next(err || null, false) 68 | } 69 | next(null, true) 70 | }) 71 | }, 72 | 73 | generateResetToken: function(next) { 74 | this.reset = { 75 | token: crypto.createHash('md5').update(new Date + Math.random()).digest('hex'), 76 | expire: app.moment().add('days', 7).utc().toDate() 77 | } 78 | this.save(next) 79 | }, 80 | 81 | resetPassword: function(attrs, next) { 82 | if (!attrs.password) { 83 | return next(new app.Error(400, { $: 'reset_password_required' })) 84 | } else if (!this.validResetToken(attrs.token)) { 85 | return next(new app.Error(401, { $: 'reset_token_expired' })) 86 | } 87 | this.password = attrs.password 88 | this.reset = null 89 | this.save(next) 90 | }, 91 | 92 | validResetToken: function(token) { 93 | return (!this.reset || this.reset.expire < app.moment().utc().toDate() || token != this.reset.token) ? false : true 94 | }, 95 | 96 | link: function() { 97 | return app.set('base_url') + '/user/' + this.username 98 | } 99 | }) 100 | 101 | User.static({ 102 | findByLogin: function(login, next) { 103 | var User = app.model('User') 104 | , login = login.toLowerCase() 105 | 106 | User.findOne({ $or: [ { email: login }, { username: login } ] }, function(err, user) { 107 | if (err || !user) { 108 | return next(err || null) 109 | } 110 | next(null, user) 111 | }) 112 | } 113 | }) 114 | 115 | User.pre('validate', function(next) { 116 | if (this.isNew) { 117 | this.username = this.username || '' 118 | this.password = this.password || '' 119 | this.email = this.email || '' 120 | } 121 | next() 122 | }) 123 | 124 | User.pre('validate', function(next) { 125 | if (!this.isNew && !this.isModified('password')) { 126 | return next() 127 | } else if (!this.password) { 128 | return next() // should be caught by required error 129 | } 130 | 131 | var self = this 132 | bcrypt.salt(function(err, salt) { 133 | if (err) { 134 | return next(err) 135 | } 136 | 137 | bcrypt.pepperedHash(self.password, salt, function(err, hash) { 138 | if (err) { 139 | return next(err) 140 | } 141 | 142 | self.salt = salt 143 | self.password = hash 144 | return next() 145 | }) 146 | }) 147 | }) 148 | 149 | 150 | // 151 | // Validators 152 | // 153 | 154 | // Username 155 | User.path('username').validate(validators.required, [ 'required' ]) 156 | User.path('username').validate(validators.tooLong(16), [ 'too_long', { maxlength: 16 } ]) 157 | User.path('username').validate(validators.match(/^([a-z0-9]*)$/i), [ 'bad_characters', { characters: 'A-Z and 0-9' } ]) 158 | User.path('username').validate(validators.alreadyTaken('User', 'username'), [ 'already_taken' ]) 159 | 160 | // Password 161 | User.path('password').validate(validators.required, [ 'required' ]) 162 | 163 | // Email 164 | User.path('email').validate(validators.required, [ 'required' ]) 165 | User.path('email').validate(validators.tooLong(200), [ 'too_long', { maxlength: 200 } ]) 166 | User.path('email').validate(validators.match(/^\S+@\S+\.\S+$/), [ 'bad_format' ]) 167 | User.path('email').validate(validators.alreadyTaken('User', 'email'), [ 'already_taken' ]) 168 | 169 | // Bio 170 | User.path('bio').validate(validators.tooLong(1000), [ 'too_long', { maxlength: 1000 } ]) 171 | 172 | // Full Name 173 | User.path('fullname').validate(validators.tooLong(100), [ 'too_long', { maxlength: 100 } ]) 174 | 175 | // Location 176 | User.path('location').validate(validators.tooLong(100), [ 'too_long', { maxlength: 100 } ]) 177 | 178 | // Website 179 | User.path('website').validate(validators.tooLong(200), [ 'too_long', { maxlength: 200 } ]) 180 | User.path('website').validate(validators.isURL, [ 'bad_format' ]) 181 | 182 | mongoose.model('User', User) 183 | -------------------------------------------------------------------------------- /public/js/keys.js: -------------------------------------------------------------------------------- 1 | View.KeyboardShortcuts = Backbone.View.extend({ 2 | el: $(document), 3 | 4 | template: jade.compile($('#keyboard-shortcuts-template').text()), 5 | 6 | events: { 7 | 'keypress #query' : 'searchAll', 8 | 'keydown' : 'keyMapper', 9 | 'keyup' : 'setLastVolume', 10 | 'click #keyboard-shortcuts' : 'render' 11 | }, 12 | 13 | render: function() { 14 | ModalView.render(this.template()) 15 | }, 16 | 17 | searchAll: function(e) { 18 | if (e.keyCode == 13) { 19 | Router.navigate('/search/' + encodeURIComponent($('#query').val()), { trigger: true }) 20 | $('#query').val('').blur() 21 | } 22 | }, 23 | 24 | keyMapper: function(e) { 25 | if ($(e.target).is('input, textarea')) { 26 | return 27 | } 28 | 29 | var fn = KeyMapper['k' + e.keyCode] 30 | if (fn) { 31 | return fn(e) 32 | } 33 | }, 34 | 35 | setLastVolume: function(e) { 36 | var self = this 37 | 38 | if ($(e.target).is('input, textarea')) { 39 | return 40 | } 41 | 42 | if (e.keyCode == 38 || e.keyCode == 40) { 43 | _.defer(function() { 44 | var value = Video.player.getVolume() 45 | if (value) { 46 | Controls.lastVolume = value 47 | } 48 | }) 49 | } 50 | } 51 | }) 52 | 53 | KeyMapper = { 54 | 55 | // The event lacks keyboard modifiers (ctrl, alt, shift, meta) 56 | keypressHasModifier: function(e) { 57 | return (e.metaKey || e.ctrlKey || e.altKey || e.shiftKey) ? true : false 58 | }, 59 | 60 | // ESCAPE 61 | k27: function(e) { 62 | if (Video.fullscreen) { 63 | Controls.toggleFullscreen() 64 | } 65 | return false 66 | }, 67 | 68 | // SPACEBAR 69 | k32: function(e) { 70 | Controls.playPause() 71 | return false 72 | }, 73 | 74 | // LEFT 75 | k37: function(e) { 76 | if (this.keypressHasModifier(e)) return 77 | window.Controls.prev() 78 | return false 79 | }, 80 | 81 | // UP 82 | k38: function(e) { 83 | if (this.keypressHasModifier(e)) return 84 | var value = Video.player.getVolume() 85 | Video.volume(value + 2) 86 | return false 87 | }, 88 | 89 | // RIGHT 90 | k39: function(e) { 91 | if (this.keypressHasModifier(e)) return 92 | window.Controls.next() 93 | return false 94 | }, 95 | 96 | // DOWN 97 | k40: function(e) { 98 | if (this.keypressHasModifier(e)) return 99 | var value = Video.player.getVolume() 100 | Video.volume(value - 2) 101 | return false 102 | }, 103 | 104 | // could make this a loop 105 | 106 | // 1 107 | k49: function(e) { 108 | if (this.keypressHasModifier(e)) return 109 | Video.track.setVideo(0) 110 | return false 111 | }, 112 | 113 | // 2 114 | k50: function(e) { 115 | if (this.keypressHasModifier(e)) return 116 | Video.track.setVideo(1) 117 | return false 118 | }, 119 | 120 | // 3 121 | k51: function(e) { 122 | if (this.keypressHasModifier(e)) return 123 | Video.track.setVideo(2) 124 | return false 125 | }, 126 | 127 | // 4 128 | k52: function(e) { 129 | if (this.keypressHasModifier(e)) return 130 | Video.track.setVideo(3) 131 | return false 132 | }, 133 | 134 | // 5 135 | k53: function(e) { 136 | if (this.keypressHasModifier(e)) return 137 | Video.track.setVideo(4) 138 | return false 139 | }, 140 | 141 | // 6 142 | k54: function(e) { 143 | if (this.keypressHasModifier(e)) return 144 | Video.track.setVideo(5) 145 | return false 146 | }, 147 | 148 | // 7 149 | k55: function(e) { 150 | if (this.keypressHasModifier(e)) return 151 | Video.track.setVideo(6) 152 | return false 153 | }, 154 | 155 | // 8 156 | k56: function(e) { 157 | if (this.keypressHasModifier(e)) return 158 | Video.track.setVideo(7) 159 | return false 160 | }, 161 | 162 | // 9 163 | k57: function(e) { 164 | if (this.keypressHasModifier(e)) return 165 | Video.track.setVideo(8) 166 | return false 167 | }, 168 | 169 | // D 170 | k68: function(e) { 171 | if (this.keypressHasModifier(e)) return 172 | Controls.toggleRadio() 173 | return false 174 | }, 175 | 176 | // F 177 | k70: function(e) { 178 | if (this.keypressHasModifier(e)) return 179 | Controls.toggleFullscreen() 180 | return false 181 | }, 182 | 183 | // M 184 | k77: function(e) { 185 | if (this.keypressHasModifier(e)) return 186 | Controls.toggleMute() 187 | return false 188 | }, 189 | 190 | // P 191 | k80: function(e) { 192 | if (this.keypressHasModifier(e)) return 193 | NowPlaying.navigateTo() 194 | return false 195 | }, 196 | 197 | // Q 198 | k81: function(e) { 199 | if (this.keypressHasModifier(e)) return 200 | Controls.toggleQuality() 201 | return false 202 | }, 203 | 204 | // R 205 | k82: function(e) { 206 | if (this.keypressHasModifier(e)) return 207 | Controls.toggleRepeat() 208 | return false 209 | }, 210 | 211 | // S 212 | k83: function(e) { 213 | if (this.keypressHasModifier(e)) return 214 | Controls.toggleShuffle() 215 | return false 216 | }, 217 | 218 | // V 219 | k86: function(e) { 220 | if (this.keypressHasModifier(e)) return 221 | if (Video.fullscreen) { 222 | Controls.toggleFullscreen() 223 | } 224 | Video.jumpTo() 225 | return false 226 | }, 227 | 228 | // X 229 | k88: function(e) { 230 | if (this.keypressHasModifier(e)) return 231 | if (window.Video && Video.track) { 232 | NowPlaying.tracks.remove([ Video.track ]) 233 | } 234 | return false 235 | }, 236 | 237 | // / 238 | k191: function(e) { 239 | if (this.keypressHasModifier(e)) return 240 | if (Video.fullscreen) { 241 | Controls.toggleFullscreen() 242 | } 243 | _.defer(function() { 244 | $body.scrollTop(0) 245 | $('#query').focus() 246 | }) 247 | return false 248 | } 249 | 250 | } 251 | 252 | $(function() { 253 | _.bindAll(KeyMapper, 'keypressHasModifier', 254 | 'k27', 'k32', 'k37', 'k38', 'k39', 'k40', 255 | 'k49', 'k50', 'k51', 'k52', 'k53', 'k54', 'k55', 'k56', 'k57', 256 | 'k68', 'k70', 'k77', 'k80', 'k81', 'k82', 'k83', 'k86', 'k88', 'k191') 257 | }) 258 | 259 | 260 | ; -------------------------------------------------------------------------------- /public/js/track.js: -------------------------------------------------------------------------------- 1 | Model.Track = Backbone.Model.extend({ 2 | 3 | initialize: function () { 4 | _.bindAll(this, 'setVideoIds') 5 | this.view = new View.Track({ model: this }) 6 | this.viewTrackInfo = new View.TrackInfo({ model: this }) 7 | }, 8 | 9 | play: function(force) { 10 | var self = this 11 | 12 | if (!Video.player || Video.loading || this.playing) { 13 | Video.seek(0) 14 | return false 15 | } 16 | 17 | if (NowPlaying != this.collection.playlist) { 18 | this.collection.playlist.setNowPlaying() 19 | } 20 | 21 | if (_.isUndefined(this.videos)) { 22 | this.getVideoIds() 23 | } else if (_.isEmpty(this.videos)) { 24 | this.noVideos() 25 | } else { 26 | this.setPlaying() 27 | 28 | Video.skipDirection = 'next' 29 | Meow.render({ 30 | message: 'Now playing ' + this.get('name') + ' by ' + this.get('artist') + '.', 31 | type: 'info' 32 | }) 33 | 34 | if (force) { 35 | this.removeFromHistory() 36 | } 37 | this.addToHistory() 38 | 39 | this.setVideo(0) 40 | Video.play() 41 | } 42 | }, 43 | 44 | // TODO clean this up, this.playing is a hack 45 | youtubeError: function(error) { 46 | if (error == 150) { 47 | this.video = null 48 | this.videos = _.rest(this.videos) 49 | if (this.videos.length) { 50 | this.setVideo() 51 | this.playing = false 52 | this.play() 53 | } else { 54 | this.noVideos() 55 | } 56 | } 57 | }, 58 | 59 | noVideos: function() { 60 | this.setPlaying() 61 | this.error = true 62 | this.view.render().$el.addClass('error') 63 | this.skip() 64 | }, 65 | 66 | removeFromHistory: function() { 67 | Shuffle.history.remove(this) 68 | }, 69 | 70 | addToHistory: function() { 71 | Shuffle.history.add(this) 72 | }, 73 | 74 | skip: function() { 75 | this.addToHistory() 76 | if (Video.skipDirection == 'prev') { 77 | NowPlaying.tracks.prev() 78 | } else { 79 | NowPlaying.tracks.next() 80 | } 81 | this.removeFromHistory() 82 | }, 83 | 84 | getVideoIds: function() { 85 | if (Video.search(this.toJSON())) { 86 | window.setTrackVideoIds = this.setVideoIds 87 | } 88 | }, 89 | 90 | setVideoIds: function(data) { 91 | if (!data.items) { 92 | this.videos = []; 93 | } else { 94 | this.videos = _.map(data.items, function(item) { 95 | return { 96 | id: item.id 97 | }; 98 | }); 99 | } 100 | 101 | window.setTrackVideoIds = null 102 | Video.loading = false 103 | this.play() 104 | }, 105 | 106 | setVideo: function(i) { 107 | if (this.videos && this.videos[i]) { 108 | this.video = this.videos[i].id 109 | Video.load(this.video) 110 | } 111 | }, 112 | 113 | unsetPlaying: function() { 114 | this.playing = false 115 | this.view.$el.removeClass('playing').find('.icon-music').addClass('icon-play').removeClass('icon-music') 116 | this.viewTrackInfo.render() 117 | }, 118 | 119 | setPlaying: function() { 120 | if (Video.track && Video.track != this) { 121 | Video.track.unsetPlaying() 122 | } 123 | Video.track = this 124 | this.playing = true 125 | this.view.$el.addClass('playing').find('.icon-play').addClass('icon-music').removeClass('icon-play') 126 | this.viewTrackInfo.render() 127 | }, 128 | 129 | addSimilarTrack: function() { 130 | if (!this.similarTracks || !this.similarTracks.length || (this.collection && this.collection.playlist != window.NowPlaying)) { 131 | return 132 | } 133 | NowPlaying.tracks.add(this.similarTracks.randomWithout([]).clone()) 134 | } 135 | 136 | }) 137 | 138 | View.TrackInfo = Backbone.View.extend({ 139 | template: jade.compile($('#track-info-template').text()), 140 | 141 | render: function() { 142 | $('#controls .track-info').html(!this.model.playing ? '' : this.template({ track: this.model })) 143 | } 144 | }) 145 | 146 | View.Track = Backbone.View.extend(_.extend(Mixins.TrackViewEvents, { 147 | tagName: 'tr', 148 | template: jade.compile($('#track-template').text()), 149 | 150 | events: { 151 | 'dblclick' : 'playNow', 152 | 'click .play-now' : 'playNow', 153 | 'click .dropdown' : 'dropdown', 154 | 'click .queue-next' : 'queueNext', 155 | 'click .queue-last' : 'queueLast', 156 | 'click .add-to-playlist' : 'addToPlaylist', 157 | 'click .remove' : 'removeTrack' 158 | }, 159 | 160 | initialize: function() { 161 | _.bindAll(this, 'playNow', 'queueNext', 'queueLast', 'addToPlaylist') 162 | this.render() 163 | }, 164 | 165 | render: function() { 166 | this.$el.html(this.template({ track: this.model.toJSON() })) 167 | return this 168 | }, 169 | 170 | playNow: function() { 171 | this.model.play(true) 172 | this.$el.find('.dropdown').removeClass('open') 173 | return false 174 | } 175 | 176 | })) 177 | 178 | Collection.Tracks = Backbone.Collection.extend({ 179 | model: Model.Track, 180 | 181 | play: function() { 182 | if (!this.length) { 183 | return 184 | } 185 | if (Shuffle.active) { 186 | this.randomWithout([]).play() 187 | } else { 188 | this.at(0).play() 189 | } 190 | }, 191 | 192 | randomWithout: function(without) { 193 | var tracks = this.without.apply(this, without) 194 | return tracks[Math.floor(Math.random() * tracks.length)] 195 | }, 196 | 197 | next: function() { 198 | var next = null 199 | 200 | if (Video.loading || Video.state == 3 || Video.tryRepeat()) { 201 | return 202 | } 203 | 204 | if (Shuffle.active) { 205 | return Shuffle.next() 206 | } else { 207 | next = (!Video.track || Video.track === this.last()) ? this.first() : this.at(this.indexOf(Video.track) + 1) 208 | } 209 | next.play() 210 | }, 211 | 212 | prev: function() { 213 | var prev = null 214 | 215 | if (Video.loading || Video.state == 3 || Video.tryRepeat() || Video.trySeek()) { 216 | return 217 | } 218 | 219 | Video.skipDirection = 'prev' 220 | if (Shuffle.active) { 221 | return Shuffle.prev() 222 | } else { 223 | prev = (!Video.track || Video.track === this.first()) ? this.last() : this.at(this.indexOf(Video.track) - 1) 224 | } 225 | prev.play() 226 | } 227 | 228 | }) 229 | 230 | 231 | ; 232 | -------------------------------------------------------------------------------- /public/less/jukesy/app.less: -------------------------------------------------------------------------------- 1 | @import "include.less"; 2 | @import "welcome.less"; 3 | @import "spinner.less"; 4 | @import "results.less"; 5 | @import "controls.less"; 6 | @import "playlist.less"; 7 | 8 | html { 9 | background: #060606; 10 | } 11 | 12 | // special dropdown styles adjust font image to up-state 13 | .btn-group.open .dropdown-toggle, .dropdown.open .dropdown-toggle { 14 | .icon-caret-down:before { content: "\f0d8"; } 15 | .icon-chevron-down:before { content: "\f077"; } 16 | } 17 | 18 | #templates { 19 | display: none; 20 | } 21 | .modal .close { color: white; } 22 | 23 | body { 24 | &.dragging { 25 | .no-select(); 26 | } 27 | &.fullscreen { 28 | position: absolute; 29 | overflow: hidden; 30 | #video-wrapper { 31 | position: absolute; 32 | padding: 0; 33 | left: 0; 34 | top: 0; 35 | z-index: @zindexFullscreenVideo; 36 | } 37 | } 38 | &.modal-open { 39 | overflow: hidden; 40 | } 41 | } 42 | 43 | #container { 44 | padding-bottom: 80px; 45 | } 46 | 47 | a:hover { 48 | cursor: pointer; 49 | } 50 | 51 | .hero-unit { 52 | padding: 1.5em; 53 | background: #222; 54 | color: #aaa; 55 | box-shadow: inset 0 0 10px #000; 56 | text-shadow: 1px 1px 1px #333; 57 | } 58 | 59 | #header .navbar-inner { 60 | background: @blueDark; 61 | border-bottom: 0; 62 | 63 | a { 64 | color: @grayLighter; 65 | border-left: 0; 66 | &:hover { 67 | border-bottom-color: @blue; 68 | } 69 | } 70 | 71 | .dropdown.open .dropdown-toggle:hover { 72 | border-bottom-color: transparent; 73 | } 74 | 75 | #query-wrapper { 76 | position: relative; 77 | padding-right: 20px; 78 | 79 | #query { 80 | margin: 6px 0 0; 81 | padding-right: 20px; 82 | background: @grayLighter; 83 | border-width: 2px; 84 | border-color: @blue; 85 | color: @blue; 86 | font-size: 13px; 87 | 88 | &::-webkit-input-placeholder, &::-moz-placeholder { 89 | color: @blue; 90 | } 91 | &:focus { 92 | background: @white; 93 | } 94 | } 95 | 96 | i.icon-search { 97 | position: absolute; 98 | top: 12px; 99 | right: 8px; 100 | color: @blue; 101 | } 102 | } 103 | 104 | .dropdown { 105 | .dropdown-menu { 106 | background: @blueDark; 107 | margin-top: 0; 108 | border: 0; 109 | .radius(0 0 4px 4px); 110 | a { 111 | border-color: transparent; 112 | color: @grayLighter; 113 | line-height: 0.8em; 114 | padding: 8px 10px; 115 | &:hover { 116 | background: @blue; 117 | color: white; 118 | } 119 | } 120 | .divider { 121 | background: @blue; 122 | } 123 | } 124 | } 125 | 126 | #session-button { 127 | .dropdown-toggle { 128 | width: 140px; 129 | text-align: right; 130 | } 131 | li a { 132 | text-align: left; 133 | } 134 | .nav { 135 | margin-right: 0; 136 | .caret { 137 | margin-top: 8px; 138 | opacity: 1; 139 | } 140 | } 141 | } 142 | 143 | #session-button .nav li > a { 144 | padding: 10px 10px 8px; 145 | } 146 | #brand { 147 | padding: 10px 20px 8px; 148 | } 149 | } 150 | 151 | #main { 152 | margin-bottom: 20px; 153 | 154 | .btn-group.pull-right, 155 | .btn.show-more { 156 | margin-left: 15px; 157 | } 158 | } 159 | 160 | #sidebar { 161 | ul { 162 | margin: 0 -8px 0 0; 163 | padding: 0; 164 | font-size: 14px; 165 | 166 | li.divider { 167 | margin: 8px 0; 168 | border: 1px solid @blueDark; 169 | } 170 | 171 | li { 172 | a { 173 | overflow: hidden; 174 | width: 148px; 175 | height: 20px; 176 | margin: 2px 0; 177 | padding: 2px 0; 178 | font-size: 14px; 179 | line-height: 20px; 180 | &:hover { 181 | color: @grayLighter; 182 | background: transparent; 183 | } 184 | &.active { 185 | color: @grayLighter; 186 | } 187 | 188 | &.new { opacity: 0.5; } 189 | &.changed { opacity: 0.8; } 190 | &.changed:hover, &.new:hover { opacity: 1; } 191 | } 192 | } 193 | } 194 | } 195 | 196 | footer { 197 | margin-bottom: 40px; 198 | a { 199 | margin: 0 5px; 200 | &:first-child { 201 | margin-left: 0; 202 | } 203 | } 204 | } 205 | 206 | #video-wrapper { 207 | padding: 0; 208 | margin-bottom: 10px; 209 | text-align: center; 210 | div { 211 | position: relative; 212 | display: inline-block; 213 | 214 | #quality { 215 | display: none; 216 | position: absolute; 217 | top: 0; 218 | right: 0; 219 | padding: 5px 10px; 220 | background: @blueDark; 221 | font-size: 12px; 222 | color: @white; 223 | cursor: pointer; 224 | } 225 | &:hover #quality { 226 | display: block; 227 | } 228 | #video { 229 | .no-select(); 230 | background: @grayDark; 231 | } 232 | } 233 | } 234 | 235 | #keyboard-shortcuts-modal { 236 | font-size: 12px; 237 | color: #666; 238 | 239 | .third { 240 | width: 33%; 241 | display: inline-block; 242 | vertical-align: top; 243 | } 244 | .key { 245 | display: inline-block; 246 | background: #eee; 247 | .gradient(#fff, #eee); 248 | .radius(2px); 249 | padding: 1px 7px; 250 | margin: 0 10px 5px 0; 251 | border: 1px solid #666; 252 | border-bottom: 2px solid #666; 253 | 254 | font-size: 10px; 255 | height: 18px; 256 | cursor: default; 257 | 258 | &.icon { 259 | cursor: default; 260 | } 261 | &.big { 262 | font-size: 12px; 263 | } 264 | } 265 | } 266 | 267 | #meow { 268 | z-index: @zindexControls; 269 | position: fixed; 270 | bottom: 45px; 271 | right: 20px; 272 | font-size: 12px; 273 | .alert { 274 | padding: 5px 10px; 275 | .close { 276 | display: none; 277 | } 278 | } 279 | } 280 | 281 | #share { 282 | .share-twitter, .share-facebook { 283 | display: inline-block; 284 | margin: 0 10px 10px 0; 285 | color: white; 286 | opacity: 0.8; 287 | } 288 | .share-twitter { background: rgb(1, 154, 210); } 289 | .share-facebook { background: rgb(52, 96, 157); } 290 | .share-facebook:hover, .share-twitter:hover { 291 | opacity: 1.0; 292 | } 293 | #share-url { background-color: @grayLighter; width: 80%; } 294 | } 295 | 296 | 297 | -------------------------------------------------------------------------------- /public/js/user.js: -------------------------------------------------------------------------------- 1 | Model.User = Backbone.Model.extend({ 2 | urlRoot: '/user', 3 | 4 | url: function() { 5 | var url = this.urlRoot 6 | if (!this.isNew()) { 7 | url += '/' + this.get('username') 8 | } 9 | return url 10 | }, 11 | 12 | isCurrentUser: function() { 13 | return Session.user && Session.user.id == this.id 14 | }, 15 | 16 | initialize: function() { 17 | this.view = new View.User({ model: this }) 18 | this.viewEdit = new View.UserEdit({ model: this }) 19 | } 20 | }) 21 | 22 | 23 | View.User = Backbone.View.extend({ 24 | template: jade.compile($('#user-show-template').text()), 25 | 26 | render: function() { 27 | this.$el.html(this.template({ 28 | user: this.model.toJSON(), 29 | currentUser: Session.userJSON() 30 | })) 31 | return this 32 | } 33 | }) 34 | 35 | View.UserEdit = View.Form.extend({ 36 | template: jade.compile($('#user-edit-template').text()), 37 | 38 | elAlert: 'form', 39 | 40 | events: { 41 | 'click button.btn-primary' : 'submit', 42 | 'keypress input' : 'keyDown' 43 | }, 44 | 45 | initialize: function() { 46 | _.bindAll(this, 'submit', 'keyDown', 'submitSuccess', 'submitError') 47 | }, 48 | 49 | render: function() { 50 | if (this.model.isCurrentUser()) { 51 | this.$el.html(this.template({ 52 | user: this.model.toJSON(), 53 | currentUser: Session.userJSON() 54 | })) 55 | } else { 56 | _.defer(function() { 57 | MainView.render('404') 58 | }) 59 | } 60 | 61 | return this 62 | }, 63 | 64 | submitSuccess: function(model, response) { 65 | this.removeErrors() 66 | new View.Alert({ 67 | className: 'alert-success alert fade', 68 | message: 'Your changes have been saved.', 69 | $prepend: this.$el.find('form') 70 | }) 71 | } 72 | }) 73 | 74 | View.UserReset = View.Form.extend({ 75 | className: 'reset modal', 76 | template: jade.compile($('#user-reset-template').text()), 77 | 78 | elAlert: 'form', 79 | elFocus: '#reset-password', 80 | 81 | events: { 82 | 'click button.btn-primary' : 'submit', 83 | 'keypress input' : 'keyDown' 84 | }, 85 | 86 | initialize: function() { 87 | _.bindAll(this, 'submit', 'keyDown', 'checkError', 'submitError', 'submitSuccess') 88 | }, 89 | 90 | render: function() { 91 | var self = this 92 | this.$el.modal({ 93 | backdrop: 'static', 94 | keyboard: false 95 | }) 96 | this.$el.html(this.template()) 97 | this.delegateEvents() 98 | _.delay(function() { 99 | self.focusInput() 100 | }, 500) 101 | return this 102 | }, 103 | 104 | validate: function() { 105 | if (this.$el.find('input[name="password"]').val() != this.$el.find('input[name="password-confirm"]').val()) { 106 | this.removeErrors() 107 | new View.Alert({ 108 | className: 'alert-danger alert fade', 109 | message: parseError(null, 'reset_password_unconfirmed'), 110 | $prepend: this.$el.find('form') 111 | }) 112 | this.focusInput() 113 | return false 114 | } 115 | return true 116 | }, 117 | 118 | submit: function() { 119 | var self = this 120 | if (!this.validate()) { 121 | return false 122 | } 123 | $.ajax({ 124 | type: 'POST', 125 | url: '/user/' + this.model.get('username') + '/reset', 126 | data: { token: this.model.get('token'), password: this.$el.find('input[name="password"]').val() }, 127 | success: this.submitSuccess, 128 | error: function(error, model) { self.submitError(model, error) } 129 | }) 130 | return false 131 | }, 132 | 133 | submitSuccess: function(model, response) { 134 | this.removeErrors() 135 | this.$el.modal('hide') 136 | new View.Alert({ 137 | className: 'alert-success alert fade', 138 | message: 'Your password has been reset!', 139 | $prepend: $('#container .span10') 140 | }) 141 | }, 142 | 143 | check: function() { 144 | $.ajax({ 145 | type: 'GET', 146 | url: '/user/' + this.model.get('username') + '/reset?token=' + this.model.get('token'), 147 | error: this.checkError 148 | }) 149 | }, 150 | 151 | checkError: function(model, error) { 152 | var errorJSON = {} 153 | , $alert 154 | 155 | try { 156 | errorJSON = JSON.parse(model.responseText) 157 | } catch(e) {} 158 | 159 | this.$el.modal('hide') 160 | new View.Alert({ 161 | className: 'alert-danger alert fade', 162 | message: parseError(null, (errorJSON.errors && errorJSON.errors.$) || 'no_connection'), 163 | $prepend: $('#container .span10') 164 | }) 165 | } 166 | }) 167 | 168 | View.UserForgot = View.Form.extend({ 169 | template: jade.compile($('#user-forgot-template').text()), 170 | 171 | elAlert: 'form', 172 | elFocus: '#forgot-login', 173 | 174 | events: { 175 | 'click button.btn-primary' : 'submit', 176 | 'keypress input' : 'keyDown' 177 | }, 178 | 179 | initialize: function() { 180 | _.bindAll(this, 'submit', 'keyDown', 'submitError', 'submitSuccess') 181 | }, 182 | 183 | render: function() { 184 | this.$el.html(this.template()) 185 | ModalView.render(this.$el) 186 | this.delegateEvents() 187 | return this 188 | }, 189 | 190 | submit: function() { 191 | var self = this 192 | $.ajax({ 193 | type: 'POST', 194 | url: '/user/forgot', 195 | data: { login: this.$el.find('input[name="login"]').val() }, 196 | success: this.submitSuccess, 197 | error: function(error, model) { self.submitError(model, error) } 198 | }) 199 | return false 200 | }, 201 | 202 | submitSuccess: function(model, response) { 203 | this.removeErrors() 204 | ModalView.hide() 205 | new View.Alert({ 206 | className: 'alert-success alert fade', 207 | message: 'You should receive an email with a link to log in and reset your password shortly.', 208 | $prepend: $('#container .span10') 209 | }) 210 | } 211 | }) 212 | 213 | View.UserCreate = View.Form.extend({ 214 | template: jade.compile($('#user-new-template').text()), 215 | 216 | elAlert: '.modal-body', 217 | 218 | events: { 219 | 'click button.btn-primary' : 'submit', 220 | 'keypress input' : 'keyDown', 221 | 'click a.sign-in' : 'newSession' 222 | }, 223 | 224 | initialize: function() { 225 | _.bindAll(this, 'submit', 'keyDown', 'submitSuccess', 'submitError', 'newSession') 226 | this.model = new Model.User 227 | }, 228 | 229 | newSession: function() { 230 | window.loginModal.render() 231 | return false 232 | }, 233 | 234 | render: function() { 235 | this.$el.html(this.template()) 236 | ModalView.render(this.$el) 237 | this.delegateEvents() 238 | return this 239 | }, 240 | 241 | submitSuccess: function(user, response) { 242 | Session.login(response) 243 | ModalView.hide() 244 | } 245 | }) 246 | 247 | 248 | ; --------------------------------------------------------------------------------