├── .eslintrc ├── thumb.jpg ├── public ├── favicon.ico └── fonts │ ├── acme.ttf │ ├── acme.woff2 │ ├── fontello.ttf │ ├── fontello.woff │ └── fontello.woff2 ├── postcss.config.js ├── .babelrc ├── client ├── scss │ ├── _globals.scss │ ├── _dropdown.scss │ ├── _reset.scss │ ├── _type.scss │ ├── _tooltip.scss │ ├── _variables.scss │ ├── _common.scss │ ├── _flexbox.scss │ ├── _prompt.scss │ ├── _mixins.scss │ ├── _forms.scss │ ├── _fontello.scss │ └── _modifiers.scss ├── components │ ├── ItemThumb.vue │ ├── LogoBar.vue │ ├── Welcome.vue │ ├── Spinner.vue │ ├── Modal.vue │ ├── ItemPlayer.vue │ ├── AppLogin.vue │ ├── Notify.vue │ ├── DropMenu.vue │ ├── ItemInfo.vue │ ├── ItemsRows.vue │ ├── ItemsGrid.vue │ ├── Options.vue │ ├── App.vue │ ├── Tabs.vue │ ├── UploadForm.vue │ ├── MovieForm.vue │ └── SideBar.vue ├── main.js └── scripts │ ├── Scroller.js │ ├── Viewport.js │ ├── Prompt.js │ ├── Polyfills.js │ └── Tooltip.js ├── .gitignore ├── server ├── modules │ ├── success.js │ ├── scanner.js │ ├── moviedb.js │ ├── users.js │ ├── stat.js │ └── drives.js ├── routes │ ├── app.js │ ├── devices.js │ ├── listings.js │ ├── moviedb.js │ ├── auth.js │ ├── maintenance.js │ └── transfers.js ├── views │ └── template.html ├── server.js └── user.js ├── common ├── api.js ├── config.example.js └── utils.js ├── package.json ├── README.md └── webpack.config.js /.eslintrc: -------------------------------------------------------------------------------- 1 | { 2 | "plugins": [ "html" ] 3 | } 4 | -------------------------------------------------------------------------------- /thumb.jpg: -------------------------------------------------------------------------------- https://raw.githubusercontent.com/rainner/file-browser/HEAD/thumb.jpg -------------------------------------------------------------------------------- /public/favicon.ico: -------------------------------------------------------------------------------- https://raw.githubusercontent.com/rainner/file-browser/HEAD/public/favicon.ico -------------------------------------------------------------------------------- /public/fonts/acme.ttf: -------------------------------------------------------------------------------- https://raw.githubusercontent.com/rainner/file-browser/HEAD/public/fonts/acme.ttf -------------------------------------------------------------------------------- /public/fonts/acme.woff2: -------------------------------------------------------------------------------- https://raw.githubusercontent.com/rainner/file-browser/HEAD/public/fonts/acme.woff2 -------------------------------------------------------------------------------- /public/fonts/fontello.ttf: -------------------------------------------------------------------------------- https://raw.githubusercontent.com/rainner/file-browser/HEAD/public/fonts/fontello.ttf -------------------------------------------------------------------------------- /public/fonts/fontello.woff: -------------------------------------------------------------------------------- https://raw.githubusercontent.com/rainner/file-browser/HEAD/public/fonts/fontello.woff -------------------------------------------------------------------------------- /postcss.config.js: -------------------------------------------------------------------------------- 1 | module.exports = { 2 | plugins: [ 3 | require( "autoprefixer" ) 4 | ] 5 | } 6 | -------------------------------------------------------------------------------- /public/fonts/fontello.woff2: -------------------------------------------------------------------------------- https://raw.githubusercontent.com/rainner/file-browser/HEAD/public/fonts/fontello.woff2 -------------------------------------------------------------------------------- /.babelrc: -------------------------------------------------------------------------------- 1 | { 2 | "presets": [ 3 | ["es2015", { "modules": false }], 4 | ["env", { "modules": false }] 5 | ] 6 | } 7 | -------------------------------------------------------------------------------- /client/scss/_globals.scss: -------------------------------------------------------------------------------- 1 | /** 2 | * Global vars and mixings for all components 3 | */ 4 | @import "./variables"; 5 | @import "./mixins"; 6 | -------------------------------------------------------------------------------- /.gitignore: -------------------------------------------------------------------------------- 1 | .DS_Store 2 | tmp/ 3 | build/ 4 | node_modules/ 5 | server/storage/ 6 | public/dist/ 7 | common/config.js 8 | package-lock.json 9 | npm-debug.log 10 | yarn-error.log 11 | -------------------------------------------------------------------------------- /server/modules/success.js: -------------------------------------------------------------------------------- 1 | /** 2 | * Wrapper for successful responses 3 | */ 4 | module.exports = ( code, message, data ) => { 5 | return { 6 | statusCode: code || 200, 7 | message: message || 'Success', 8 | data: data, 9 | }; 10 | }; 11 | -------------------------------------------------------------------------------- /server/routes/app.js: -------------------------------------------------------------------------------- 1 | /** 2 | * App static routes 3 | */ 4 | const routes = []; 5 | 6 | /** 7 | * Main app route 8 | * @return {html}: app template and bundles 9 | */ 10 | routes.push({ 11 | method: 'GET', 12 | path: '/', 13 | config: { 14 | auth: false, 15 | handler: ( request, reply ) => { 16 | return reply.file( 'template.html' ); 17 | } 18 | } 19 | }); 20 | 21 | // export routes 22 | module.exports = routes; 23 | -------------------------------------------------------------------------------- /server/views/template.html: -------------------------------------------------------------------------------- 1 | 2 | 3 | 4 | FileBrowser 5 | 6 | 7 | 8 | 9 | 10 | 11 | 12 | 13 |
14 | 15 | 16 | 17 | -------------------------------------------------------------------------------- /server/routes/devices.js: -------------------------------------------------------------------------------- 1 | /** 2 | * Get devices data from OS 3 | */ 4 | const drives = require( '../modules/drives' ); 5 | const Success = require( '../modules/success' ); 6 | const Boom = require( 'boom' ); 7 | const routes = []; 8 | 9 | /** 10 | * List OS local storage devices (drives) 11 | * @return {object}: HTTP response object with data list 12 | */ 13 | routes.push({ 14 | method: 'GET', 15 | path: '/devices', 16 | config: { 17 | handler: ( request, reply ) => { 18 | 19 | return drives.getDrives( drives => { 20 | if ( !drives.length ) { 21 | return reply( Boom.notFound( 'Could not read list of devices from the system.' ) ); 22 | } 23 | return reply( Success( 200, 'Success', drives ) ); 24 | }); 25 | } 26 | } 27 | }); 28 | 29 | // export routes 30 | module.exports = routes; 31 | -------------------------------------------------------------------------------- /common/api.js: -------------------------------------------------------------------------------- 1 | /** 2 | * Backend API endpoints 3 | */ 4 | const base = ''; 5 | 6 | export default { 7 | 8 | // user auth routes 9 | user: base + '/user', 10 | login: base + '/login', 11 | logout: base + '/logout', 12 | 13 | // devices and folder listings 14 | devices: base + '/devices', 15 | listing: base + '/listing', 16 | 17 | // folder/file process 18 | info: base + '/info', 19 | create: base + '/create', 20 | move: base + '/move', 21 | copy: base + '/copy', 22 | delete: base + '/delete', 23 | batch: base + '/batch', 24 | 25 | // file transfers 26 | upload: base + '/upload', 27 | open: base + '/open', 28 | download: base + '/download', 29 | thumbs: base + '/thumbs', 30 | 31 | // movie API data 32 | movieSearch: base + '/movie-search', 33 | movieInfo: base + '/movie-info', 34 | 35 | // tv API data 36 | tvSearch: base + '/tv-search', 37 | tvSeason: base + '/tv-season', 38 | tvEpisode: base + '/tv-episode', 39 | 40 | // maintenance 41 | cleanNames: base + '/clean-names', 42 | cleanThumbs: base + '/clean-thumbs', 43 | cleanJunk: base + '/clean-junk', 44 | } 45 | -------------------------------------------------------------------------------- /client/components/ItemThumb.vue: -------------------------------------------------------------------------------- 1 | 7 | 8 | 22 | 23 | 48 | -------------------------------------------------------------------------------- /server/modules/scanner.js: -------------------------------------------------------------------------------- 1 | /** 2 | * Scans a directory tree for files or folders 3 | */ 4 | const fs = require( 'fs' ); 5 | const path = require( 'path' ); 6 | 7 | class Scanner { 8 | 9 | // recursive scan all files within a path 10 | scanFiles( dir, callback ) { 11 | fs.readdirSync( dir ).forEach( ( name ) => { 12 | let fpath = path.join( dir, name ); 13 | let stats = fs.statSync( fpath ); 14 | 15 | if ( stats.isDirectory() ) { 16 | this.scanFiles( fpath, callback ); 17 | } else if ( stats.isFile() ) { 18 | callback( fpath, stats ); 19 | } 20 | }); 21 | } 22 | 23 | // recursive scan for empty folders within a path 24 | scanEmptyFolders( dir, callback ) { 25 | fs.readdirSync( dir ).forEach( ( name ) => { 26 | let fpath = path.join( dir, name ); 27 | let stats = fs.statSync( fpath ); 28 | 29 | if ( stats.isDirectory() ) { 30 | if ( fs.readdirSync( fpath ).length ) { 31 | this.scanEmptyFolders( fpath, callback ); 32 | } else { 33 | callback( fpath, stats ); 34 | } 35 | } 36 | }); 37 | } 38 | 39 | }; 40 | 41 | module.exports = new Scanner(); 42 | -------------------------------------------------------------------------------- /client/components/LogoBar.vue: -------------------------------------------------------------------------------- 1 | 11 | 12 | 26 | 27 | 51 | 52 | -------------------------------------------------------------------------------- /client/main.js: -------------------------------------------------------------------------------- 1 | /** 2 | * App entry file 3 | */ 4 | import './scripts/Polyfills'; 5 | import Vue from 'vue'; 6 | import App from './components/App.vue'; 7 | import Tooltip from './scripts/Tooltip'; 8 | 9 | const EventBus = new Vue(); 10 | const tooltip = new Tooltip(); 11 | 12 | // create global vue $bus property 13 | Object.defineProperties( Vue.prototype, { 14 | $bus: { get: function() { return EventBus; } } 15 | }); 16 | 17 | // single tooltip instance for entire app 18 | Vue.directive( 'tooltip', { 19 | bind: el => { tooltip.select( el ); }, 20 | unbind: el => { tooltip.unselect( el ); }, 21 | }); 22 | 23 | // custom directive for detecting if a click came from outside an element 24 | Vue.directive( 'clickout', { 25 | bind: ( el, binding, vnode ) => { 26 | el.event = ( event ) => { 27 | if ( !( el == event.target || el.contains( event.target ) ) ) { 28 | vnode.context[ binding.expression ]( event ); 29 | } 30 | }; 31 | document.body.addEventListener( 'click', el.event ); 32 | }, 33 | unbind: ( el ) => { 34 | document.body.removeEventListener( 'click', el.event ); 35 | }, 36 | }); 37 | 38 | // render app 39 | new Vue({ 40 | el: '#app', 41 | render: h => h( App ) 42 | }); 43 | -------------------------------------------------------------------------------- /package.json: -------------------------------------------------------------------------------- 1 | { 2 | "name": "file-browser", 3 | "description": "File browser app/server application for browsing contents of system attached storage devices.", 4 | "version": "1.0.0", 5 | "author": "Rainner Lins ", 6 | "scripts": { 7 | "dev": "cross-env NODE_ENV=development webpack-dev-server --inline --watch --hot", 8 | "build": "cross-env NODE_ENV=production webpack --progress --hide-modules", 9 | "user": "node ./server/user.js", 10 | "node": "node ./server/server.js", 11 | "nodemon": "nodemon ./server/server.js", 12 | "pm2": "pm2 start ./server/server.js" 13 | }, 14 | "dependencies": { 15 | "bcrypt": "^1.0.3", 16 | "fs-extra": "^4.0.3", 17 | "hapi": "^16.6.3", 18 | "hapi-auth-cookie": "^7.0.0", 19 | "inert": "^4.2.1", 20 | "leveldown": "^2.1.1", 21 | "levelup": "^2.0.2", 22 | "md5": "^2.2.1", 23 | "mime": "^2.3.1", 24 | "sharp": "github:lovell/sharp", 25 | "vue": "^2.5.16" 26 | }, 27 | "devDependencies": { 28 | "babel-core": "^6.0.0", 29 | "babel-loader": "^6.0.0", 30 | "babel-preset-env": "^1.5.1", 31 | "babel-preset-es2015": "^6.24.1", 32 | "cross-env": "^3.0.0", 33 | "css-loader": "^0.25.0", 34 | "extract-text-webpack-plugin": "^2.1.2", 35 | "file-loader": "^0.9.0", 36 | "ignore-loader": "^0.1.2", 37 | "node-sass": "^4.8.3", 38 | "postcss-loader": "^2.1.3", 39 | "sass-loader": "^5.0.1", 40 | "vue-loader": "^12.1.0", 41 | "vue-template-compiler": "^2.5.16", 42 | "webpack": "^2.6.1", 43 | "webpack-dev-server": "^2.11.2" 44 | } 45 | } 46 | -------------------------------------------------------------------------------- /client/scss/_dropdown.scss: -------------------------------------------------------------------------------- 1 | // dropdown show animation 2 | @keyframes dropShow { 3 | 0% { transform: translateY( 20px ); opacity: 0; } 4 | 100% { transform: translateY( 0 ); opacity: 1; } 5 | } 6 | 7 | // dropdown menu 8 | .dropdown-menu { 9 | display: block; 10 | overflow: visible; 11 | position: relative; 12 | 13 | .dropdown-trigger { 14 | cursor: pointer; 15 | } 16 | 17 | & > .dropdown-list { 18 | display: none; 19 | position: absolute; 20 | list-style: none; 21 | margin: 0; 22 | padding: ( $padSpace / 2 ) 0; 23 | right: 0; 24 | top: 50%; 25 | bottom: auto; 26 | max-width: 300px; 27 | @include borderAccent; 28 | background-color: lighten( $colorDocument, 10% ); 29 | border-radius: $lineJoin; 30 | box-shadow: $shadowBold; 31 | animation: dropShow $fxSpeed $fxEaseBounce forwards; 32 | z-index: 999; 33 | 34 | & > .dropdown-list-bottom { 35 | top: auto; 36 | bottom: 50%; 37 | } 38 | 39 | & > .dropdown-item { 40 | display: block; 41 | @include textNoWrap; 42 | margin: 0; 43 | padding: ( $padSpace / 2 ) $padSpace; 44 | background-color: lighten( $colorDocument, 10% ); 45 | color: $colorSecondary; 46 | 47 | &:hover { 48 | background-color: lighten( $colorDocument, 6% ); 49 | } 50 | } 51 | 52 | & > .dropdown-item + .dropdown-item { 53 | border-top: $lineWidth $lineStyle lighten( $colorDocument, 6% ); 54 | } 55 | } 56 | 57 | &:hover > .dropdown-list, 58 | &:focus > .dropdown-list, 59 | &:active > .dropdown-list { 60 | display: block; 61 | } 62 | } 63 | -------------------------------------------------------------------------------- /server/routes/listings.js: -------------------------------------------------------------------------------- 1 | /** 2 | * Folder listings 3 | */ 4 | const fs = require( 'fs-extra' ); 5 | const levelup = require( 'levelup' ); 6 | const leveldown = require( 'leveldown' ); 7 | const Boom = require( 'boom' ); 8 | const Success = require( '../modules/success' ); 9 | const Stat = require( '../modules/stat' ); 10 | const config = require( '../../common/config' ); 11 | const utils = require( '../../common/utils' ); 12 | 13 | /** 14 | * List items from a folder path. 15 | * @string {path}: Full path to a folder on a device to be listed 16 | * @return {object}: HTTP response object with data list 17 | */ 18 | module.exports = { 19 | method: 'POST', 20 | path: '/listing', 21 | config: { 22 | handler: ( request, reply ) => { 23 | let p = request.payload || {}; 24 | 25 | if ( !p.path ) { 26 | return reply( Boom.badRequest( 'Must provide a folder path.' ) ); 27 | } 28 | let target = utils.fixPath( p.path, '/' ); 29 | let store = levelup( leveldown( config.storage.thumbs ) ); 30 | let folders = []; 31 | let files = []; 32 | let plist = []; 33 | 34 | fs.readdir( target, {}, ( err, list ) => { 35 | if ( err ) return reply( Boom.badImplementation( err ) ); 36 | 37 | for ( let item of list ) { 38 | plist.push( Stat( target + item, store ) ); 39 | } 40 | Promise.all( plist ).then( items => { 41 | for ( let i = 0; i < items.length; ++i ) { 42 | if ( !items[ i ] ) continue; 43 | if ( items[ i ].type === 'folder' ) { folders.push( items[ i ] ); } 44 | else { files.push( items[ i ] ); } 45 | } 46 | store.close(); 47 | return reply( Success( 200, 'Success', [].concat( folders, files ) ) ); 48 | }); 49 | }); 50 | } 51 | } 52 | }; 53 | -------------------------------------------------------------------------------- /client/scss/_reset.scss: -------------------------------------------------------------------------------- 1 | // main font 2 | @font-face { 3 | font-family: 'Acme'; 4 | font-style: normal; 5 | font-weight: 400; 6 | src: local( 'Acme Regular' ), 7 | local( 'Acme-Regular' ), 8 | url( '/fonts/acme.woff2' ) format( 'woff2' ), 9 | url( '/fonts/acme.ttf' ) format( 'truetype' ); 10 | } 11 | 12 | // global resets 13 | *, *:before, *:after { 14 | margin: 0; 15 | padding: 0; 16 | border: 0; 17 | outline: none; 18 | background-color: transparent; 19 | text-transform: none; 20 | text-shadow: none; 21 | box-shadow: none; 22 | box-sizing: border-box; 23 | -moz-box-shadow: none; 24 | -webkit-box-shadow: none; 25 | -webkit-appearance: none; 26 | -webkit-tap-highlight-color: rgba( 0, 0, 0, 0 ); 27 | -webkit-font-smoothing: antialiased; 28 | -moz-osx-font-smoothing: grayscale; 29 | backface-visibility: hidden; 30 | touch-action: manipulation; 31 | transform-style: flat; 32 | transition: 33 | color $fxSpeed $fxEase, 34 | border-color $fxSpeed $fxEase, 35 | background-color $fxSpeed $fxEase, 36 | opacity $fxSpeed $fxEase, 37 | transform $fxSpeed $fxEase; 38 | } 39 | 40 | // remove ouline fro active elements 41 | *:active, *:hover, *:focus { 42 | outline: none !important; 43 | } 44 | 45 | // Block types 46 | article, aside, details, figcaption, figure, footer, header, hgroup, 47 | menu, nav, section, main, summary, div, h1, h2, h3, h4, h5, h6, hr, 48 | p, ol, ul, form, img { 49 | display: block; 50 | } 51 | 52 | // Document 53 | html, body { 54 | display: block; 55 | position: relative; 56 | overflow: hidden; 57 | min-height: 100vh; 58 | } 59 | body { 60 | font-family: 'Acme', 'Helvetica', 'Arial', sans-serif; 61 | font-size: calc( #{$fontSize} - 2px ); 62 | font-weight: normal; 63 | line-height: 1.5em; 64 | background-color: $colorDocument; 65 | color: $colorDocumentText; 66 | 67 | @media #{$screenMedium} { 68 | font-size: $fontSize; 69 | } 70 | } 71 | 72 | -------------------------------------------------------------------------------- /client/components/Welcome.vue: -------------------------------------------------------------------------------- 1 | 37 | 38 | 53 | 54 | 78 | -------------------------------------------------------------------------------- /common/config.example.js: -------------------------------------------------------------------------------- 1 | /** 2 | * Application config options. 3 | * IMPORTANT: rename this file to 'config.js' before app use. 4 | */ 5 | const path = require( 'path' ); 6 | 7 | module.exports = { 8 | 9 | // ------------------------- 10 | // webpack dev server info 11 | devServer: { 12 | host: 'localhost', 13 | port: 8000, 14 | public: path.join( __dirname, '../public/' ), 15 | fallback: path.join( __dirname, '../server/views/template.html' ), 16 | }, 17 | 18 | // ------------------------- 19 | // main app server info 20 | mainServer: { 21 | host: 'localhost', 22 | port: 3000, 23 | public: path.join( __dirname, '../public/' ), 24 | routes: path.join( __dirname, '../server/routes/' ), 25 | views: path.join( __dirname, '../server/views/' ), 26 | }, 27 | 28 | // ------------------------- 29 | // secret keys 30 | keys: { 31 | 32 | // key used for crypt-ops on secure data 33 | crypt: '--some-random-32-chars-or-longer-hash--', 34 | 35 | // key used for fetching TV/Movie data from TheMovieDB 36 | moviedb: '--your-themoviedb-api-key--', 37 | }, 38 | 39 | // ------------------------- 40 | // server auth session options 41 | session: { 42 | 43 | // cookie name 44 | name: '_appsid_', 45 | 46 | // session duration (ttl) 47 | duration: 1000 * 60 * 60 * 24, 48 | }, 49 | 50 | // ------------------------- 51 | // server data/cache storage paths 52 | storage: { 53 | 54 | // listed devices will show a warning color when percentage hits this value 55 | dangerSize: 80, 56 | 57 | // path used to store image thumbnails 58 | thumbs: path.join( __dirname, '../server/storage/thumbs' ), 59 | 60 | // path used to store user authentication data 61 | users: path.join( __dirname, '../server/storage/users' ), 62 | }, 63 | 64 | // ------------------------- 65 | // thumbnail creation options 66 | thumbs: { 67 | 68 | // type of file extensions to process 69 | types: /^(jpe?g|png|gif|webp|svg)$/, 70 | 71 | // thumbnails no bigger than: 72 | maxWidth: 200, 73 | maxHeight: 100, 74 | 75 | // passed to sharp's jpeg() function 76 | jpeg: { 77 | quality: 60, 78 | chromaSubsampling: '4:4:4', 79 | }, 80 | }, 81 | 82 | } 83 | 84 | -------------------------------------------------------------------------------- /client/scss/_type.scss: -------------------------------------------------------------------------------- 1 | // links 2 | a { 3 | text-decoration: none; 4 | color: $colorPrimary; 5 | 6 | &:hover { 7 | color: darken( $colorPrimary, 10% ); 8 | } 9 | } 10 | 11 | // paragraphs 12 | p { 13 | margin-bottom: $padSpace; 14 | } 15 | * > p:last-of-type { 16 | margin-bottom: 0; 17 | } 18 | p > a { 19 | text-decoration: underline; 20 | } 21 | // badge text containers 22 | .text-badge { 23 | display: inline-block; 24 | padding: 0 .5em; 25 | background-color: rgba( 0, 0, 0, 0.1 ); 26 | border-radius: 100px; 27 | } 28 | 29 | // pill style wrappers for text elements 30 | .text-pill { 31 | display: inline-block; 32 | font-size: 80%; 33 | line-height: 1.2em; 34 | padding: .2em .6em; 35 | letter-spacing: -0.5px; 36 | text-transform: uppercase; 37 | background: $colorBright linear-gradient( 180deg, transparent, rgba(0,0,0,0.2) ); 38 | color: $colorBrightText; 39 | border-radius: 100px; 40 | box-shadow: $shadowPaper; 41 | } 42 | 43 | // text display styles 44 | .text-reset { 45 | font-family: $fontFamily; 46 | font-size: $fontSize; 47 | font-style: normal; 48 | font-weight: normal; 49 | line-height: 1.5em; 50 | text-transform: none; 51 | } 52 | .text-left { 53 | text-align: left; 54 | } 55 | .text-right { 56 | text-align: right; 57 | } 58 | .text-center { 59 | text-align: center; 60 | } 61 | .text-justify { 62 | text-align: justify; 63 | } 64 | .text-top { 65 | vertical-align: top; 66 | } 67 | .text-middle { 68 | vertical-align: middle; 69 | } 70 | .text-bottom { 71 | vertical-align: bottom; 72 | } 73 | .text-baseline { 74 | vertical-align: baseline; 75 | } 76 | .text-uppercase { 77 | text-transform: uppercase; 78 | } 79 | .text-capitalize { 80 | text-transform: capitalize; 81 | } 82 | .text-italic { 83 | font-style: italic; 84 | } 85 | .text-bold { 86 | font-weight: bold; 87 | } 88 | .text-nowrap { 89 | @include textNoWrap; 90 | } 91 | .text-clip { 92 | @include textClip; 93 | } 94 | .text-noselect { 95 | @include textNoSelect; 96 | } 97 | .text-flip { 98 | text-align: left; 99 | direction: rtl; 100 | } 101 | .text-faded { 102 | opacity: 0.5; 103 | } 104 | .text-bigger { 105 | font-size: 140%; 106 | } 107 | .text-smaller { 108 | font-size: 80%; 109 | } 110 | .text-collapse { 111 | line-height: 1em; 112 | } 113 | 114 | -------------------------------------------------------------------------------- /server/modules/moviedb.js: -------------------------------------------------------------------------------- 1 | /** 2 | * Class for talking with The Movie DB API. 3 | * https://developers.themoviedb.org/3/getting-started 4 | */ 5 | const https = require( 'https' ); 6 | 7 | // class export 8 | module.exports = class MovieDB { 9 | 10 | // se the local API data 11 | constructor( apiKey ) { 12 | this._apiurl = 'https://api.themoviedb.org/3'; 13 | this._apikey = apiKey; 14 | } 15 | 16 | // search for a movie by name 17 | searchMovie( name, callback ) { 18 | let url = this._getUrl( '/search/movie', { query: name } ); 19 | this._fetchData( url, callback ); 20 | } 21 | 22 | // search for TV show by name 23 | searchShow( name, callback ) { 24 | let url = this._getUrl( '/search/tv', { query: name } ); 25 | this._fetchData( url, callback ); 26 | } 27 | 28 | // fetch data for a movie by id number 29 | fetchMovieInfo( id, callback ) { 30 | let url = this._getUrl( '/movie/'+ id, {} ); 31 | this._fetchData( url, callback ); 32 | } 33 | 34 | // fetch list of episodes for a tv show season by id number 35 | fetchShowSeason( id, season, callback ) { 36 | let url = this._getUrl( '/tv/'+ id +'/season/'+ season, {} ); 37 | this._fetchData( url, callback ); 38 | } 39 | 40 | // fetch data for a single tv show episode by id number 41 | fetchShowEpisode( id, season, episode, callback ) { 42 | let url = this._getUrl( '/tv/'+ id +'/season/'+ season +'/episode/'+ episode, {} ); 43 | this._fetchData( url, callback ); 44 | } 45 | 46 | // build api url 47 | _getUrl( route, query ) { 48 | route = String( route || '' ); 49 | query = Object.assign( {}, query, { api_key: this._apikey } ); 50 | let pairs = []; 51 | 52 | for ( let key in query ) { 53 | if ( query.hasOwnProperty( key ) ) { 54 | pairs.push( key + '=' + encodeURIComponent( query[ key ] ) ); 55 | } 56 | } 57 | return this._apiurl + route + '?' + pairs.join( '&' ); 58 | } 59 | 60 | // fetch data from api 61 | _fetchData( url, callback ) { 62 | https.get( url, res => { 63 | let body = ''; 64 | let data = null; 65 | 66 | res.setEncoding( 'utf8' ); 67 | res.on( 'data', ( data ) => { body += data; } ); 68 | res.on( 'end', () => { 69 | 70 | try { data = JSON.parse( body ); } 71 | catch ( e ) { return callback( 'Error parsing the API response body.' ); } 72 | 73 | if ( data.status_message ) { 74 | return callback( data.status_message ); 75 | } 76 | callback( null, data ); 77 | }); 78 | }); 79 | 80 | } 81 | } 82 | -------------------------------------------------------------------------------- /client/components/Spinner.vue: -------------------------------------------------------------------------------- 1 | 6 | 7 | 40 | 41 | 111 | -------------------------------------------------------------------------------- /client/scss/_tooltip.scss: -------------------------------------------------------------------------------- 1 | /** 2 | * Tooltips 3 | */ 4 | $tipColor: lighten( $colorDocument, 20% ); 5 | 6 | @keyframes tooltipShowLeft { 7 | 0% { opacity: 0; transform: translateX( -20px ); } 8 | 100% { opacity: 1; transform: translateX( -10px ); } 9 | } 10 | @keyframes tooltipShowRight { 11 | 0% { opacity: 0; transform: translateX( 20px ); } 12 | 100% { opacity: 1; transform: translateX( 10px ); } 13 | } 14 | @keyframes tooltipShowTop { 15 | 0% { opacity: 0; transform: translateY( -20px ); } 16 | 100% { opacity: 1; transform: translateY( -10px ); } 17 | } 18 | @keyframes tooltipShowBottom { 19 | 0% { opacity: 0; transform: translateY( 20px ); } 20 | 100% { opacity: 1; transform: translateY( 10px ); } 21 | } 22 | .tooltip-wrap { 23 | display: block; 24 | position: absolute; 25 | text-align: center; 26 | white-space: nowrap; 27 | text-overflow: ellipsis; 28 | pointer-events: none; 29 | transition: none; 30 | border: none; 31 | border-radius: $lineJoin; 32 | max-width: 500px; 33 | margin: 0; 34 | padding: ( $padSpace / 2 ) $padSpace; 35 | font-size: 80%; 36 | line-height: 1.2em; 37 | color: $colorDocumentText; 38 | background-color: $tipColor; 39 | box-shadow: $shadowBold; 40 | left: 0; 41 | top: 0; 42 | z-index: 999999999; 43 | 44 | &.tooltip-left { animation: tooltipShowLeft $fxSpeed $fxEaseBounce forwards; } 45 | &.tooltip-right { animation: tooltipShowRight $fxSpeed $fxEaseBounce forwards; } 46 | &.tooltip-top { animation: tooltipShowTop $fxSpeed $fxEaseBounce forwards; } 47 | &.tooltip-bottom { animation: tooltipShowBottom $fxSpeed $fxEaseBounce forwards; } 48 | 49 | &:after { // arrow 50 | content: " "; 51 | position: absolute; 52 | height: 0; 53 | width: 0; 54 | pointer-events: none; 55 | transition: none; 56 | border: solid transparent; 57 | border-color: transparent; 58 | border-width: 5px; 59 | } 60 | 61 | &.tooltip-left:after { // arrow on right 62 | left: 100%; 63 | top: 50%; 64 | border-left-color: $tipColor; 65 | margin-top: -5px; 66 | } 67 | &.tooltip-right:after { // arrow on left 68 | right: 100%; 69 | top: 50%; 70 | border-right-color: $tipColor; 71 | margin-top: -5px; 72 | } 73 | &.tooltip-top:after { // arrow on bottom 74 | top: 100%; 75 | left: 50%; 76 | border-top-color: $tipColor; 77 | margin-left: -5px; 78 | } 79 | &.tooltip-bottom:after { // arrow on top 80 | bottom: 100%; 81 | left: 50%; 82 | border-bottom-color: $tipColor; 83 | margin-left: -5px; 84 | } 85 | } 86 | -------------------------------------------------------------------------------- /client/scss/_variables.scss: -------------------------------------------------------------------------------- 1 | // height of topbar 2 | $topbarHeight: 3.5em; 3 | 4 | // spacing and padding 5 | $padSpace: 1em; 6 | $colSpace: 1.0em; 7 | $rowSpace: 1.2em; 8 | $listSpace: 0.4em; 9 | 10 | // borders and lines 11 | $lineWidth: 2px; 12 | $lineStyle: solid; 13 | $lineColor: rgba( 0, 0, 0, 0.1 ); 14 | $lineJoin: 3px; 15 | 16 | // common z-index values 17 | $zindexUnder: -1; 18 | $zindexElements: 100; 19 | $zindexModals: 8888; 20 | $zindexAlerts: 9999; 21 | 22 | // base font options 23 | $fontSize: 18px; 24 | $fontFamily: Acme, Helvetica, Arial, sans-serif; 25 | $fontFixed: Consolas, "Andale Mono", Monaco, "Courier New", monospace; 26 | 27 | // document colors 28 | $colorDocument: #e0e0e0; 29 | $colorDocumentText: #302040; 30 | 31 | // input colors 32 | $colorInput: #f0f0f0; 33 | $colorInputText: #302040; 34 | 35 | // default colors 36 | $colorDefault: #afb5b7; 37 | $colorDefaultText: #3e4243; 38 | 39 | // primary colors 40 | $colorPrimary: #9b2646; 41 | $colorPrimaryText: #ffdede; 42 | 43 | // secondary colors 44 | $colorSecondary: #452666; 45 | $colorSecondaryText: #e4dce9; 46 | 47 | // success colors 48 | $colorSuccess: #4c7156; 49 | $colorSuccessText: #e5ecd5; 50 | 51 | // warning colors 52 | $colorWarning: #a09069; 53 | $colorWarningText: #f4ead0; 54 | 55 | // warning colors 56 | $colorDanger: #9a2638; 57 | $colorDangerText: #fae3e0; 58 | 59 | // warning colors 60 | $colorInfo: #29567b; 61 | $colorInfoText: #e0ebf4; 62 | 63 | // grey colors 64 | $colorGrey: #909a9d; 65 | $colorGreyText: #e5e4e4; 66 | 67 | // bright colors 68 | $colorBright: #ffffff; 69 | $colorBrightText: #434343; 70 | 71 | // base page colors 72 | $colorText: #404050; 73 | $colorBg: darken( $colorDocument, 10% ); 74 | $colorOverlay: rgba( 0, 0, 0, 0.6); 75 | 76 | // common shadow styles 77 | $shadowHollow: inset 0 1px 4px rgba( 0, 0, 0, 0.15 ); 78 | $shadowBubble: inset 0 -20px 20px rgba( 0, 0, 0, 0.1 ); 79 | $shadowPaper: 0 1px 2px rgba( 0, 0, 0, 0.2 ); 80 | $shadowDark: 0 1px 3px rgba( 0, 0, 0, 0.3 ); 81 | $shadowGlow: 0 0 10px rgba( 0, 0, 0, 0.2 ); 82 | $shadowBold: 0 2px 12px rgba( 0, 0, 0, 0.4 ); 83 | $shadowText: 1px 1px 0 rgba( 0, 0, 0, 0.3 ); 84 | 85 | // common speed for most small ui transition animations 86 | $fxSpeed: 300ms; 87 | $fxEase: cubic-bezier( 0.215, 0.610, 0.355, 1.000 ); 88 | $fxEaseBounce: cubic-bezier( 0.640, -0.280, 0.050, 1.405 ); 89 | 90 | // screen sizes 91 | $sizeSmall: 420px; 92 | $sizeMedium: 720px; 93 | $sizeLarge: 1200px; 94 | 95 | // screen breakpoints 96 | $screenSmall: "only screen and (min-width : #{$sizeSmall})"; 97 | $screenMedium: "only screen and (min-width : #{$sizeMedium})"; 98 | $screenLarge: "only screen and (min-width : #{$sizeLarge})"; 99 | -------------------------------------------------------------------------------- /server/modules/users.js: -------------------------------------------------------------------------------- 1 | /** 2 | * Manage user store data 3 | */ 4 | const levelup = require( 'levelup' ); 5 | const leveldown = require( 'leveldown' ); 6 | const config = require( '../../common/config' ); 7 | const store = levelup( leveldown( config.storage.users ) ); 8 | 9 | module.exports = { 10 | 11 | // list all used in store 12 | list( callback ) { 13 | let rows = []; 14 | return store.createReadStream() 15 | .on( 'data', data => { rows.push( JSON.parse( data.value.toString() ) ); } ) 16 | .on( 'error', err => { callback( err, rows ); } ) 17 | .on( 'end', () => { callback( null, rows ); } ); 18 | }, 19 | 20 | // fetch user entry by id 21 | fetchById( id, callback ) { 22 | return store.get( id, ( err, data ) => { 23 | if ( err || !data ) return callback( err ); 24 | callback( null, JSON.parse( data.toString() ) ); 25 | }); 26 | }, 27 | 28 | // fetch user entry by username 29 | fetchByName( username, callback ) { 30 | return this.list( ( err, rows ) => { 31 | if ( err ) return callback( err ); // error 32 | 33 | for ( let i = 0; i < rows.length; ++i ) { 34 | if ( rows[ i ].username === username ) { 35 | return callback( null, rows[ i ] ); // found 36 | } 37 | } 38 | return callback( null, null ); // not found 39 | }); 40 | }, 41 | 42 | // create new user entry 43 | create( id, data, callback ) { 44 | return store.put( id, JSON.stringify( data ), err => { 45 | if ( err ) return callback( err ); 46 | callback( null, data ); 47 | }); 48 | }, 49 | 50 | // update data for existing user entry 51 | update( id, newdata, callback ) { 52 | return store.get( id, ( err, data ) => { 53 | if ( err || !data ) return callback( err ); 54 | data = JSON.parse( data.toString() ); 55 | data = Object.assign( {}, data, newdata ); 56 | store.put( id, JSON.stringify( data ), err => { 57 | if ( err ) return callback( err ); 58 | callback( null, data ); 59 | }); 60 | }); 61 | }, 62 | 63 | // delete user entry by id 64 | delete( id, callback ) { 65 | return store.del( id, err => { 66 | if ( err ) return callback( err ); 67 | callback( null, true ); 68 | }); 69 | }, 70 | 71 | // flush all user data from store 72 | flush( callback ) { 73 | let plist = []; 74 | return store.createReadStream() 75 | .on( 'data', data => { plist.push( store.del( data.key ) ); } ) 76 | .on( 'error', err => { callback( err ); } ) 77 | .on( 'end', () => { 78 | Promise.all( plist ) 79 | .then( () => { callback( null, plist.length ); } ) 80 | .catch( err => { callback( err ); } ); 81 | }); 82 | }, 83 | 84 | }; 85 | -------------------------------------------------------------------------------- /client/scss/_common.scss: -------------------------------------------------------------------------------- 1 | // horizontal lines 2 | hr { 3 | display: block; 4 | margin: $padSpace 0; 5 | border: 0; 6 | border-top: 1px $lineStyle $lineColor; 7 | } 8 | 9 | // fade-in animation 10 | @keyframes fadeIn { 11 | 0% { opacity: 0; } 12 | 100% { opacity: 1; } 13 | } 14 | .fade-in { 15 | opacity: 0; 16 | animation: fadeIn $fxSpeed $fxEase forwards; 17 | } 18 | 19 | // fade-out animation 20 | @keyframes fadeOut { 21 | 0% { opacity: 1; } 22 | 100% { opacity: 0; } 23 | } 24 | .fade-out { 25 | opacity: 1; 26 | animation: fadeOut $fxSpeed $fxEase forwards; 27 | } 28 | 29 | // zoom-in animation (modal, alert, etc) 30 | @keyframes zoomIn { 31 | 0% { opacity: 0; transform: scale( 0.5 ); } 32 | 100% { opacity: 1; transform: scale( 1 ); } 33 | } 34 | .zoom-in { 35 | animation: zoomIn $fxSpeed $fxEase forwards; 36 | } 37 | 38 | // fade-out animation (modal, alert, etc) 39 | @keyframes zoomOut { 40 | 0% { opacity: 1; transform: scale( 1 ); } 41 | 100% { opacity: 0; transform: scale( 0.5 ); } 42 | } 43 | .zoom-out { 44 | animation: zoomOut $fxSpeed $fxEase forwards; 45 | } 46 | 47 | // cursor for clickable elements 48 | .clickable, [clickable] { 49 | cursor: pointer; 50 | } 51 | 52 | // not rendered 53 | .hidden, [hidden] { 54 | display: none; 55 | } 56 | 57 | // visible but not usable 58 | .disabled, [disabled] { 59 | pointer-events: none; 60 | opacity: 0.5; 61 | } 62 | 63 | // rendered but not visible or usable 64 | .cloaked, [cloaked], [v-cloak] { 65 | pointer-events: none; 66 | opacity: 0.0000000000001; 67 | } 68 | 69 | // common card style 70 | .card { 71 | padding: $padSpace; 72 | background-color: lighten( $colorDocument, 10% ); 73 | border-radius: $lineJoin; 74 | box-shadow: $shadowPaper; 75 | } 76 | 77 | // horizontail container for inline nav links 78 | .navlinks { 79 | overflow: hidden; 80 | white-space: nowrap; 81 | text-overflow: ellipsis; 82 | margin: 0 0 $padSpace 0; 83 | padding: ( $padSpace / 2 ) $padSpace; 84 | line-height: 2em; 85 | } 86 | 87 | // common margins 88 | .push-top { 89 | margin-top: $padSpace; 90 | } 91 | .push-right { 92 | margin-right: $padSpace; 93 | } 94 | .push-bottom { 95 | margin-bottom: $padSpace; 96 | } 97 | .push-left { 98 | margin-left: $padSpace; 99 | } 100 | .push-all { 101 | margin: $padSpace; 102 | } 103 | 104 | // common paddings 105 | .pad-top { 106 | padding-top: $padSpace; 107 | } 108 | .pad-right { 109 | padding-right: $padSpace; 110 | } 111 | .pad-bottom { 112 | padding-bottom: $padSpace; 113 | } 114 | .pad-left { 115 | padding-left: $padSpace; 116 | } 117 | .pad-all { 118 | padding: $padSpace; 119 | } 120 | 121 | -------------------------------------------------------------------------------- /client/scripts/Scroller.js: -------------------------------------------------------------------------------- 1 | /** 2 | * Scroller Class File. 3 | * Vertical scrolls a target element to a position or element. 4 | */ 5 | export default class Scroller { 6 | 7 | // class constructor 8 | constructor( target, destination, callback ) { 9 | this._active = true; 10 | this._target = window; 11 | this._method = null; 12 | this._ease = 8; 13 | this._min = 0; 14 | this._max = 0; 15 | this._pos = 0; 16 | this._to = 0; 17 | this._callback = callback; 18 | this._loop = this._loop.bind( this ); 19 | 20 | this._parseTarget( target ); 21 | this._parseDestination( destination ); 22 | if ( this._target ) this._loop(); 23 | } 24 | 25 | // parse target element to be scrolled 26 | _parseTarget( target ) { 27 | if ( target && typeof target === "string" ) { 28 | this._target = document.querySelector( target ); 29 | } 30 | else if ( target && target instanceof Element ) { 31 | this._target = target; 32 | } 33 | if ( this._target ) { 34 | var scrollHeight = Math.max( 0, Math.floor( this._target.scrollHeight || 0, this._target.clientHeight || 0 ) ); 35 | this._max = Math.floor( scrollHeight - this._target.clientHeight || 0 ); 36 | this._pos = this._target.scrollTop || 0; 37 | } 38 | } 39 | 40 | // parse dest position 41 | _parseDestination( dest ) { 42 | if ( typeof dest === "number" ) { 43 | this._to = dest; 44 | } 45 | else if ( typeof dest === "object" && dest instanceof Element ) { 46 | this._to = ( this._pos + dest.getBoundingClientRect().top ) || this._pos; 47 | } 48 | else if ( typeof dest === "string" ) { 49 | if ( /^(up|top)$/i.test( dest ) ) { this._to = this._min; } else 50 | if ( /^(middle|center)$/i.test( dest ) ) { this._to = this._max / 2; } else 51 | if ( /^(down|bottom)$/i.test( dest ) ) { this._to = this._max; } else 52 | if ( /^([0-9]+)$/.test( dest ) ) { this._to = parseInt( dest ); } 53 | else { 54 | var node = document.querySelector( dest ); 55 | this._to = node ? ( this._pos + node.getBoundingClientRect().top ) : this._pos; 56 | } 57 | } 58 | this._to = Math.max( this._min, Math.min( this._to, this._max ) ); 59 | } 60 | 61 | // cleanup after event is done 62 | _isDone() { 63 | this._active = false; 64 | if ( typeof this._callback === "function" ) { 65 | this._callback( this._to ); 66 | } 67 | } 68 | 69 | // Start scroll animation 70 | _loop() { 71 | if ( this._active !== true ) return; 72 | 73 | if ( Math.abs( this._to - this._pos ) < 1 ) { 74 | this._target.scrollTop = this._to; 75 | return this._isDone(); 76 | } 77 | this._pos += ( this._to - this._pos ) / this._ease; 78 | this._target.scrollTop = this._pos; 79 | requestAnimationFrame( this._loop ); 80 | } 81 | 82 | } 83 | -------------------------------------------------------------------------------- /README.md: -------------------------------------------------------------------------------- 1 | [twitter]: http://twitter.com/raintek_ 2 | [mit]: http://www.opensource.org/licenses/mit-license.php 3 | [vue]: https://github.com/vuejs/vue 4 | [hapi]: https://github.com/hapijs/hapi 5 | [levelup]: https://github.com/Level/levelup 6 | [sharp]: https://github.com/lovell/sharp 7 | [mdb]: https://www.themoviedb.org/ 8 | [node]: https://nodejs.org/ 9 | 10 | # FileBrowser SPA 11 | 12 | ![File-Browser](https://raw.githubusercontent.com/rainner/file-browser/master/thumb.jpg) 13 | 14 | This is a single page web app built with Vue.js and served by Hapi.js running on Node.js on the backend, intended to be used as a system file browser similar to Finder or Explorer. Some of the features of this file browser web app include: 15 | 16 | - Fast and reactive, powered by [Vue][vue]. 17 | - Session/Cookie user authentication with [Hapi][hapi]. 18 | - Tested to work on both Windows and Linux environments. 19 | - Automatically scan system for local attached devices. 20 | - Save device locations to favorite with LocalStorage. 21 | - Support for image thumbnail with [Sharp][sharp] using data urls. 22 | - Fast app, cache and user data storage with [Levelup][levelup]. 23 | - Integrated [TheMovieDb][mdb] API support for renaming video files. 24 | - Batch operation on multiple selected files (Move/Delete). 25 | - Cleanup options for deleting or renaming multiple files at once. 26 | - Drag/drop multi-file upload to current selected location on a device. 27 | - Small footprint. 28 | 29 | ### Installation 30 | 31 | This app makes use of Async/Await on the backend which requires [Node][node] version 7.6+. Some of the other app dependencies requires compilers to build it's code (c++, gcc, msvs, etc) depending on your OS. 32 | 33 | After cloning this, you should probably rename the example config file included and make any changes to it before moving on. Rename file `/common/config.example.js` to just `/common/config.js`. 34 | 35 | Installing the dependencies and building the bundles after install. 36 | 37 | ```sh 38 | # install everything 39 | $ npm install 40 | 41 | # build app bundles with webpack 42 | $ npm run build 43 | ``` 44 | 45 | Managing user accounts used to access the app (authentication). This will run an included script used to manage user accounts. Follow the help message and use arguments as needed. 46 | 47 | ```sh 48 | # get the default help output 49 | $ npm run user 50 | 51 | # list existing users 52 | $ npm run user list 53 | 54 | # add a new user to the list 55 | $ npm run user create Bob l337p4$$ 56 | ``` 57 | 58 | Running the app. 59 | 60 | ```sh 61 | # start with node 62 | $ npm run node 63 | 64 | # start with nodemon 65 | $ npm run nodemon 66 | 67 | # start with pm2 68 | $ npm run pm2 69 | ``` 70 | 71 | ### Todos 72 | 73 | - Write Tests 74 | - Add Night Mode 75 | 76 | ### Author 77 | 78 | Rainner Lins: [@raintek_][twitter] 79 | 80 | ### License 81 | 82 | Licensed under [MIT][mit]. 83 | -------------------------------------------------------------------------------- /webpack.config.js: -------------------------------------------------------------------------------- 1 | /** 2 | * Webpack client-side config file 3 | */ 4 | const path = require( 'path' ); 5 | const webpack = require( 'webpack' ); 6 | const ExtractTextPlugin = require( 'extract-text-webpack-plugin' ); 7 | const config = require( './common/config' ); 8 | 9 | // server details 10 | const devServer = config.devServer; 11 | const mainServer = config.mainServer; 12 | 13 | // global stuff 14 | const cssVars = './client/scss/globals'; 15 | const isProd = ( process.env.NODE_ENV === 'production' ); 16 | 17 | // hot reload vue css in development 18 | const vueDevLoaders = { 19 | scss: 'vue-style-loader!css-loader!postcss-loader!sass-loader?data=@import "'+ cssVars +'";' 20 | } 21 | 22 | // extract vue css in production build 23 | const vueProdLoaders = { 24 | scss: ExtractTextPlugin.extract({ 25 | use: 'css-loader!postcss-loader!sass-loader?data=@import "'+ cssVars +'";', 26 | fallback: 'vue-style-loader' 27 | }) 28 | } 29 | 30 | module.exports = { 31 | devtool: '#eval-source-map', 32 | entry: { 33 | app: './client/main.js', 34 | }, 35 | output: { 36 | path: mainServer.public, 37 | publicPath: '/', 38 | filename: 'dist/[name].bundle.js', 39 | }, 40 | module: { 41 | rules: [ 42 | { 43 | test: /\.(jpe?g|png|gif|svg|map|css|eot|woff|woff2|ttf)$/, 44 | loader: 'ignore-loader', 45 | }, 46 | { 47 | test: /\.vue$/, 48 | loader: 'vue-loader', 49 | options: { 50 | loaders: isProd ? vueProdLoaders : vueDevLoaders 51 | } 52 | }, 53 | { 54 | test: /\.js$/, 55 | loader: 'babel-loader', 56 | exclude: /node_modules/, 57 | } 58 | ] 59 | }, 60 | plugins: [ 61 | new ExtractTextPlugin( 'dist/[name].bundle.css' ) 62 | ], 63 | resolve: { 64 | alias: { 65 | 'vue$': 'vue/dist/vue.esm.js' 66 | } 67 | }, 68 | devServer: { 69 | host: devServer.host, 70 | port: devServer.port, 71 | contentBase: mainServer.public, 72 | clientLogLevel: 'info', 73 | historyApiFallback: { 74 | index: devServer.fallback, 75 | }, 76 | proxy: { 77 | '/': { 78 | target: 'http://'+ mainServer.host +':'+ mainServer.port +'/', 79 | secure: false, 80 | } 81 | }, 82 | hot: true, 83 | inline: true, 84 | quiet: false, 85 | noInfo: false, 86 | compress: false, 87 | }, 88 | performance: { 89 | hints: false 90 | } 91 | } 92 | 93 | if ( isProd ) { 94 | module.exports.devtool = '#source-map' 95 | module.exports.plugins = (module.exports.plugins || []).concat([ 96 | new webpack.DefinePlugin({ 97 | 'process.env': { 98 | NODE_ENV: '"production"' 99 | } 100 | }), 101 | new webpack.optimize.UglifyJsPlugin({ 102 | sourceMap: true, 103 | compress: { 104 | warnings: false 105 | } 106 | }), 107 | new webpack.LoaderOptionsPlugin({ 108 | minimize: true 109 | }) 110 | ]) 111 | } 112 | -------------------------------------------------------------------------------- /client/scss/_flexbox.scss: -------------------------------------------------------------------------------- 1 | /** 2 | * Align children vertically (column) 3 | */ 4 | .flex-column { 5 | display: flex; 6 | flex-direction: column; 7 | flex-wrap: nowrap; 8 | } 9 | 10 | /** 11 | * Align children horizontally (row) 12 | */ 13 | .flex-row { 14 | display: flex; 15 | flex-direction: row; 16 | flex-wrap: nowrap; 17 | } 18 | 19 | /** 20 | * Flex items alignment and space distribution 21 | */ 22 | .flex-left { 23 | justify-content: flex-start; 24 | } 25 | .flex-center { 26 | justify-content: center; 27 | } 28 | .flex-right { 29 | justify-content: flex-end; 30 | } 31 | .flex-space { 32 | justify-content: space-between; 33 | } 34 | .flex-around { 35 | justify-content: space-around; 36 | } 37 | .flex-top { 38 | align-items: flex-start; 39 | } 40 | .flex-middle { 41 | align-items: center; 42 | } 43 | .flex-bottom { 44 | align-items: flex-end; 45 | } 46 | 47 | /** 48 | * Flex items sizing 49 | */ 50 | .flex-1 { 51 | flex: 1; 52 | } 53 | .flex-2 { 54 | flex: 2; 55 | } 56 | .flex-3 { 57 | flex: 3; 58 | } 59 | .flex-4 { 60 | flex: 4; 61 | } 62 | .flex-5 { 63 | flex: 5; 64 | } 65 | .flex-10 { 66 | flex-basis: 10%; 67 | } 68 | .flex-20 { 69 | flex-basis: 20%; 70 | } 71 | .flex-30 { 72 | flex-basis: 30%; 73 | } 74 | .flex-40 { 75 | flex-basis: 40%; 76 | } 77 | .flex-50 { 78 | flex-basis: 50%; 79 | } 80 | .flex-60 { 81 | flex-basis: 60%; 82 | } 83 | .flex-70 { 84 | flex-basis: 70%; 85 | } 86 | .flex-80 { 87 | flex-basis: 80%; 88 | } 89 | .flex-90 { 90 | flex-basis: 90%; 91 | } 92 | .flex-100 { 93 | flex-basis: 100%; 94 | } 95 | 96 | /** 97 | * Fixed with labels 98 | */ 99 | .flex-label { 100 | width: 100px; 101 | white-space: nowrap; 102 | text-overflow: ellipsis; 103 | overflow: hidden; 104 | } 105 | 106 | /** 107 | * Basic grid 108 | */ 109 | .flex-grid { 110 | display: flex; 111 | flex-direction: column; 112 | flex-wrap: nowrap; 113 | align-items: stretch; 114 | justify-content: stretch; 115 | 116 | .flex-grid-item { 117 | margin: 0 0 $padSpace 0; 118 | } 119 | .flex-10, .flex-20, .flex-30, .flex-40, .flex-50, .flex-60, .flex-70, .flex-80, .flex-90, .flex-100 { 120 | flex: 1; 121 | flex-basis: 100%; 122 | width: 100%; 123 | margin: 0; 124 | padding: 0; 125 | } 126 | 127 | @media #{$screenMedium} { 128 | flex-direction: row; 129 | 130 | .flex-grid-item { 131 | margin: 0 $padSpace 0 0; 132 | 133 | &:last-of-type { 134 | margin-right: 0; 135 | } 136 | } 137 | .flex-10 { flex-basis: 10%; width: 10%; } 138 | .flex-20 { flex-basis: 20%; width: 20%; } 139 | .flex-30 { flex-basis: 30%; width: 30%; } 140 | .flex-40 { flex-basis: 40%; width: 40%; } 141 | .flex-50 { flex-basis: 50%; width: 50%; } 142 | .flex-60 { flex-basis: 60%; width: 60%; } 143 | .flex-70 { flex-basis: 70%; width: 70%; } 144 | .flex-80 { flex-basis: 80%; width: 80%; } 145 | .flex-90 { flex-basis: 90%; width: 90%; } 146 | .flex-100 { flex-basis: 100%; width: 100%; } 147 | } 148 | } 149 | 150 | -------------------------------------------------------------------------------- /client/scripts/Viewport.js: -------------------------------------------------------------------------------- 1 | /** 2 | * Viewport Object. 3 | * Helper for working with viewport data. 4 | */ 5 | const _w = window || {}; 6 | const _s = window.screen || {}; 7 | const _d = document.documentElement || {}; 8 | const _b = document.body || {}; 9 | 10 | export default { 11 | screenWidth: function() { 12 | return Math.max( 0, _s.width || _s.availWidth || 0 ); 13 | }, 14 | screenHeight: function() { 15 | return Math.max( 0, _s.height || _s.availHeight || 0 ); 16 | }, 17 | clientWidth: function() { 18 | return Math.max( 0, _w.innerWidth || _d.clientWidth || _b.clientWidth || 0 ); 19 | }, 20 | clientHeight: function() { 21 | return Math.max( 0, _w.innerHeight || _d.clientHeight || _b.clientHeight || 0 ); 22 | }, 23 | pageWidth: function() { 24 | return Math.max( 0, _b.scrollWidth || 0, _b.offsetWidth || 0, _d.clientWidth || 0, _d.offsetWidth || 0, _d.scrollWidth || 0 ); 25 | }, 26 | pageHeight: function() { 27 | return Math.max( 0, _b.scrollHeight || 0, _b.offsetHeight || 0, _d.clientHeight || 0, _d.offsetHeight || 0, _d.scrollHeight || 0 ); 28 | }, 29 | pageLeft: function() { 30 | return Math.max( 0, _d.clientLeft || _b.clientLeft || 0 ); 31 | }, 32 | pageTop: function() { 33 | return Math.max( 0, _d.clientTop || _b.clientTop || 0 ); 34 | }, 35 | scrollLeft: function() { 36 | return Math.max( 0, _w.pageXOffset || _d.scrollLeft || _b.scrollLeft || 0 ) - this.pageLeft(); 37 | }, 38 | scrollTop: function() { 39 | return Math.max( 0, _w.pageYOffset || _d.scrollTop || _b.scrollTop || 0 ) - this.pageTop(); 40 | }, 41 | scrollRight: function() { // max right 42 | return Math.max( 0, Math.floor( this.pageWidth() - this.clientWidth() ) ); 43 | }, 44 | scrollBottom: function() { // max bottom 45 | return Math.max( 0, Math.floor( this.pageHeight() - this.clientHeight() ) ); 46 | }, 47 | mouseLeft: function( e ) { 48 | var t = ( e && e.changedTouches ) ? e.changedTouches[ 0 ] : {}; 49 | return e ? Math.max( 0, t.pageX || e.pageX || e.clientX || 0 ) : 0; 50 | }, 51 | mouseTop: function( e ) { 52 | var t = ( e && e.changedTouches ) ? e.changedTouches[ 0 ] : {}; 53 | return e ? Math.max( 0, t.pageY || e.pageY || e.clientY || 0 ) : 0; 54 | }, 55 | centerX: function( e ) { // pointer axis from center 56 | return ( this.mouseLeft( e ) - ( this.clientWidth() / 2 ) ); 57 | }, 58 | centerY: function( e ) { // pointer axis from center 59 | return ( this.mouseTop( e ) - ( this.clientHeight() / 2 ) ); 60 | }, 61 | elementWidth: function( e ) { // border-box 62 | return e ? Math.max( 0, e.offsetWidth || 0 ) : 0; 63 | }, 64 | elementHeight: function( e ) { // border-box 65 | return e ? Math.max( 0, e.offsetHeight || 0 ) : 0; 66 | }, 67 | elementLeft: function( e ) { // from window 68 | return e ? e.getBoundingClientRect().left : 0; 69 | }, 70 | elementTop: function( e ) { // from window 71 | return e ? e.getBoundingClientRect().top : 0; 72 | }, 73 | clampValue: function( value, min, max ) { 74 | return Math.max( min, Math.min( value, max ) ); 75 | } 76 | } 77 | -------------------------------------------------------------------------------- /client/scss/_prompt.scss: -------------------------------------------------------------------------------- 1 | /** 2 | * Custom prompt box 3 | */ 4 | .prompt-overlay { 5 | display: flex; 6 | flex-direction: column; 7 | align-items: center; 8 | justify-content: center; 9 | position: fixed; 10 | overflow: hidden; 11 | pointer-events: none; 12 | cursor: auto; 13 | background-color: $colorOverlay; 14 | padding: $padSpace; 15 | left: 0; 16 | top: 0; 17 | width: 100%; 18 | height: 100%; 19 | opacity: 0; 20 | z-index: $zindexModals; 21 | 22 | .prompt-container { 23 | display: block; 24 | overflow: hidden; 25 | position: relative; 26 | cursor: auto; 27 | width: 100%; 28 | max-width: 400px; 29 | background-color: lighten( $colorDocument, 20% ); 30 | border-radius: $lineJoin; 31 | box-shadow: $shadowBold; 32 | transform: scale( 0.5 ); 33 | 34 | .prompt-title { 35 | line-height: 1em; 36 | padding: $padSpace; 37 | border-bottom: $lineWidth $lineStyle $lineColor; 38 | 39 | &:before { 40 | display: inline-block; 41 | width: 1em; 42 | margin: 0 0.5em 0 0; 43 | font-family: "fontello"; 44 | content: "\e811"; 45 | } 46 | } 47 | 48 | .prompt-message { 49 | padding: $padSpace; 50 | } 51 | 52 | .prompt-input { 53 | display: block; 54 | overflow: hidden; 55 | font-size: inherit; 56 | line-height: 1.4em; 57 | min-width: 100%; 58 | margin: 0; 59 | padding: $padSpace; 60 | color: $colorGrey; 61 | background-color: transparent; 62 | box-shadow: none; 63 | 64 | &:active, &:focus { 65 | color: $colorText; 66 | } 67 | } 68 | 69 | .prompt-buttons { 70 | display: flex; 71 | flex-direction: row; 72 | align-items: center; 73 | justify-content: stretch; 74 | border-top: $lineWidth $lineStyle $colorPrimary; 75 | 76 | button { 77 | display: block; 78 | flex: 1; 79 | cursor: pointer; 80 | color: $colorDocumentText; 81 | text-align: center; 82 | text-transform: uppercase; 83 | font-size: 90%; 84 | line-height: 1.2em; 85 | margin: 0; 86 | padding: $padSpace; 87 | border-bottom: $lineWidth $lineStyle rgba( 0, 0, 0, 0.1 ); 88 | } 89 | .prompt-accept-btn { 90 | background-color: rgba( 0, 0, 0, 0.05 ); 91 | color: darken( $colorSuccess, 10% ); 92 | 93 | &:hover, &:active, &:focus { 94 | background-color: rgba( 0, 0, 0, 0 ); 95 | } 96 | } 97 | .prompt-cancel-btn { 98 | background-color: rgba( 0, 0, 0, 0.15 ); 99 | color: $colorDanger; 100 | 101 | &:hover, &:active, &:focus { 102 | background-color: rgba( 0, 0, 0, 0.1 ); 103 | } 104 | } 105 | } 106 | } 107 | 108 | &.prompt-visible { 109 | pointer-events: auto; 110 | opacity: 1; 111 | 112 | .prompt-container { 113 | transform: scale( 1 ); 114 | } 115 | } 116 | } 117 | -------------------------------------------------------------------------------- /common/utils.js: -------------------------------------------------------------------------------- 1 | /** 2 | * Common utilities 3 | */ 4 | module.exports = { 5 | 6 | // limit characters allowed for a system file name 7 | stripName( name ) { 8 | name = String( name || '' ) 9 | .replace( /[^\w\-\+\~\.\;\,\'\`\@\!\#\$\%\&\^\(\)\[\]\{\}]+/g, ' ' ) 10 | .replace( /[\s]+/g, ' ' ) 11 | .trim(); 12 | return ( name === '.' || name === '..' ) ? '' : name; 13 | }, 14 | 15 | // sanitize a path 16 | fixPath( path, append ) { 17 | append = String( append || '' ); 18 | return String( path || '' ) 19 | .replace( /\\/g, '/' ) 20 | .replace( /\/\/+/g, '/' ) 21 | .replace( /\/+$/g, '' ) + append; 22 | }, 23 | 24 | // adds characters to the left of a value if it's lenght is less than a limit 25 | leftPad( value, limit, char ) { 26 | value = String( value || '' ); 27 | char = String( char || '0' ); 28 | limit = limit >> 0; 29 | 30 | if ( value.length > limit ) { 31 | return value; 32 | } 33 | limit = limit - value.length; 34 | 35 | if ( limit > char.length ) { 36 | char += char.repeat( limit / char.length ); 37 | } 38 | return char.slice( 0, limit ) + value; 39 | }, 40 | 41 | // get readable file size from bytes 42 | byteSize( bytes, decimals ) { 43 | if ( bytes == 0 ) return '0 b'; 44 | let k = 1024; 45 | let s = [ 'b', 'Kb', 'Mb', 'Gb', 'Tb', 'Pb', 'Eb', 'Zb', 'Yb' ]; 46 | let i = Math.floor( Math.log( bytes ) / Math.log( k ) ); 47 | return parseFloat( ( bytes / Math.pow( k, i ) ).toFixed( decimals || 2 ) ) + ' ' + s[ i ]; 48 | }, 49 | 50 | // get date string for args, or current time 51 | dateString( dateStr, addTime ) { 52 | let output = ''; 53 | let date = new Date( dateStr || Date.now() ); 54 | let year = date.getUTCFullYear(); 55 | let month = ['Jan', 'Feb', 'Mar', 'Apr', 'May', 'Jun', 'Jul', 'Aug', 'Sep', 'Oct', 'Nov', 'Dec'][ date.getMonth() ]; 56 | let day = date.getUTCDate(); 57 | let minute = date.getMinutes(); 58 | let fullh = date.getHours(); 59 | let hour = ( fullh > 12 ) ? ( fullh - 12 ) : fullh; 60 | let ampm = ( fullh > 12 ) ? 'PM' : 'AM'; 61 | let _p = function( n ) { return ( n < 10 ) ? '0'+ n : ''+ n; }; 62 | 63 | hour = ( hour === 0 ) ? 12 : hour; 64 | output = month + '/' + _p( day ) + '/' + year; 65 | return ( addTime ) ? output + ' ' + _p( hour ) + ':' + _p( minute ) + ' ' + ampm : output; 66 | }, 67 | 68 | // random string for a given length 69 | randString( length ) { 70 | let chars = 'abcdefghijklmnopqrstuvwxyzABCDEFGHIJKLMNOPQRSTUVWXYZ0123456789'; 71 | let total = parseInt( length ) || 10; 72 | let output = ''; 73 | 74 | while ( total ) { 75 | output += chars.charAt( Math.floor( Math.random() * chars.length ) ); 76 | total--; 77 | } 78 | return output; 79 | }, 80 | 81 | // get a unique ID string that uses the current timestamp and a random value 82 | idString() { 83 | return ( Date.now().toString( 36 ) + Math.random().toString( 36 ).substr( 2, 5 ) ).toUpperCase(); 84 | }, 85 | 86 | // get a alphanumeric version of a string 87 | keyString( str ) { 88 | return String( str || '' ).trim().replace( /[^\w]+/g, '_' ); 89 | }, 90 | 91 | // build noun from a number 92 | getNoun( count, singular, plutal ) { 93 | return count + ' ' + ( ( count === 1 ) ? singular : plutal ); 94 | }, 95 | 96 | } 97 | -------------------------------------------------------------------------------- /client/scss/_mixins.scss: -------------------------------------------------------------------------------- 1 | 2 | // prevent text from being selected 3 | @mixin textNoSelect { 4 | -webkit-touch-callout: none; /* iOS Safari */ 5 | -webkit-user-select: none; /* Safari */ 6 | -khtml-user-select: none; /* Konqueror HTML */ 7 | -moz-user-select: none; /* Firefox */ 8 | -ms-user-select: none; /* Internet Explorer/Edge */ 9 | user-select: none; 10 | } 11 | 12 | // prevent text from wrapping 13 | @mixin textNoWrap { 14 | white-space: nowrap; 15 | } 16 | 17 | // clip text and add ellipsis 18 | @mixin textClip { 19 | overflow: hidden; 20 | text-overflow: ellipsis; 21 | white-space: nowrap; 22 | } 23 | 24 | // appearance of list rows 25 | @mixin listRow { 26 | border-top: 1px $lineStyle lighten( $colorDocument, 4% ); 27 | background-color: lighten( $colorDocument, 10% ); 28 | color: lighten( $colorDocumentText, 10% ); 29 | 30 | &:hover { 31 | background-color: lighten( $colorDocument, 6% ); 32 | color: lighten( $colorDocumentText, 4% ); 33 | } 34 | &:first-of-type { 35 | border-top: none; 36 | } 37 | } 38 | 39 | // add border accent to containers 40 | @mixin borderAccent { 41 | border-bottom: $lineWidth solid rgba( 0, 0, 0, 0.065 ); 42 | } 43 | 44 | // common top header bar styles 45 | @mixin headerBar { 46 | position: relative; 47 | vertical-align: middle; 48 | padding: 0 $padSpace; 49 | height: $topbarHeight; 50 | background-color: $colorPrimary; 51 | border-bottom: $lineWidth $lineStyle rgba( 0, 0, 0, 0.1 ); 52 | color: $colorBright; 53 | box-shadow: $shadowPaper; 54 | z-index: 100; 55 | 56 | .clickable:hover { 57 | color: lighten( $colorPrimary, 50% ); 58 | } 59 | } 60 | 61 | // common outer page wrapper 62 | @mixin contentWrapper { 63 | display: block; 64 | position: relative; 65 | overflow: hidden; 66 | margin: 0; 67 | padding: 0; 68 | height: calc( 100vh - #{$topbarHeight} ); 69 | } 70 | 71 | // common scrollable page wrapper 72 | @mixin contentScroller { 73 | @include contentWrapper; 74 | overflow-y: auto; 75 | } 76 | 77 | // common container box 78 | @mixin containerBox { 79 | padding: $padSpace; 80 | background-color: lighten( $colorDocument, 10% ); 81 | box-shadow: $shadowPaper; 82 | border-radius: $lineJoin; 83 | // @include borderAccent; 84 | } 85 | 86 | // common small headings 87 | @mixin smallHeading { 88 | display: flex; 89 | flex-direction: row; 90 | align-items: center; 91 | justify-content: space-between; 92 | margin: 0 0 $padSpace 0; 93 | padding: 0; 94 | height: ( $padSpace * 2 ); 95 | font-size: 75%; 96 | font-weight: normal; 97 | line-height: 1.4em; 98 | } 99 | 100 | // fullscreen modifiers 101 | @mixin fullScreen( $fixed: 0 ) { 102 | display: block; 103 | overflow: hidden; 104 | @if $fixed == 1 { position: fixed; } 105 | @else { position: absolute; } 106 | left: 0; 107 | top: 0; 108 | width: 100%; 109 | height: 100%; 110 | } 111 | 112 | // radial gradient background color 113 | @mixin bgRadial( $color ) { 114 | $dark: saturate( darken( $color, 8% ), 8% ); 115 | $image: radial-gradient( circle, $color 0%, $dark 55% ); 116 | background-color: $dark; 117 | background-image: $image; 118 | background-position: center center; 119 | background-repeat: no-repeat; 120 | } 121 | 122 | -------------------------------------------------------------------------------- /client/components/Modal.vue: -------------------------------------------------------------------------------- 1 | 16 | 17 | 65 | 66 | 142 | -------------------------------------------------------------------------------- /server/routes/moviedb.js: -------------------------------------------------------------------------------- 1 | /** 2 | * Get data from remote API for files that are Movie/Show related. 3 | */ 4 | const fs = require( 'fs-extra' ); 5 | const Boom = require( 'boom' ); 6 | const Stat = require( '../modules/stat' ); 7 | const Success = require( '../modules/success' ); 8 | const MovieDB = require( '../modules/moviedb' ); 9 | const config = require( '../../common/config' ); 10 | const routes = []; 11 | 12 | // The Movie DB API handler 13 | const tmdb = new MovieDB( config.keys.moviedb ); 14 | 15 | /** 16 | * Search API for movies by a given name 17 | */ 18 | routes.push({ 19 | method: 'POST', 20 | path: '/movie-search', 21 | config: { 22 | handler: ( request, reply ) => { 23 | let p = request.payload || {}; 24 | 25 | if ( !p.name ) { 26 | return reply( Boom.badRequest( 'Must provide a movie name.' ) ); 27 | } 28 | tmdb.searchMovie( p.name, ( err, data ) => { 29 | if ( err ) return reply( Boom.badRequest( err ) ); 30 | return reply( Success( 200, 'Success', data ) ); 31 | }); 32 | } 33 | } 34 | }); 35 | 36 | /** 37 | * Get details for a single movie by ID 38 | */ 39 | routes.push({ 40 | method: 'POST', 41 | path: '/movie-info', 42 | config: { 43 | handler: ( request, reply ) => { 44 | let p = request.payload || {}; 45 | 46 | if ( !p.id ) { 47 | return reply( Boom.badRequest( 'Must provide a movie ID number.' ) ); 48 | } 49 | tmdb.fetchMovieInfo( p.id, ( err, data ) => { 50 | if ( err ) return reply( Boom.badRequest( err ) ); 51 | return reply( Success( 200, 'Success', data ) ); 52 | }); 53 | } 54 | } 55 | }); 56 | 57 | /** 58 | * Search API for tv shows by a given name 59 | */ 60 | routes.push({ 61 | method: 'POST', 62 | path: '/tv-search', 63 | config: { 64 | handler: ( request, reply ) => { 65 | let p = request.payload || {}; 66 | 67 | if ( !p.name ) { 68 | return reply( Boom.badRequest( 'Must provide a tv show name.' ) ); 69 | } 70 | tmdb.searchShow( p.name, ( err, data ) => { 71 | if ( err ) return reply( Boom.badRequest( err ) ); 72 | return reply( Success( 200, 'Success', data ) ); 73 | }); 74 | } 75 | } 76 | }); 77 | 78 | /** 79 | * Get episodes list for a tv show season by ID 80 | */ 81 | routes.push({ 82 | method: 'POST', 83 | path: '/tv-season', 84 | config: { 85 | handler: ( request, reply ) => { 86 | let p = request.payload || {}; 87 | 88 | if ( !p.id || !p.season ) { 89 | return reply( Boom.badRequest( 'Must provide a tv show ID and season number.' ) ); 90 | } 91 | tmdb.fetchShowSeason( p.id, p.season, ( err, data ) => { 92 | if ( err ) return reply( Boom.badRequest( err ) ); 93 | return reply( Success( 200, 'Success', data ) ); 94 | }); 95 | } 96 | } 97 | }); 98 | 99 | /** 100 | * Get details for a single tv show episode by id, season and episode number 101 | */ 102 | routes.push({ 103 | method: 'POST', 104 | path: '/tv-episode', 105 | config: { 106 | handler: ( request, reply ) => { 107 | let p = request.payload || {}; 108 | 109 | if ( !p.id || !p.season || !p.episode ) { 110 | return reply( Boom.badRequest( 'Must provide a tv show ID, season and episode number.' ) ); 111 | } 112 | tmdb.fetchShowEpisode( p.id, p.season, p.episode, ( err, data ) => { 113 | if ( err ) return reply( Boom.badRequest( err ) ); 114 | return reply( Success( 200, 'Success', data ) ); 115 | }); 116 | } 117 | } 118 | }); 119 | 120 | // export routes 121 | module.exports = routes; 122 | -------------------------------------------------------------------------------- /server/modules/stat.js: -------------------------------------------------------------------------------- 1 | /** 2 | * Stat an item and get info about it 3 | */ 4 | const fs = require( 'fs' ); 5 | const md5 = require( 'md5' ); 6 | const mime = require( 'mime' ); 7 | const utils = require( '../../common/utils' ); 8 | 9 | const iconClass = ( type ) => { 10 | switch ( type ) { 11 | case 'folder' : return 'icon-folder'; 12 | case 'audio' : return 'icon-audio'; 13 | case 'video' : return 'icon-movie'; 14 | case 'image' : return 'icon-image'; 15 | case 'text' : return 'icon-edit'; 16 | case 'executable' : return 'icon-puzzle'; 17 | case 'package' : return 'icon-file-zip'; 18 | case 'application' : return 'icon-file-code'; 19 | default : return 'icon-file'; 20 | } 21 | }; 22 | 23 | module.exports = ( target, store ) => { 24 | 25 | return new Promise( resolve => { 26 | target = utils.fixPath( target ); 27 | 28 | fs.stat( target, ( err, stats ) => { 29 | if ( err ) return resolve(); 30 | 31 | let isdir = stats.isDirectory(); 32 | let name = target.split( '/' ).pop(); 33 | let item = { 34 | selected: false, 35 | protected: ( /^(\.|\$|desktop\.ini|thumbs\.db)/i.test( name ) ), 36 | parent: target.split( '/' ).slice( 0, -1 ).join( '/' ), 37 | path: target, 38 | name: name, 39 | type: isdir ? 'folder' : 'file', 40 | extension: '', 41 | size: '', 42 | created: utils.dateString( stats.birthtime || Date.now() ), 43 | modified: utils.dateString( stats.mtime || stats.birthtime || Date.now() ), 44 | icon: isdir ? 'icon-folder' : 'icon-file', 45 | thumbnail: '', 46 | stats: stats, 47 | }; 48 | 49 | // resolve items count within folder 50 | if ( item.type === 'folder' ) { 51 | return fs.readdir( target, {}, ( err, list ) => { 52 | list = list || []; 53 | item.size = utils.getNoun( list.length, 'item', 'items' ); 54 | resolve( item ); 55 | }); 56 | } 57 | 58 | // resolve file 59 | if ( item.type === 'file' ) { 60 | item.size = utils.byteSize( parseInt( stats.size || 0 ) ); 61 | 62 | // resolve file extension and type from mime 63 | if ( /\.[\w\-]+$/.test( name ) ) { 64 | item.extension = name.split( '.' ).pop().toLowerCase(); 65 | item.type = String( mime.getType( item.extension ) || '' ).split( '/' ).shift(); 66 | item.type = /^(audio|video|image|text|application)$/i.test( item.type ) ? item.type : 'file'; 67 | } 68 | // try to resolve file types based on extension 69 | if ( item.extension ) { 70 | item.type = /^(jsx|vue)$/i.test( item.extension ) ? 'application' : item.type; 71 | item.type = /^(json|xml|ini|info|nfo|cnf|config|lst|list)$/i.test( item.extension ) ? 'text' : item.type; 72 | item.type = /^(exe|apk|app|ipa|k?sh|py|cpl|msi|msp|msc|mst|cmd|bat|reg|rgs|run|out|job|paf|pif|vb|vbe|vbs|ws|wsf|wsh)$/i.test( item.extension ) ? 'executable' : item.type; 73 | item.type = /^(ar?|lbr|iso|dmg|mar|rar|sda|jar|pak|zipx?|s?7z|zz|gz|lz|rz|sz|xz|shar|bz2|ace|arc|cab|car)$/i.test( item.extension ) ? 'package' : item.type; 74 | } 75 | // resolve icon after resolving type 76 | item.icon = iconClass( item.type ); 77 | 78 | // check if thumb image for this file exists in cache/db 79 | if ( item.type === 'image' && store ) { 80 | return store.get( md5( target ), ( err, thumb ) => { 81 | item.thumbnail = String( thumb || '' ); 82 | resolve( item ); 83 | }); 84 | } 85 | // all done here 86 | resolve( item ); 87 | } 88 | }); 89 | }); 90 | } 91 | -------------------------------------------------------------------------------- /client/scss/_forms.scss: -------------------------------------------------------------------------------- 1 | // base elements 2 | button, 3 | input, 4 | optgroup, 5 | select, 6 | textarea { 7 | font-family: inherit; 8 | font-size: inherit; 9 | line-height: inherit; 10 | color: inherit; 11 | font-weight: normal; 12 | outline: none; 13 | } 14 | 15 | // inputs 16 | input, 17 | select, 18 | textarea { 19 | display: block; 20 | padding: 0; 21 | line-height: 1.5em; 22 | width: 100%; 23 | } 24 | 25 | // buttons 26 | button, 27 | input[type="button"], 28 | input[type="reset"], 29 | input[type="submit"] { 30 | cursor: pointer; 31 | } 32 | 33 | // row container for a form element 34 | .form-row { 35 | display: block; 36 | margin: 0 0 ( $padSpace / 2 ) 0; 37 | 38 | &:last-of-type { 39 | margin: 0; 40 | } 41 | } 42 | 43 | // common form element label text style 44 | .form-label { 45 | display: block; 46 | text-transform: uppercase; 47 | font-weight: normal; 48 | font-size: 80%; 49 | opacity: 0.6; 50 | 51 | &:before { 52 | display: inline-block; 53 | margin: 0 0.5em 0 0; 54 | opacity: 0.5; 55 | font-family: 'fontello'; 56 | content: '\f004'; 57 | } 58 | } 59 | 60 | // custom inputs 61 | .form-input { 62 | display: block; 63 | overflow: hidden; 64 | padding: ( $padSpace / 2 ) $padSpace; 65 | line-height: 1.4em; 66 | vertical-align: middle; 67 | color: $colorInputText; 68 | background-color: $colorInput; 69 | border: $lineWidth $lineStyle darken( $colorInput, 20% ); 70 | border-radius: $lineJoin; 71 | box-shadow: $shadowHollow; 72 | 73 | &:hover { 74 | border-color: darken( $colorInput, 40% ); 75 | } 76 | &:active, &:focus { 77 | border-color: $colorSecondary; 78 | } 79 | &.success { 80 | border-color: $colorSuccess; 81 | } 82 | &.warning { 83 | border-color: $colorWarning; 84 | } 85 | &.danger { 86 | border-color: $colorDanger; 87 | } 88 | &.info { 89 | border-color: $colorInfo; 90 | } 91 | } 92 | textarea.form-input { 93 | overflow: auto; 94 | min-width: 100%; 95 | max-width: 100%; 96 | min-height: 80px; 97 | } 98 | 99 | // custom toggle button 100 | .form-toggle { 101 | display: inline-block; 102 | position: relative; 103 | margin-right: $listSpace; 104 | 105 | span { 106 | display: flex; 107 | flex-direction: row; 108 | align-items: center; 109 | @include textNoSelect; 110 | 111 | &:before { 112 | content: ""; 113 | display: inline-block; 114 | cursor: pointer; 115 | margin: 0 0.5em 0 0; 116 | padding: 0; 117 | width: 1em; 118 | height: 1em; 119 | border-radius: 100px; 120 | border: $lineWidth $lineStyle #fff; 121 | background-color: darken( $colorInput, 10% ); 122 | box-shadow: 0 0 0 $lineWidth darken( $colorInput, 20% ); 123 | } 124 | } 125 | span:hover:before { 126 | background-color: darken( $colorInput, 20% ); 127 | } 128 | input[type="radio"], 129 | input[type="checkbox"] { 130 | display: inline; 131 | position: absolute; 132 | visibility: hidden; 133 | left: -1000px; 134 | top: 0; 135 | } 136 | input[type="radio"]:checked ~ span:before, 137 | input[type="checkbox"]:checked ~ span:before { 138 | background-color: $colorSuccess; 139 | } 140 | } 141 | 142 | // form buttons 143 | .form-btn { 144 | display: inline-block; 145 | padding: ( $padSpace / 2 ) $padSpace; 146 | text-transform: uppercase; 147 | font-size: 90%; 148 | line-height: 1.8em; 149 | border-radius: $lineJoin; 150 | background-color: $colorDefault; 151 | color: $colorDefaultText; 152 | box-shadow: $shadowPaper; 153 | 154 | &:hover { 155 | background-color: darken( $colorDefault, 5% ); 156 | } 157 | } 158 | 159 | 160 | -------------------------------------------------------------------------------- /client/components/ItemPlayer.vue: -------------------------------------------------------------------------------- 1 | 45 | 46 | 105 | 106 | -------------------------------------------------------------------------------- /server/modules/drives.js: -------------------------------------------------------------------------------- 1 | /** 2 | * Reads list of local drives on the system. 3 | */ 4 | const os = require( 'os' ); 5 | const exec = require( 'child_process' ).exec; 6 | const config = require( '../../common/config' ); 7 | const utils = require( '../../common/utils' ); 8 | 9 | // percentage of device used that triggers a warning 10 | const dangerSize = config.storage.dangerSize || 80; 11 | 12 | /** 13 | * Parse output from wmic on windows 14 | */ 15 | function win32Parse( callback ) { 16 | exec( 'wmic logicaldisk get Caption,FreeSpace,Size,VolumeSerialNumber,VolumeName,DriveType /format:list', ( err, stdout, stderr ) => { 17 | let drives = []; 18 | let devices = String( stdout || '' ).replace( /[\r]+/g, '' ).trim().split( '\n\n' ); 19 | 20 | for ( let i = 0; i < devices.length; ++i ) { 21 | let drive = JSON.parse( '{"' + String( devices[ i ] ).trim().replace( /\n+/g, '","' ).replace( /\=/g, '":"' ) + '"}' ); 22 | let size = parseInt( drive.Size || 0 ); 23 | let free = parseInt( drive.FreeSpace || 0 ); 24 | let used = Number( size - free ); 25 | let perc = Number( parseFloat( used / ( used + free ) * 100 ).toFixed( 1 ) ); 26 | 27 | if ( !drive.Caption || !drive.VolumeName || !drive.VolumeSerialNumber || drive.DriveType !== '3' ) continue; 28 | 29 | drives.push({ 30 | id: drive.VolumeSerialNumber, 31 | name: drive.VolumeName, 32 | path: drive.Caption, 33 | size: utils.byteSize( size ), 34 | free: utils.byteSize( free ), 35 | perc: perc + '%', 36 | warn: ( perc >= dangerSize ), 37 | }); 38 | } 39 | callback( drives ); 40 | }); 41 | } 42 | 43 | /** 44 | * Parse output from lsblk on unix 45 | */ 46 | function linuxParse( callback ) { 47 | exec( 'lsblk -bJo UUID,LABEL,SIZE,MOUNTPOINT', ( err, stdout, stderr ) => { 48 | let drives = []; 49 | let parsed = JSON.parse( stdout || '{}' ); 50 | let devices = parsed.blockdevices || []; 51 | let plist = []; 52 | 53 | for ( let i = 0; i < devices.length; ++i ) { 54 | let drive = devices[ i ]; 55 | 56 | if ( !drive.uuid || !drive.label || !drive.mountpoint ) continue; 57 | 58 | plist.push( new Promise( resolve => { 59 | exec( 'df --block-size=1 --output=avail '+ drive.mountpoint, ( err, stdout, stderr ) => { 60 | let size = parseInt( drive.size || 0 ); 61 | let free = parseInt( String( stdout ).replace( /[^0-9]+/g, '' ) || 0 ); 62 | let used = Number( size - free ); 63 | let perc = Number( parseFloat( used / ( used + free ) * 100 ).toFixed( 1 ) ); 64 | 65 | drives.push({ 66 | id: drive.uuid, 67 | name: drive.label, 68 | path: drive.mountpoint, 69 | size: utils.byteSize( size ), 70 | free: utils.byteSize( free ), 71 | perc: perc + '%', 72 | warn: ( perc >= dangerSize ), 73 | }); 74 | resolve(); 75 | }); 76 | })); 77 | } 78 | Promise.all( plist ).then( () => { 79 | callback( drives ); 80 | }); 81 | }); 82 | } 83 | 84 | /** 85 | * Parse output on MacOS 86 | */ 87 | function darwinParse( callback ) { 88 | let drives = []; 89 | // ... 90 | callback( drives ); 91 | } 92 | 93 | 94 | /** 95 | * Get list of local hard drives on current platform 96 | */ 97 | module.exports = { 98 | getDrives( callback ) { 99 | switch ( os.platform().toLowerCase() ) { 100 | case "win32" : win32Parse( callback ); break; 101 | case "linux" : linuxParse( callback ); break; 102 | case "darwin" : darwinParse( callback ); break; 103 | default : callback( [] ); 104 | } 105 | } 106 | } 107 | 108 | -------------------------------------------------------------------------------- /client/components/AppLogin.vue: -------------------------------------------------------------------------------- 1 | 36 | 37 | 89 | 90 | 130 | -------------------------------------------------------------------------------- /client/components/Notify.vue: -------------------------------------------------------------------------------- 1 | 12 | 13 | 56 | 57 | 143 | -------------------------------------------------------------------------------- /client/components/DropMenu.vue: -------------------------------------------------------------------------------- 1 | 11 | 12 | 58 | 59 | 145 | -------------------------------------------------------------------------------- /client/components/ItemInfo.vue: -------------------------------------------------------------------------------- 1 | 59 | 60 | 109 | 110 | 139 | -------------------------------------------------------------------------------- /server/routes/auth.js: -------------------------------------------------------------------------------- 1 | /** 2 | * User authentication routes 3 | */ 4 | const Joi = require( 'joi' ); 5 | const Bcrypt = require( 'bcrypt' ); 6 | const Boom = require( 'boom' ); 7 | const Success = require( '../modules/success' ); 8 | const Users = require( '../modules/users' ); 9 | const config = require( '../../common/config' ); 10 | const utils = require( '../../common/utils' ); 11 | const routes = []; 12 | 13 | // wrapper for modifying user data after login or update 14 | const fixUserData = ( data ) => { 15 | if ( data.password ) delete data.password; 16 | return Object.assign( data, { 17 | login_time: Date.now(), 18 | login_duration: config.session.duration, 19 | }); 20 | }; 21 | 22 | // helper for updating user data 23 | const updateUserData = ( id, newdata, request, reply ) => { 24 | Users.update( id, newdata, ( err, userdata ) => { 25 | if ( err ) return reply( Boom.badImplementation( err ) ); 26 | userdata = fixUserData( userdata ); // filter data 27 | request.cookieAuth.set( userdata ); // update login data 28 | reply( Success( 200, 'User data saved successfully.', userdata ) ); 29 | }); 30 | }; 31 | 32 | /** 33 | * Get client loggin status and data. 34 | * @return {object}: HTTP response object with data 35 | */ 36 | routes.push({ 37 | method: 'GET', 38 | path: '/user', 39 | config: { 40 | auth: { mode: 'try' }, 41 | handler: ( request, reply ) => { 42 | let auth = request.auth || {}; 43 | let loggedin = auth.isAuthenticated || false; 44 | let userdata = auth.credentials || {}; 45 | return reply( Success( 200, 'Status', { loggedin, userdata } ) ); 46 | } 47 | } 48 | }); 49 | 50 | /** 51 | * save user account data. 52 | * @string {id}: User id 53 | * @string {data}: New data to be merged 54 | * @return {object}: HTTP response object with user ata 55 | */ 56 | routes.push({ 57 | method: 'POST', 58 | path: '/user', 59 | config: { 60 | validate: { 61 | payload: { 62 | id: Joi.string().min( 32 ).max( 32 ).required(), 63 | data: Joi.object().required(), 64 | } 65 | }, 66 | handler: ( request, reply ) => { 67 | let id = request.payload.id; 68 | let data = request.payload.data; 69 | let newdata = {}; 70 | 71 | if ( !id || !data.name || !data.username ) { 72 | return reply( Boom.badRequest( 'Account id, name and username are required.' ) ); 73 | } 74 | newdata.modified = Date.now(); 75 | newdata.name = data.name; 76 | newdata.username = data.username; 77 | 78 | if ( data.password ) { 79 | return Bcrypt.hash( data.password, 10, ( err, hash ) => { 80 | if ( err || !hash ) return reply( Boom.badImplementation( err ) ); 81 | newdata.password = hash.toString(); 82 | updateUserData( id, newdata, request, reply ); 83 | }); 84 | } 85 | updateUserData( id, newdata, request, reply ); 86 | } 87 | } 88 | }); 89 | 90 | /** 91 | * Authenticate login request 92 | * @string {username}: User name 93 | * @string {password}: Account password 94 | * @return {object}: HTTP response object with user data 95 | */ 96 | routes.push({ 97 | method: 'POST', 98 | path: '/login', 99 | config: { 100 | auth: false, 101 | validate: { 102 | payload: { 103 | username: Joi.string().min( 3 ).max( 30 ).required(), 104 | password: Joi.string().min( 6 ).max( 200 ).required(), 105 | } 106 | }, 107 | handler: ( request, reply ) => { 108 | let username = request.payload.username; 109 | let pw_plain = request.payload.password; 110 | 111 | Users.fetchByName( username, ( err, data ) => { 112 | if ( err ) return reply( Boom.unauthorized( 'Account could not be found.' ) ); 113 | if ( !data ) return reply( Boom.unauthorized( 'Account could not be found.' ) ); 114 | 115 | Bcrypt.compare( pw_plain, data.password, ( err, isValid ) => { 116 | if ( err || !isValid ) return reply( Boom.unauthorized( 'Incorrect username or password.' ) ); 117 | data = fixUserData( data ); 118 | request.cookieAuth.set( data ); 119 | reply( Success( 200, 'Login successful.', data ) ); 120 | }); 121 | }); 122 | } 123 | } 124 | }); 125 | 126 | /** 127 | * Clear session and return to login page. 128 | * @return {object}: HTTP response object with data 129 | */ 130 | routes.push({ 131 | method: 'GET', 132 | path: '/logout', 133 | config: { 134 | auth: { mode: 'try' }, 135 | handler: ( request, reply ) => { 136 | request.cookieAuth.clear(); 137 | return reply( Success( 200, 'Session terminated successfully.' ) ); 138 | } 139 | } 140 | }); 141 | 142 | // export routes 143 | module.exports = routes; 144 | -------------------------------------------------------------------------------- /client/components/ItemsRows.vue: -------------------------------------------------------------------------------- 1 | 58 | 59 | 80 | 81 | 168 | -------------------------------------------------------------------------------- /client/components/ItemsGrid.vue: -------------------------------------------------------------------------------- 1 | 54 | 55 | 82 | 83 | 170 | -------------------------------------------------------------------------------- /client/scripts/Prompt.js: -------------------------------------------------------------------------------- 1 | /** 2 | * Prompt Class. 3 | * Asks user for input, or show a confirmation dialog. 4 | */ 5 | export default class Prompt { 6 | 7 | // class constructor 8 | constructor( options ) { 9 | 10 | this._options = Object.assign({ 11 | // title to show on prompt window 12 | title: 'Enter a value', 13 | // default value to be inserted 14 | value: '', 15 | // show confirm message instead of a prompt input 16 | confirm: '', 17 | // text for input placeholder 18 | inputText: 'Type here...', 19 | // text to show on accept button 20 | acceptText: 'Accept', 21 | // text to show on cancel button 22 | cancelText: 'Cancel', 23 | // function to call on accept 24 | onAccept: null, 25 | // function to call on cancel 26 | onCancel: null, 27 | // function to call on empty input 28 | onEmpty: null, 29 | // don't accept empty input values for prompts 30 | forceValue: true, 31 | // class used to animate loader in/out 32 | toggleClass: 'prompt-visible', 33 | // duration of css animation on show/hide toggle 34 | toggleDuration: 600, 35 | // z-index for loader when active 36 | zIndex: 99999, 37 | // ... 38 | }, options ); 39 | 40 | this._title = document.createElement( 'div' ); 41 | this._title.setAttribute( 'class', 'prompt-title' ); 42 | this._title.innerHTML = this._options.title || ''; 43 | 44 | this._message = document.createElement( 'div' ); 45 | this._message.setAttribute( 'class', 'prompt-message' ); 46 | this._message.innerHTML = this._options.confirm || ''; 47 | 48 | this._input = document.createElement( 'input' ); 49 | this._input.setAttribute( 'type', 'text' ); 50 | this._input.setAttribute( 'placeholder', this._options.inputText ); 51 | this._input.setAttribute( 'class', 'prompt-input' ); 52 | this._input.value = this._options.value || ''; 53 | 54 | this._accept = document.createElement( 'button' ); 55 | this._accept.setAttribute( 'type', 'submit' ); 56 | this._accept.setAttribute( 'class', 'prompt-accept-btn' ); 57 | this._accept.innerHTML = this._options.acceptText; 58 | 59 | this._cancel = document.createElement( 'button' ); 60 | this._cancel.setAttribute( 'type', 'button' ); 61 | this._cancel.setAttribute( 'class', 'prompt-cancel-btn' ); 62 | this._cancel.innerHTML = this._options.cancelText; 63 | 64 | this._buttons = document.createElement( 'div' ); 65 | this._buttons.setAttribute( 'class', 'prompt-buttons' ); 66 | this._buttons.appendChild( this._accept ); 67 | this._buttons.appendChild( this._cancel ); 68 | 69 | this._container = document.createElement( 'form' ); 70 | this._container.setAttribute( 'class', 'prompt-container' ); 71 | this._container.setAttribute( 'action', '#' ); 72 | this._container.setAttribute( 'method', 'get' ); 73 | this._container.addEventListener( 'submit', this._onAccept.bind( this ), true ); 74 | this._container.appendChild( this._title ); 75 | 76 | if ( this._options.confirm ) { this._container.appendChild( this._message ); } 77 | else { this._container.appendChild( this._input ); } 78 | this._container.appendChild( this._buttons ); 79 | 80 | this._overlay = document.createElement( 'div' ); 81 | this._overlay.setAttribute( 'class', 'prompt-overlay' ); 82 | this._overlay.addEventListener( 'click', this._onCancel.bind( this ), true ); 83 | this._overlay.appendChild( this._container ); 84 | 85 | document.body.appendChild( this._overlay ); 86 | 87 | setTimeout( () => { 88 | this._overlay.classList.add( this._options.toggleClass ); 89 | this._input.selectionStart = this._input.selectionEnd = 10000; 90 | this._input.focus(); 91 | }, 60 ); 92 | } 93 | 94 | // remove prompt from page 95 | _remove() { 96 | this._overlay.classList.remove( this._options.toggleClass ); 97 | 98 | setTimeout( () => { 99 | if ( document.body.contains( this._overlay ) ) { 100 | document.body.removeChild( this._overlay ); 101 | } 102 | }, this._options.toggleDuration ); 103 | } 104 | 105 | // on accept button 106 | _onAccept( e ) { 107 | e.preventDefault(); 108 | 109 | if ( typeof this._options.onAccept === 'function' ) { 110 | let value = String( this._input.value || '' ).trim(); 111 | 112 | if ( this._options.forceValue && !this._options.confirm && !value ) { 113 | if ( typeof this._options.onEmpty === 'function' ) { 114 | this._options.onEmpty( 'Must enter a value.' ); 115 | } 116 | return; 117 | } 118 | this._options.onAccept( value ); 119 | } 120 | this._remove(); 121 | } 122 | 123 | // on cancel bubble 124 | _onCancel( e ) { 125 | if ( e.target === this._cancel || e.target === this._overlay ) { 126 | if ( typeof this._options.onCancel === 'function' ) { 127 | this._options.onCancel( this._input.value ); 128 | } 129 | this._remove(); 130 | } 131 | } 132 | 133 | } 134 | -------------------------------------------------------------------------------- /server/server.js: -------------------------------------------------------------------------------- 1 | /** 2 | * Web server 3 | */ 4 | const fs = require( 'fs' ); 5 | const Path = require( 'path' ); 6 | const Hapi = require( 'hapi' ); 7 | const Cookie = require( 'hapi-auth-cookie' ); 8 | const Inert = require( 'inert' ); 9 | const config = require( '../common/config' ); 10 | 11 | const serverInfo = config.mainServer; 12 | 13 | /** 14 | * Hapi server 15 | */ 16 | const server = new Hapi.Server({ 17 | // debug: false, 18 | }); 19 | 20 | /** 21 | * Connection options 22 | */ 23 | server.connection({ 24 | host: serverInfo.host, 25 | port: process.argv[2] || serverInfo.port || 3000, 26 | router: { 27 | stripTrailingSlash: true 28 | }, 29 | routes: { 30 | files: { 31 | relativeTo: serverInfo.views, 32 | } 33 | } 34 | }); 35 | 36 | /** 37 | * Pre-response event handler 38 | */ 39 | server.ext( 'onPreResponse', ( request, reply ) => { 40 | let response = request.response.isBoom ? request.response.output : request.response; 41 | let output = '-'.repeat( 50 ) +'\n'+ request.method.toUpperCase() +' '+ request.path +'\n'; 42 | 43 | // log info about the request 44 | for ( let name in request.headers ) { 45 | if ( !request.headers.hasOwnProperty( name ) ) continue; 46 | output += ' > '+ name +': '+ request.headers[ name ] +'\n'; 47 | } 48 | // set CORS headers for OPTIONS request 49 | if ( request.headers.origin ) { 50 | response.headers['access-control-allow-origin'] = request.headers.origin; 51 | response.headers['access-control-allow-credentials'] = 'true'; 52 | 53 | if ( request.method === 'options' ) { 54 | response.statusCode = 200; 55 | response.headers['access-control-expose-headers'] = 'content-type, content-length, etag'; 56 | response.headers['access-control-max-age'] = 60 * 10; 57 | 58 | if ( request.headers['access-control-request-headers'] ) { 59 | response.headers['access-control-allow-headers'] = request.headers['access-control-request-headers']; 60 | } 61 | if ( request.headers['access-control-request-method'] ) { 62 | response.headers['access-control-allow-methods'] = request.headers['access-control-request-method']; 63 | } 64 | } 65 | } 66 | // done 67 | console.log( '\n', output, '\n', request.payload || 'No payload data.' ); 68 | reply.continue(); 69 | }); 70 | 71 | /** 72 | * Load custom routes from a path 73 | */ 74 | const setupRoutes = () => { 75 | return new Promise( resolve => { 76 | 77 | // setup cookie auth options 78 | server.auth.strategy( 'session', 'cookie', { 79 | cookie: config.session.name, 80 | ttl: config.session.duration, 81 | password: config.keys.crypt, 82 | isSecure: false, 83 | }); 84 | 85 | // default auth strategy 86 | server.auth.default({ 87 | strategy: 'session', 88 | mode: 'required', 89 | }); 90 | 91 | // common static files handler with Inert 92 | server.route({ 93 | method: 'GET', 94 | path: '/{param*}', 95 | config: { 96 | auth: false, 97 | handler: { 98 | directory: { 99 | path: serverInfo.public, 100 | redirectToSlash: true, 101 | listing: false, 102 | } 103 | } 104 | } 105 | }); 106 | 107 | // look for route files 108 | fs.readdir( serverInfo.routes, {}, ( err, list ) => { 109 | if ( err ) throw err; 110 | let routesList = []; 111 | 112 | // add all routes found into single array 113 | for ( let file of list ) { 114 | if ( !/\.js$/.test( file ) ) continue; 115 | let data = require( Path.join( serverInfo.routes, file ) ); 116 | 117 | if ( Array.isArray( data ) ) { 118 | for ( let route of data ) routesList.push( route ); 119 | } 120 | else if ( typeof data === 'object' ) { 121 | routesList.push( data ); 122 | } 123 | } 124 | 125 | // check and register all routes 126 | for ( let i = 0; i < routesList.length; ++i ) { 127 | let r = routesList[ i ]; 128 | r.config = r.config || {}; 129 | 130 | // default payload type if not specified all by GET, HEAD methods 131 | if ( !/get|head/i.test( r.method ) && !r.config.payload ) { 132 | r.config.payload = { output: 'data', parse: true }; 133 | } 134 | // move handler into config object 135 | if ( typeof r.handler === 'function' ) { 136 | r.config.handler = r.handler; 137 | r.handler = false; 138 | } 139 | // register route 140 | console.log( 'Registering route for:', r.method, r.path ); 141 | server.route( r ); 142 | } 143 | 144 | resolve(); 145 | }); 146 | }); 147 | }; 148 | 149 | /** 150 | * Register plugins and start the server when all is ready 151 | */ 152 | const initServer = async () => { 153 | try { 154 | await server.register( Cookie ); 155 | await server.register( Inert ); 156 | await setupRoutes(); 157 | await server.start(); 158 | console.log( `Server running at: ${server.info.uri}` ); 159 | } 160 | catch( e ) { 161 | console.log( e ); 162 | } 163 | }; 164 | 165 | /** 166 | * Start 167 | */ 168 | initServer(); 169 | 170 | -------------------------------------------------------------------------------- /server/routes/maintenance.js: -------------------------------------------------------------------------------- 1 | /** 2 | * Common fs maintenance 3 | */ 4 | const fs = require( 'fs-extra' ); 5 | const mime = require( 'mime' ); 6 | const Boom = require( 'boom' ); 7 | const Scanner = require( '../modules/scanner' ); 8 | const Success = require( '../modules/success' ); 9 | const config = require( '../../common/config' ); 10 | const utils = require( '../../common/utils' ); 11 | const routes = []; 12 | 13 | // 1:1 map of regular expressions to test for incoming request params 14 | const reglist = { 15 | hidden_files : /^\.(DS_Store|AppleDouble|LSOverride|Trashes).*$|^Desktop\.ini$/, 16 | thumb_files : /^\._.*$|^Icon\r$|^(Thumbs|ehthumbs)\.db$|^(folder|Album|Cover|banner).*\.jpg$/, 17 | cache_files : /^\..*\.swp$|~$/, 18 | torrent_files : /\.torrent$/, 19 | log_files : /\.log$/, 20 | }; 21 | 22 | /** 23 | * Rename child files of given path with incoming options 24 | */ 25 | routes.push({ 26 | method: 'POST', 27 | path: '/clean-names', 28 | config: { 29 | handler: ( request, reply ) => { 30 | let p = request.payload || {}; 31 | 32 | if ( !p.path ) { 33 | return reply( Boom.badRequest( 'Must provide a folder path.' ) ); 34 | } 35 | let fpath = utils.fixPath( p.path ); 36 | let count = 0; 37 | 38 | // scan for files... 39 | fs.readdirSync( fpath ).forEach( ( name ) => { 40 | let filePath = fpath +'/'+ name; 41 | let fileStats = fs.statSync( filePath ); 42 | 43 | if ( fileStats.isFile() ) { 44 | 45 | // resolve date info for current file 46 | let date = new Date( fileStats.birthtime || null ); 47 | let month = utils.leftPad( date.getMonth() + 1, 2, '0' ); 48 | let day = utils.leftPad( date.getDate(), 2, '0' ); 49 | let year = date.getFullYear(); 50 | 51 | // file name options from client 52 | let pre = String( request.payload.name_prefix || '' ).trim(); 53 | let suf = String( request.payload.name_suffix || '' ).trim(); 54 | let sep = String( request.payload.name_separator || '_' ); 55 | 56 | // resolve file extension, type, name and path 57 | let randStr = utils.randString( 10 ); 58 | let fileExt = name.split( '.' ).pop().toLowerCase(); 59 | let fileType = String( mime.getType( fileExt ) || 'file/file' ).split( '/' ).shift(); 60 | let newPath = fpath; 61 | let newName = []; 62 | 63 | if ( pre ) newName.push( pre ); 64 | newName.push( fileType ); 65 | newName.push( year ); 66 | newName.push( month ); 67 | newName.push( day ); 68 | newName.push( randStr ); 69 | if ( suf ) newName.push( suf ); 70 | 71 | // final file name 72 | newName = newName.join( sep ) +'.'+ fileExt; 73 | 74 | // create type subfolder 75 | if ( request.payload.sub_type ) { 76 | newPath += '/'+ fileType; 77 | } 78 | // create year subfolder 79 | if ( request.payload.sub_year ) { 80 | newPath += '/'+ year; 81 | } 82 | // create extension subfolder 83 | if ( request.payload.sub_extension ) { 84 | newPath += '/'+ fileExt; 85 | } 86 | // final file path 87 | newPath += '/'+ newName; 88 | 89 | // move/rename 90 | fs.moveSync( filePath, newPath, { overwrite: true } ); 91 | if ( fs.existsSync( newPath ) ) count++; 92 | } 93 | }); 94 | return reply( Success( 200, 'Done, '+ utils.getNoun( count, 'file', 'files' ) +' renamed.' ) ); 95 | } 96 | } 97 | }); 98 | 99 | /** 100 | * Recursive clean files within a root path based on incoming options 101 | */ 102 | routes.push({ 103 | method: 'POST', 104 | path: '/clean-junk', 105 | config: { 106 | handler: ( request, reply ) => { 107 | let p = request.payload || {}; 108 | 109 | if ( !p.path ) { 110 | return reply( Boom.badRequest( 'Must provide a folder path.' ) ); 111 | } 112 | let fpath = utils.fixPath( p.path ); 113 | let count = 0; 114 | 115 | // delete empty folders 116 | if ( request.payload.empty_folders ) { 117 | Scanner.scanEmptyFolders( fpath, ( item, stats ) => { 118 | fs.removeSync( item ); 119 | if ( !fs.existsSync( item ) ) count++; 120 | }); 121 | } 122 | // scan all sub-files and check against reglist defined at the top 123 | Scanner.scanFiles( fpath, ( item, stats ) => { 124 | let name = path.basename( item ); 125 | Object.keys( reglist ).forEach( ( key ) => { 126 | if ( request.payload[ key ] && reglist[ key ].test( name ) ) { 127 | fs.removeSync( item ); 128 | if ( !fs.existsSync( item ) ) count++; 129 | } 130 | }); 131 | }); 132 | return reply( Success( 200, 'Done, '+ utils.getNoun( count, 'item', 'items' ) +' deleted.' ) ); 133 | } 134 | } 135 | }); 136 | 137 | /** 138 | * Delete thumbnail data managed by this app 139 | */ 140 | routes.push({ 141 | method: 'POST', 142 | path: '/clean-thumbs', 143 | config: { 144 | handler: ( request, reply ) => { 145 | 146 | fs.remove( config.storage.thumbs, err => { 147 | if ( err ) return reply( Boom.badImplementation( err ) ); 148 | return reply( Success( 200, 'Thumbnail database cache has been deleted.' ) ); 149 | }) 150 | } 151 | } 152 | }); 153 | 154 | // export routes 155 | module.exports = routes; 156 | -------------------------------------------------------------------------------- /server/routes/transfers.js: -------------------------------------------------------------------------------- 1 | /** 2 | * Handles file uploads and downloads 3 | */ 4 | const fs = require( 'fs' ); 5 | const md5 = require( 'md5' ); 6 | const mime = require( 'mime' ); 7 | const levelup = require( 'levelup' ); 8 | const leveldown = require( 'leveldown' ); 9 | const sharp = require( 'sharp' ); 10 | const Path = require( 'path' ); 11 | const Boom = require( 'boom' ); 12 | const Success = require( '../modules/success' ); 13 | const config = require( '../../common/config' ); 14 | const utils = require( '../../common/utils' ); 15 | const routes = []; 16 | 17 | // sharp lib options 18 | sharp.cache( false ); // don't lock original files 19 | sharp.simd( true ); // better scaling performance on some CPUs 20 | 21 | /** 22 | * Read file data from disk to be streamed 23 | * @string {path}: Full path to a file on a device to stream 24 | * @return {object}: HTTP response file data 25 | */ 26 | routes.push({ 27 | method: 'GET', 28 | path: '/open', 29 | config: { 30 | handler: ( request, reply ) => { 31 | let p = request.query || {}; 32 | let file = utils.fixPath( p.file || '' ); 33 | 34 | if ( !file ) return reply( Boom.badRequest( 'Must provide a file path.' ) ); 35 | reply.file( file, { mode: 'inline', etagMethod: 'simple', confine: false } ); 36 | } 37 | } 38 | }); 39 | 40 | /** 41 | * Read file data from disk to be downloaded 42 | * @string {path}: Full path to a file on a device to downloaded 43 | * @return {object}: HTTP response file data 44 | */ 45 | routes.push({ 46 | method: 'GET', 47 | path: '/download', 48 | config: { 49 | handler: ( request, reply ) => { 50 | let p = request.query || {}; 51 | let file = utils.fixPath( p.file || '' ); 52 | 53 | if ( !file ) return reply( Boom.badRequest( 'Must provide a file path.' ) ); 54 | reply.file( file, { mode: 'attachment', etagMethod: 'simple', confine: false } ); 55 | } 56 | } 57 | }); 58 | 59 | /** 60 | * Save single file being uploaded to a path 61 | * @string {path}: Full path to a folder on a device to save to 62 | * @string {file}: File form-data stream to be saved 63 | * @return {object}: HTTP response object with saved file name 64 | */ 65 | routes.push({ 66 | method: 'POST', 67 | path: '/upload', 68 | config: { 69 | payload: { 70 | output: 'stream', 71 | allow: 'multipart/form-data', 72 | maxBytes: 1024 * 1024 * 10, // 10 Mb 73 | parse: true, 74 | }, 75 | handler: ( request, reply ) => { 76 | let data = request.payload; 77 | 78 | if ( !data.path ) { 79 | return reply( Boom.badRequest( 'Must provide a folder path.' ) ); 80 | } 81 | if ( !data.file || !data.file.hapi || !data.file.hapi.filename ) { 82 | return reply( Boom.badRequest( 'Must send a valid file to be saved.' ) ); 83 | } 84 | let name = utils.stripName( data.file.hapi.filename ); 85 | let fpath = utils.fixPath( data.path +'/'+ name ); 86 | let file = fs.createWriteStream( fpath ); 87 | 88 | file.on( 'error', ( err ) => { reply( Boom.badImplementation( err ) ) } ); 89 | 90 | data.file.on( 'end', ( err ) => { 91 | if ( err ) return reply( Boom.badImplementation( err ) ); 92 | return reply( Success( 200, 'File saved successfully', { name } ) ); 93 | }); 94 | data.file.pipe( file ); 95 | } 96 | } 97 | }); 98 | 99 | /** 100 | * Create, cache and return thumbnails for a list of items. 101 | * @string {list}: List that includes a path for each image to process 102 | * @return {object}: HTTP response object with thumb list data 103 | */ 104 | routes.push({ 105 | method: 'POST', 106 | path: '/thumbs', 107 | config: { 108 | handler: ( request, reply ) => { 109 | let p = request.payload || {}; 110 | let list = p.list || []; 111 | 112 | if ( !Array.isArray( list ) || !list.length ) { 113 | return reply( Boom.badRequest( 'Must provide a list of image paths to be processed.' ) ); 114 | } 115 | // image processing options 116 | let plist = []; 117 | let cap_w = config.thumbs.maxWidth || 200; 118 | let cap_h = config.thumbs.maxHeight || 100; 119 | let store = levelup( leveldown( config.storage.thumbs ) ); 120 | 121 | // process image list 122 | for ( let i = 0; i < list.length; ++i ) { 123 | let item = list[ i ]; 124 | let target = utils.fixPath( item.path || '' ); 125 | let ext = target.split( '.' ).pop().toLowerCase(); 126 | 127 | // wrap each item's process into a promise that 128 | // resolves to the index and a final data url string 129 | plist.push( new Promise( resolve => { 130 | 131 | fs.stat( target, ( err, stats ) => { 132 | if ( err ) return resolve(); 133 | if ( !stats.isFile() ) return resolve(); 134 | if ( !config.thumbs.types.test( ext ) ) return resolve(); 135 | 136 | let image = sharp( target ); 137 | 138 | image.metadata( ( err, md ) => { 139 | if ( err ) return resolve(); 140 | if ( md.height > cap_h ) { image.resize( null, cap_h ); } else 141 | if ( md.width > cap_w ) { image.resize( cap_w, null ); } 142 | 143 | image.jpeg( config.thumbs.jpeg ).toBuffer( ( err, data, info ) => { 144 | if ( err ) return resolve(); 145 | let thumb = 'data:image/jpeg;base64,'+ data.toString( 'base64' ); 146 | list[ i ].thumb = thumb; 147 | store.put( md5( target ), thumb, err => { resolve(); } ); 148 | }); 149 | }); 150 | }); 151 | })); 152 | } 153 | 154 | // wait for all items to resolve and send final response 155 | Promise.all( plist ).then( () => { 156 | store.close(); 157 | reply( Success( 200, 'Success.', { list } ) ); 158 | }); 159 | } 160 | } 161 | }); 162 | 163 | // export routes 164 | module.exports = routes; 165 | -------------------------------------------------------------------------------- /client/components/Options.vue: -------------------------------------------------------------------------------- 1 | 62 | 63 | 142 | 143 | 158 | -------------------------------------------------------------------------------- /client/components/App.vue: -------------------------------------------------------------------------------- 1 | 25 | 26 | 208 | 209 | 222 | -------------------------------------------------------------------------------- /client/scss/_fontello.scss: -------------------------------------------------------------------------------- 1 | // glyph icon fonts 2 | @font-face { 3 | font-family: 'fontello'; 4 | font-weight: normal; 5 | font-style: normal; 6 | src: url( '/fonts/fontello.woff2' ) format( 'woff2' ), 7 | url( '/fonts/fontello.woff' ) format( 'woff' ), 8 | url( '/fonts/fontello.ttf' ) format( 'truetype' ); 9 | } 10 | 11 | // spin animation 12 | @keyframes iconSpin { 13 | 0% { transform: rotate( 0deg ); } 14 | 100% { transform: rotate( 359deg ); } 15 | } 16 | 17 | // base icon class 18 | [class^="icon-"]:before, 19 | [class*=" icon-"]:before { 20 | display: inline-block; 21 | margin: 0; 22 | padding: 0; 23 | width: 1em; 24 | font-family: 'fontello'; 25 | font-style: normal; 26 | font-weight: normal; 27 | font-variant: normal; 28 | text-align: center; 29 | text-decoration: inherit; 30 | text-transform: none; 31 | outline: none; 32 | speak: none; 33 | -webkit-font-smoothing: antialiased; 34 | -moz-osx-font-smoothing: grayscale; 35 | } 36 | 37 | // spin animation 38 | .icon-spin:before { 39 | margin: 0 0.2em; 40 | transition: none; 41 | animation: iconSpin 2s infinite linear; 42 | } 43 | 44 | // spin animation 45 | .icon-zoom:before { 46 | transition: transform $fxSpeed $fxEaseBounce; 47 | } 48 | .icon-zoom:hover::before { 49 | transform: scale( 2 ); 50 | } 51 | 52 | // push right 53 | .icon-pr:before { 54 | margin-right: 0.3em; 55 | } 56 | 57 | // push left 58 | .icon-pl:before { 59 | margin-left: 0.3em; 60 | } 61 | 62 | // add pseudo icon element as fixed background 63 | .icon-bg:before { 64 | display: block; 65 | position: absolute; 66 | font-size: calc( 100vh - 100px ); 67 | pointer-events: none; 68 | transition: none; 69 | transform: translateX( -50% ) translateY( -50% ) rotate( 10deg ); 70 | left: 50%; 71 | top: 50%; 72 | opacity: 0.04; 73 | z-index: -1; 74 | } 75 | 76 | // icon set 77 | .icon-heart:before { content: '\e800'; } /* '' */ 78 | .icon-check:before { content: '\e801'; } /* '' */ 79 | .icon-close:before { content: '\e802'; } /* '' */ 80 | .icon-plus:before { content: '\e803'; } /* '' */ 81 | .icon-edit:before { content: '\e804'; } /* '' */ 82 | .icon-folder:before { content: '\e805'; } /* '' */ 83 | .icon-settings:before { content: '\e806'; } /* '' */ 84 | .icon-down:before { content: '\e807'; } /* '' */ 85 | .icon-left:before { content: '\e808'; } /* '' */ 86 | .icon-right:before { content: '\e809'; } /* '' */ 87 | .icon-up:before { content: '\e80a'; } /* '' */ 88 | .icon-clock:before { content: '\e80b'; } /* '' */ 89 | .icon-home:before { content: '\e80c'; } /* '' */ 90 | .icon-audio:before { content: '\e80d'; } /* '' */ 91 | .icon-angry:before { content: '\e80e'; } /* '' */ 92 | .icon-sad:before { content: '\e80f'; } /* '' */ 93 | .icon-tag:before { content: '\e810'; } /* '' */ 94 | .icon-help:before { content: '\e811'; } /* '' */ 95 | .icon-happy:before { content: '\e812'; } /* '' */ 96 | .icon-warn:before { content: '\e813'; } /* '' */ 97 | .icon-grid:before { content: '\e814'; } /* '' */ 98 | .icon-list:before { content: '\e815'; } /* '' */ 99 | .icon-zoom-in:before { content: '\e816'; } /* '' */ 100 | .icon-zoom-out:before { content: '\e817'; } /* '' */ 101 | .icon-star:before { content: '\e818'; } /* '' */ 102 | .icon-image:before { content: '\e819'; } /* '' */ 103 | .icon-camera:before { content: '\e81a'; } /* '' */ 104 | .icon-visible:before { content: '\e81b'; } /* '' */ 105 | .icon-hidden:before { content: '\e81c'; } /* '' */ 106 | .icon-loader:before { content: '\e832'; } /* '' */ 107 | .icon-down-open:before { content: '\f004'; } /* '' */ 108 | .icon-up-open:before { content: '\f005'; } /* '' */ 109 | .icon-right-open:before { content: '\f006'; } /* '' */ 110 | .icon-left-open:before { content: '\f007'; } /* '' */ 111 | .icon-menu:before { content: '\f008'; } /* '' */ 112 | .icon-launcher:before { content: '\f00b'; } /* '' */ 113 | .icon-pause:before { content: '\f00e'; } /* '' */ 114 | .icon-play:before { content: '\f00f'; } /* '' */ 115 | .icon-next:before { content: '\f010'; } /* '' */ 116 | .icon-previous:before { content: '\f011'; } /* '' */ 117 | .icon-network:before { content: '\f017'; } /* '' */ 118 | .icon-send:before { content: '\f01d'; } /* '' */ 119 | .icon-reload:before { content: '\f025'; } /* '' */ 120 | .icon-return:before { content: '\f02a'; } /* '' */ 121 | .icon-login:before { content: '\f02c'; } /* '' */ 122 | .icon-logout:before { content: '\f02d'; } /* '' */ 123 | .icon-download:before { content: '\f02e'; } /* '' */ 124 | .icon-upload:before { content: '\f02f'; } /* '' */ 125 | .icon-location:before { content: '\f031'; } /* '' */ 126 | .icon-desktop:before { content: '\f032'; } /* '' */ 127 | .icon-mobile:before { content: '\f033'; } /* '' */ 128 | .icon-device:before { content: '\f035'; } /* '' */ 129 | .icon-video:before { content: '\f03a'; } /* '' */ 130 | .icon-shuffle:before { content: '\f03b'; } /* '' */ 131 | .icon-alert:before { content: '\f03f'; } /* '' */ 132 | .icon-movie:before { content: '\f040'; } /* '' */ 133 | .icon-mute:before { content: '\f047'; } /* '' */ 134 | .icon-unmute:before { content: '\f048'; } /* '' */ 135 | .icon-user:before { content: '\f061'; } /* '' */ 136 | .icon-users:before { content: '\f064'; } /* '' */ 137 | .icon-attach:before { content: '\f06a'; } /* '' */ 138 | .icon-inbox:before { content: '\f070'; } /* '' */ 139 | .icon-unlocked:before { content: '\f075'; } /* '' */ 140 | .icon-link:before { content: '\f07b'; } /* '' */ 141 | .icon-stop:before { content: '\f080'; } /* '' */ 142 | .icon-preview:before { content: '\f082'; } /* '' */ 143 | .icon-trash:before { content: '\f083'; } /* '' */ 144 | .icon-info:before { content: '\f086'; } /* '' */ 145 | .icon-tasks:before { content: '\f0ae'; } /* '' */ 146 | .icon-speed:before { content: '\f0e4'; } /* '' */ 147 | .icon-code:before { content: '\f121'; } /* '' */ 148 | .icon-puzzle:before { content: '\f12e'; } /* '' */ 149 | .icon-file:before { content: '\f15c'; } /* '' */ 150 | .icon-database:before { content: '\f1c0'; } /* '' */ 151 | .icon-file-image:before { content: '\f1c5'; } /* '' */ 152 | .icon-file-zip:before { content: '\f1c6'; } /* '' */ 153 | .icon-file-audio:before { content: '\f1c7'; } /* '' */ 154 | .icon-file-video:before { content: '\f1c8'; } /* '' */ 155 | .icon-file-code:before { content: '\f1c9'; } /* '' */ 156 | .icon-copy:before { content: '\f1e0'; } /* '' */ 157 | .icon-connect:before { content: '\f1e6'; } /* '' */ 158 | .icon-admin:before { content: '\f21b'; } /* '' */ 159 | .icon-server:before { content: '\f233'; } /* '' */ 160 | .icon-usb:before { content: '\f287'; } /* '' */ 161 | .icon-private:before { content: '\f2a8'; } /* '' */ 162 | .icon-search:before { content: '\f50d'; } /* '' */ 163 | .icon-locked:before { content: '\f510'; } /* '' */ 164 | -------------------------------------------------------------------------------- /client/scripts/Polyfills.js: -------------------------------------------------------------------------------- 1 | /** 2 | * String trimming 3 | */ 4 | (function() { 5 | 'use strict'; 6 | 7 | if ( !String.prototype.trim ) { 8 | String.prototype.trim = function() { 9 | return this.replace( /^[\s\uFEFF\xA0]+|[\s\uFEFF\xA0]+$/g, '' ); 10 | }; 11 | } 12 | if ( !String.prototype.trimLeft ){ 13 | String.prototype.trimLeft = function() { 14 | return this.replace( /^[\s\uFEFF\xA0]+/g, '' ); 15 | }; 16 | } 17 | if ( !String.prototype.trimRight ) { 18 | String.prototype.trimRight = function() { 19 | return this.replace( /[\s\uFEFF\xA0]+$/g, '' ); 20 | }; 21 | } 22 | })(); 23 | 24 | /** 25 | * Object.keys 26 | */ 27 | (function() { 28 | if ( typeof Object.keys != 'function' ) { 29 | Object.keys = (function() { 30 | 'use strict'; 31 | 32 | var hasOwnProperty = Object.prototype.hasOwnProperty, 33 | hasDontEnumBug = !({ toString: null }).propertyIsEnumerable( 'toString' ), 34 | dontEnums = ['toString','toLocaleString','valueOf','hasOwnProperty','isPrototypeOf','propertyIsEnumerable','constructor'], 35 | dontEnumsLength = dontEnums.length; 36 | 37 | return function( obj ) { 38 | if ( typeof obj !== 'function' && ( typeof obj !== 'object' || obj === null ) ) { 39 | throw new TypeError( 'Object.keys called on non-object' ); 40 | } 41 | var result = [], prop, i; 42 | 43 | for ( prop in obj ) { 44 | if ( hasOwnProperty.call( obj, prop ) ) { 45 | result.push( prop ); 46 | } 47 | } 48 | if ( hasDontEnumBug ) { 49 | for ( i = 0; i < dontEnumsLength; i++ ) { 50 | if ( hasOwnProperty.call( obj, dontEnums[i] ) ) { 51 | result.push( dontEnums[i] ); 52 | } 53 | } 54 | } 55 | return result; 56 | }; 57 | })(); 58 | } 59 | })(); 60 | 61 | /** 62 | * Object.assign 63 | */ 64 | (function() { 65 | if ( typeof Object.assign != 'function' ) { 66 | Object.assign = (function() { 67 | 'use strict'; 68 | 69 | return function( target ) { 70 | if ( target === undefined || target === null ) { 71 | throw new TypeError( 'Cannot convert undefined or null to object' ); 72 | } 73 | var output = Object( target ); 74 | 75 | for ( var index = 1; index < arguments.length; index++ ) { 76 | var source = arguments[ index ]; 77 | 78 | if ( source !== undefined && source !== null ) { 79 | for ( var nextKey in source ) { 80 | if ( source.hasOwnProperty( nextKey ) ) { 81 | output[ nextKey ] = source[ nextKey ]; 82 | } 83 | } 84 | } 85 | } 86 | return output; 87 | }; 88 | })(); 89 | } 90 | })(); 91 | 92 | /** 93 | * Request Animation Frame 94 | */ 95 | (function() { 96 | 'use strict'; 97 | 98 | if ( typeof window !== 'object' ) return; 99 | 100 | var vendors = ['webkit', 'moz']; 101 | var lastTime = 0; 102 | 103 | for ( var x = 0; x < vendors.length && !window.requestAnimationFrame; ++x ) { 104 | window.requestAnimationFrame = window[ vendors[x] + 'RequestAnimationFrame' ]; 105 | window.cancelAnimationFrame = window[ vendors[x] + 'CancelAnimationFrame' ] || window[ vendors[x] + 'CancelRequestAnimationFrame' ]; 106 | } 107 | if ( !window.requestAnimationFrame ) { 108 | window.requestAnimationFrame = function( callback, element ) { 109 | var currTime = new Date().getTime(); 110 | var timeToCall = Math.max( 0, 16 - ( currTime - lastTime ) ); 111 | var id = window.setTimeout( function() { callback( currTime + timeToCall ); }, timeToCall ); 112 | lastTime = currTime + timeToCall; 113 | return id; 114 | }; 115 | } 116 | if ( !window.cancelAnimationFrame ) { 117 | window.cancelAnimationFrame = function( id ) { 118 | clearTimeout( id ); 119 | }; 120 | } 121 | })(); 122 | 123 | /** 124 | * Element selector match support 125 | */ 126 | (function() { 127 | 'use strict'; 128 | 129 | if ( !Element.prototype.matches ) { 130 | Element.prototype.matches = 131 | Element.prototype.matchesSelector || 132 | Element.prototype.mozMatchesSelector || 133 | Element.prototype.msMatchesSelector || 134 | Element.prototype.oMatchesSelector || 135 | Element.prototype.webkitMatchesSelector || 136 | function( s ) { 137 | var matches = ( this.document || this.ownerDocument ).querySelectorAll( s ), i = matches.length; 138 | while ( --i >= 0 && matches.item( i ) !== this ) {} 139 | return i > -1; 140 | }; 141 | } 142 | })(); 143 | 144 | /** 145 | * Element classList support 146 | */ 147 | (function() { 148 | 'use strict'; 149 | 150 | if ( typeof window !== 'object' ) return; 151 | if ( typeof window.Element === 'undefined' ) return; 152 | if ( 'classList' in document.documentElement ) return; 153 | 154 | var prototype = Array.prototype, 155 | push = prototype.push, 156 | splice = prototype.splice, 157 | join = prototype.join; 158 | 159 | var indexOf = prototype.indexOf || function( item ) { 160 | for ( var i = 0, t = this.length; i < t; ++i ) { 161 | if ( i in this && this[i] === item ) return i; 162 | } 163 | return -1; 164 | }; 165 | 166 | function DOMTokenList( el ) { 167 | this.el = el; 168 | 169 | var classes = String( el.className || '' ).trim().split( /\s+/ ); 170 | 171 | for ( var i = 0, t = classes.length; i < t; ++i ) { 172 | push.call( this, classes[i] ); 173 | } 174 | }; 175 | 176 | DOMTokenList.prototype = { 177 | 178 | item: function( index ) { 179 | return this[ index ] || null; 180 | }, 181 | contains: function( token ) { 182 | return indexOf.call( this, token ) != -1; 183 | }, 184 | add: function( token ) { 185 | if( this.contains( token ) ) return; 186 | push.call( this, token ); 187 | this.el.className = this.toString(); 188 | }, 189 | remove: function( token ) { 190 | var index = indexOf.call( this, token ); 191 | if( index == -1 ) return; 192 | splice.call( this, index, 1 ); 193 | this.el.className = this.toString(); 194 | }, 195 | toggle: function( token ) { 196 | var index = indexOf.call( this, token ); 197 | if( index == -1 ){ push.call( this, token ); } 198 | else{ splice.call( this, index, 1 ); } 199 | this.el.className = this.toString(); 200 | }, 201 | toString: function() { 202 | return join.call( this, ' ' ); 203 | }, 204 | }; 205 | 206 | window.DOMTokenList = DOMTokenList; 207 | 208 | function defineElementGetter( obj, prop, getter ) { 209 | if( Object.defineProperty ) { 210 | Object.defineProperty( obj, prop, { get: getter } ); 211 | } else { 212 | obj.__defineGetter__( prop, getter ); 213 | } 214 | } 215 | defineElementGetter( Element.prototype, 'classList', function() { 216 | return new DOMTokenList( this ); 217 | }); 218 | })(); 219 | 220 | 221 | 222 | 223 | 224 | 225 | 226 | 227 | -------------------------------------------------------------------------------- /client/components/Tabs.vue: -------------------------------------------------------------------------------- 1 | 16 | 17 | 149 | 150 | 264 | -------------------------------------------------------------------------------- /client/components/UploadForm.vue: -------------------------------------------------------------------------------- 1 | 29 | 30 | 169 | 170 | 255 | -------------------------------------------------------------------------------- /client/scss/_modifiers.scss: -------------------------------------------------------------------------------- 1 | 2 | /** 3 | * Color modifiers 4 | */ 5 | @mixin colorImage( $color, $text ) { 6 | background-color: $color !important; 7 | background-image: radial-gradient( circle, rgba( 0, 0, 0, 0 ) 0%, rgba( 0, 0, 0, 0.8 ) 100% ) !important; 8 | background-position: center center !important; 9 | background-repeat: no-repeat !important; 10 | } 11 | 12 | @mixin colorBg( $color, $text, $hover: 0 ) { 13 | background-color: $color !important; 14 | color: $text !important; 15 | @if $hover == 1 { 16 | &:hover { 17 | background-color: darken( $color, 5% ) !important; 18 | color: desaturate( darken( $text, 5% ), 10% ) !important; 19 | } 20 | } 21 | } 22 | 23 | @mixin colorBorder( $color, $hover: 0 ) { 24 | border-color: $color !important; 25 | @if $hover == 1 { 26 | &:hover { 27 | border-color: desaturate( darken( $color, 8% ), 10% ) !important; 28 | } 29 | } 30 | } 31 | 32 | @mixin colorText( $color, $hover: 0 ) { 33 | color: lighten( $color, 5% ) !important; 34 | @if $hover == 1 { 35 | &:hover { 36 | color: darken( $color, 8% ) !important; 37 | } 38 | } 39 | } 40 | 41 | /** 42 | * Radial gradient background image colors 43 | */ 44 | .bg-document-image { @include colorImage( $colorDocument, $colorDocumentText ); } 45 | .bg-input-image { @include colorImage( $colorInput, $colorInputText ); } 46 | .bg-default-image { @include colorImage( $colorDefault, $colorDefaultText ); } 47 | .bg-primary-image { @include colorImage( $colorPrimary, $colorPrimaryText ); } 48 | .bg-secondary-image { @include colorImage( $colorSecondary, $colorSecondaryText ); } 49 | .bg-success-image { @include colorImage( $colorSuccess, $colorSuccessText ); } 50 | .bg-warning-image { @include colorImage( $colorWarning, $colorWarningText ); } 51 | .bg-danger-image { @include colorImage( $colorDanger, $colorDangerText ); } 52 | .bg-info-image { @include colorImage( $colorInfo, $colorInfoText ); } 53 | .bg-grey-image { @include colorImage( $colorGrey, $colorGreyText ); } 54 | .bg-bright-image { @include colorImage( $colorBright, $colorBrightText ); } 55 | 56 | /** 57 | * Solid background colors 58 | */ 59 | .bg-document { @include colorBg( $colorDocument, $colorDocumentText ); } 60 | .bg-input { @include colorBg( $colorInput, $colorInputText ); } 61 | .bg-default { @include colorBg( $colorDefault, $colorDefaultText ); } 62 | .bg-primary { @include colorBg( $colorPrimary, $colorPrimaryText ); } 63 | .bg-secondary { @include colorBg( $colorSecondary, $colorSecondaryText ); } 64 | .bg-success { @include colorBg( $colorSuccess, $colorSuccessText ); } 65 | .bg-warning { @include colorBg( $colorWarning, $colorWarningText ); } 66 | .bg-danger { @include colorBg( $colorDanger, $colorDangerText ); } 67 | .bg-info { @include colorBg( $colorInfo, $colorInfoText ); } 68 | .bg-grey { @include colorBg( $colorGrey, $colorGreyText ); } 69 | .bg-bright { @include colorBg( $colorBright, $colorBrightText ); } 70 | 71 | /** 72 | * Solid background colors with hover/active state 73 | */ 74 | .bg-document-hover { @include colorBg( $colorDocument, $colorDocumentText, 1 ); } 75 | .bg-input-hover { @include colorBg( $colorInput, $colorInputText, 1 ); } 76 | .bg-default-hover { @include colorBg( $colorDefault, $colorDefaultText, 1 ); } 77 | .bg-primary-hover { @include colorBg( $colorPrimary, $colorPrimaryText, 1 ); } 78 | .bg-secondary-hover { @include colorBg( $colorSecondary, $colorSecondaryText, 1 ); } 79 | .bg-success-hover { @include colorBg( $colorSuccess, $colorSuccessText, 1 ); } 80 | .bg-warning-hover { @include colorBg( $colorWarning, $colorWarningText, 1 ); } 81 | .bg-danger-hover { @include colorBg( $colorDanger, $colorDangerText, 1 ); } 82 | .bg-info-hover { @include colorBg( $colorInfo, $colorInfoText, 1 ); } 83 | .bg-grey-hover { @include colorBg( $colorGrey, $colorGreyText, 1 ); } 84 | .bg-bright-hover { @include colorBg( $colorBright, $colorBrightText, 1 ); } 85 | 86 | /** 87 | * Solid border colors 88 | */ 89 | .border-document { @include colorBorder( $colorDocument ); } 90 | .border-input { @include colorBorder( $colorInput ); } 91 | .border-default { @include colorBorder( $colorDefault ); } 92 | .border-primary { @include colorBorder( $colorPrimary ); } 93 | .border-secondary { @include colorBorder( $colorSecondary ); } 94 | .border-success { @include colorBorder( $colorSuccess ); } 95 | .border-warning { @include colorBorder( $colorWarning ); } 96 | .border-danger { @include colorBorder( $colorDanger ); } 97 | .border-info { @include colorBorder( $colorInfo ); } 98 | .border-grey { @include colorBorder( $colorGrey ); } 99 | .border-bright { @include colorBorder( $colorBright ); } 100 | 101 | /** 102 | * Solid border colors with hover/active state 103 | */ 104 | .border-document-hover { @include colorBorder( $colorDocument, 1 ); } 105 | .border-input-hover { @include colorBorder( $colorInput, 1 ); } 106 | .border-default-hover { @include colorBorder( $colorDefault, 1 ); } 107 | .border-primary-hover { @include colorBorder( $colorPrimary, 1 ); } 108 | .border-secondary-hover { @include colorBorder( $colorSecondary, 1 ); } 109 | .border-success-hover { @include colorBorder( $colorSuccess, 1 ); } 110 | .border-warning-hover { @include colorBorder( $colorWarning, 1 ); } 111 | .border-danger-hover { @include colorBorder( $colorDanger, 1 ); } 112 | .border-info-hover { @include colorBorder( $colorInfo, 1 ); } 113 | .border-grey-hover { @include colorBorder( $colorGrey, 1 ); } 114 | .border-bright-hover { @include colorBorder( $colorBright, 1 ); } 115 | 116 | /** 117 | * Solid text colors 118 | */ 119 | .text-document { @include colorText( $colorDocument ); } 120 | .text-input { @include colorText( $colorInput ); } 121 | .text-default { @include colorText( $colorDefault ); } 122 | .text-primary { @include colorText( $colorPrimary ); } 123 | .text-secondary { @include colorText( $colorSecondary ); } 124 | .text-success { @include colorText( $colorSuccess ); } 125 | .text-warning { @include colorText( $colorWarning ); } 126 | .text-danger { @include colorText( $colorDanger ); } 127 | .text-info { @include colorText( $colorInfo ); } 128 | .text-grey { @include colorText( $colorGrey ); } 129 | .text-bright { @include colorText( $colorBright ); } 130 | 131 | /** 132 | * Solid text colors with hover/active state 133 | */ 134 | .text-document-hover { @include colorText( $colorDocument, 1 ); } 135 | .text-input-hover { @include colorText( $colorInput, 1 ); } 136 | .text-default-hover { @include colorText( $colorDefault, 1 ); } 137 | .text-primary-hover { @include colorText( $colorPrimary, 1 ); } 138 | .text-secondary-hover { @include colorText( $colorSecondary, 1 ); } 139 | .text-success-hover { @include colorText( $colorSuccess, 1 ); } 140 | .text-warning-hover { @include colorText( $colorWarning, 1 ); } 141 | .text-danger-hover { @include colorText( $colorDanger, 1 ); } 142 | .text-info-hover { @include colorText( $colorInfo, 1 ); } 143 | .text-grey-hover { @include colorText( $colorGrey, 1 ); } 144 | .text-bright-hover { @include colorText( $colorBright, 1 ); } 145 | -------------------------------------------------------------------------------- /server/user.js: -------------------------------------------------------------------------------- 1 | /** 2 | * User script for managing user accounts. 3 | * Usage: node ./user.js 4 | */ 5 | const fs = require( 'fs-extra' ); 6 | const md5 = require( 'md5' ); 7 | const Bcrypt = require( 'bcrypt' ); 8 | const Users = require( './modules/users' ); 9 | const config = require( '../common/config' ); 10 | const utils = require( '../common/utils' ); 11 | 12 | // params 13 | const action = process.argv[2] || ''; // what to do 14 | const userinfo = process.argv[3] || ''; // id or username depending on action 15 | const password = process.argv[4] || ''; // plain text password to be hashed 16 | 17 | // log messages 18 | const logger = ( message ) => { 19 | console.log( message ); 20 | }; 21 | 22 | // log error message 23 | const logError = ( message ) => { 24 | logger( '[!] '+ message ); 25 | }; 26 | 27 | // log info message 28 | const logInfo = ( message ) => { 29 | logger( '[-] '+ message ); 30 | }; 31 | 32 | // log error message 33 | const logSuccess = ( message ) => { 34 | logger( '[+] '+ message ); 35 | }; 36 | 37 | // log header message 38 | const logHeader = () => { 39 | logger( ' ' ); 40 | logger( '-'.repeat( 50 ) ); 41 | logInfo( 'Welcome to the user manager script.' ); 42 | }; 43 | 44 | // log help message 45 | const logHelp = () => { 46 | logHeader(); 47 | logInfo( 'Usage: ./user.js [action] [id/username] [password]' ); 48 | logInfo( ' ' ); 49 | logInfo( 'action: list - List all [id] and [username] user entries.' ); 50 | logInfo( 'action: create - Create new user entry providing a [username] and [password].' ); 51 | logInfo( 'action: update - Update existing user entry providing an [id] and new [password].' ); 52 | logInfo( 'action: fetch - Load data for an user providing an [id] or [username].' ); 53 | logInfo( 'action: delete - Delete existing user entry providing an [id].' ); 54 | logInfo( 'action: flush - Remove all user entries from database.' ); 55 | logger( ' ' ); 56 | }; 57 | 58 | // list all users 59 | const listUsers = () => { 60 | logHeader(); 61 | logInfo( 'Listing all available users...' ); 62 | 63 | Users.list( ( err, rows ) => { 64 | if ( err ) return logError( err.message || 'Problem listing users data.' ); 65 | if ( !rows.length ) return logInfo( 'No user entries found.' ); 66 | logInfo( 'Total user entries found: '+ rows.length ); 67 | 68 | for ( let i = 0; i < rows.length; ++i ) { 69 | console.log( '\n', '-'.repeat( 32 ) ); 70 | console.log( rows[ i ] ); 71 | } 72 | }); 73 | }; 74 | 75 | // fetch user data 76 | const fetchUser = ( idname ) => { 77 | logHeader(); 78 | logInfo( 'Fetching user data for ('+ idname +')...' ); 79 | 80 | if ( !idname ) return logError( 'Must provide an id or username!' ); 81 | 82 | if ( /^([a-f0-9]{32})$/.test( idname ) ) { // md5 hash id 83 | return Users.fetchById( idname, ( err, data ) => { 84 | if ( err ) return logError( err.message || 'Problem finding user entry by id ('+ idname +').' ); 85 | if ( !data ) return logError( 'Could not find user by id ('+ idname +').' ); 86 | logSuccess( 'User data for ('+ data.username +'):' ); 87 | console.log( '\n', '-'.repeat( 32 ) ); 88 | console.log( data ); 89 | }); 90 | } 91 | return Users.fetchByName( idname, ( err, data ) => { 92 | if ( err ) return logError( err.message || 'Problem finding user entry by username ('+ idname +').' ); 93 | if ( !data ) return logError( 'Could not find user by username ('+ idname +').' ); 94 | logSuccess( 'User data for ('+ data.username +'):' ); 95 | console.log( '\n', '-'.repeat( 32 ) ); 96 | console.log( data ); 97 | }); 98 | }; 99 | 100 | // create or update user 101 | const createUser = ( username, password ) => { 102 | logHeader(); 103 | logInfo( 'Inserting new password for user ('+ username +')...' ); 104 | 105 | if ( !username ) return logError( 'Must provide a username!' ); 106 | if ( !password ) return logError( 'Must provide a new password!' ); 107 | 108 | Bcrypt.hash( password, 10, ( err, hash ) => { 109 | if ( err || !hash ) return logError( err.message || 'Failed to create password hash.' ); 110 | 111 | let now = Date.now(); 112 | let userid = md5( utils.randString( 30 ) +'-'+ now ); // one-time unique id 113 | let userdata = { 114 | id: userid, 115 | created: now, 116 | modified: 0, 117 | image: '', 118 | name: username, 119 | username: username, 120 | password: hash.toString(), 121 | options: {}, 122 | }; 123 | 124 | Users.list( ( err, rows ) => { 125 | for ( let i = 0; i < rows.length; ++i ) { 126 | if ( rows[ i ].username === username ) { 127 | return logError( 'This username is already taken ('+ username +').' ); 128 | } 129 | } 130 | Users.create( userid, userdata, ( err, data ) => { 131 | if ( err ) return logError( err.message || 'Failed to create new user entry for ('+ username +').' ); 132 | logSuccess( 'Created new user account for ('+ username +').' ); 133 | }); 134 | }); 135 | }); 136 | }; 137 | 138 | // update user 139 | const updateUser = ( id, password ) => { 140 | logHeader(); 141 | logInfo( 'Update existing user by id ('+ id +')...' ); 142 | 143 | if ( !id ) return logError( 'Must provide the user id!' ); 144 | if ( !password ) return logError( 'Must provide a new password!' ); 145 | 146 | Bcrypt.hash( password, 10, ( err, hash ) => { 147 | if ( err || !hash ) return logError( err.message || 'Failed to create password hash.' ); 148 | 149 | let newdata = { 150 | modified: Date.now(), 151 | password: hash.toString(), 152 | }; 153 | Users.update( id, newdata, ( err, data ) => { 154 | if ( err ) return logError( err.message || 'Failed to write data to store.' ); 155 | logSuccess( 'Updated user account for ('+ data.username +').' ); 156 | }); 157 | }); 158 | }; 159 | 160 | // delete user 161 | const deleteUser = ( id ) => { 162 | logHeader(); 163 | logInfo( 'Removing existing user by id ('+ id +')...' ); 164 | 165 | if ( !id ) return logError( 'Must provide an id!' ); 166 | 167 | Users.delete( id, ( err, status ) => { 168 | if ( err || !status ) return logError( err.message || 'Failed to remove user ('+ id +').' ); 169 | logSuccess( 'Removed user ('+ id +') form database.' ); 170 | }); 171 | }; 172 | 173 | // flush all data 174 | const flushData = () => { 175 | logHeader(); 176 | logInfo( 'Removing all users from database...' ); 177 | 178 | Users.flush( ( err, total ) => { 179 | if ( err ) return logError( err.message || 'Problem flushing users data.' ); 180 | if ( !total ) return logInfo( 'No user entries found.' ); 181 | logInfo( 'Done, removed '+ utils.getNoun( total, 'entry', 'entries' ) +' from database.' ); 182 | }); 183 | }; 184 | 185 | // check storage folder, create if needed 186 | const checkStorage = () => { 187 | let userStore = utils.fixPath( config.storage.users ); 188 | let storeDir = userStore.split( '/' ).slice( 0, -1 ).join( '/' ); 189 | 190 | fs.ensureDir( storeDir, err => { 191 | if ( err ) { 192 | logError( 'Could not create app storage folder: '+ storeDir ); 193 | logError( 'This folder is required for app and user data.' ); 194 | return; 195 | } 196 | switch ( action ) { 197 | case 'list': return listUsers(); 198 | case 'create': return createUser( userinfo, password ); 199 | case 'update': return updateUser( userinfo, password ); 200 | case 'fetch': return fetchUser( userinfo ); 201 | case 'delete': return deleteUser( userinfo ); 202 | case 'flush': return flushData(); 203 | default: return logHelp(); 204 | } 205 | }); 206 | } 207 | 208 | // go 209 | checkStorage(); 210 | -------------------------------------------------------------------------------- /client/components/MovieForm.vue: -------------------------------------------------------------------------------- 1 | 64 | 65 | 223 | 224 | -------------------------------------------------------------------------------- /client/scripts/Tooltip.js: -------------------------------------------------------------------------------- 1 | /** 2 | * Tooltips Class. 3 | * Adds custom tooltips to elements on the page. 4 | */ 5 | import Viewport from './Viewport'; 6 | 7 | export default class Tooltip { 8 | 9 | // class constructor 10 | constructor( options ) { 11 | this._options = Object.assign({ 12 | // class to apply to tooltip element 13 | tipClass : 'tooltip-wrap', 14 | // class to apply when tooltip is placed on the left 15 | leftClass : 'tooltip-left', 16 | // class to apply when tooltip is placed on the right 17 | rightClass : 'tooltip-right', 18 | // class to apply when tooltip is placed on the top 19 | topClass : 'tooltip-top', 20 | // class to apply when tooltip is placed on the bottom 21 | bottomClass : 'tooltip-bottom', 22 | // delay to show the tooltip 23 | showDelay : 100, 24 | // auto hide delay 25 | hideDelay: 2000, 26 | // ... 27 | }, options ); 28 | 29 | this._tooltip = null; 30 | this._hovItem = null; 31 | this._timeout = null; 32 | this._autohide = null; 33 | this._visible = false; 34 | this._elements = []; 35 | this._onScroll = this._onScroll.bind( this ); 36 | this._init(); 37 | } 38 | 39 | // set target elements 40 | select( selector ) { 41 | if ( typeof selector === 'string' ) { 42 | this._elements = document.querySelectorAll( selector ) || []; 43 | } 44 | else if ( typeof selector === 'object' && selector instanceof Element ) { 45 | this._elements.push( selector ); 46 | } 47 | for ( let i = 0; i < this._elements.length; ++i ) { 48 | this._setupItem( this._elements[ i ] ); 49 | } 50 | this._hideTooltip(); 51 | } 52 | 53 | // remove element from the list 54 | unselect( element ) { 55 | if ( typeof element === 'object' && element instanceof Element ) { 56 | for ( let i = 0, t = this._elements.length; i < t; ++i ) { 57 | if ( this._elements[ i ] === element ) { 58 | this._resetItem( this._elements[ i ] ); 59 | this._elements.splice( i, 1 ); 60 | break; 61 | } 62 | } 63 | this._hideTooltip(); 64 | } 65 | } 66 | 67 | // cleanup this instance 68 | destroy() { 69 | for ( let i = 0; i < this._elements.length; ++i ) { 70 | this._resetItem( this._elements[ i ] ); 71 | } 72 | if ( document.body.contains( this._tooltip ) ) { 73 | document.body.removeChild( this._tooltip ); 74 | } 75 | window.removeEventListener( 'scroll', this._onScroll ); 76 | this._elements = []; 77 | this._tooltip = null; 78 | } 79 | 80 | // initlaize elements 81 | _init() { 82 | this._tooltip = document.createElement( 'div' ); 83 | this._tooltip.className = this._options.tipClass; 84 | this._tooltip.style['display'] = 'block'; 85 | this._tooltip.style['position'] = 'absolute'; 86 | this._tooltip.style['pointer-events'] = 'none'; 87 | this._hideTooltip(); 88 | document.body.appendChild( this._tooltip ); 89 | window.addEventListener( 'scroll', this._onScroll ); 90 | } 91 | 92 | // set an element to have tooltip, if not alredy setup 93 | _setupItem( item ) { 94 | if ( item && item instanceof Element ) { 95 | if ( item.hasAttribute( 'title' ) ) { 96 | item.setAttribute( 'data-tip', item.getAttribute( 'title' ) || '' ); 97 | item.removeAttribute( 'title' ); 98 | item.addEventListener( 'mouseenter', e => { this._onEnter( e ); } ); 99 | item.addEventListener( 'touchstart', e => { this._onEnter( e ); } ); 100 | item.addEventListener( 'mouseleave', e => { this._onLeave( e ); } ); 101 | item.addEventListener( 'touchend', e => { this._onLeave( e ); } ); 102 | } 103 | } 104 | } 105 | 106 | // remove tooltip events from element, if needed 107 | _resetItem( item ) { 108 | if ( item && item instanceof Element ) { 109 | if ( item.hasAttribute( 'data-tip' ) ) { 110 | item.setAttribute( 'title', item.getAttribute( 'data-tip' ) || '' ); 111 | item.removeAttribute( 'data-tip' ); 112 | item.removeEventListener( 'mouseenter', e => { this._onEnter( e ); } ); 113 | item.removeEventListener( 'touchstart', e => { this._onEnter( e ); } ); 114 | item.removeEventListener( 'mouseleave', e => { this._onLeave( e ); } ); 115 | item.removeEventListener( 'touchend', e => { this._onLeave( e ); } ); 116 | } 117 | } 118 | } 119 | 120 | // decides where to place the tooltip in relation to item and screen bounds 121 | _showTooltip() { 122 | if ( this._tooltip && this._hovItem ) { 123 | 124 | let box = this._hovItem.getBoundingClientRect(), 125 | centerX = box.left + ( this._hovItem.offsetWidth - this._tooltip.offsetWidth ) / 2, 126 | centerY = box.top + ( this._hovItem.offsetHeight - this._tooltip.offsetHeight ) / 2, 127 | leftPos = box.left - this._tooltip.offsetWidth, 128 | rightPos = box.left + this._hovItem.offsetWidth, 129 | topPos = box.top - this._tooltip.offsetHeight, 130 | bottomPos = box.top + this._hovItem.offsetHeight, 131 | tipHalf = this._tooltip.offsetWidth / 2, 132 | clss = this._options.topClass, 133 | left = centerX, 134 | top = topPos; 135 | 136 | // move to the right 137 | if ( box.left < tipHalf ) { 138 | clss = this._options.rightClass; 139 | left = rightPos; 140 | top = centerY; 141 | } 142 | // move to the left 143 | else if ( ( Viewport.clientWidth() - rightPos ) < tipHalf ) { 144 | clss = this._options.leftClass; 145 | left = leftPos; 146 | top = centerY; 147 | } 148 | // move to the bottom 149 | else if( topPos < 0 ) { 150 | clss = this._options.bottomClass; 151 | left = centerX; 152 | top = bottomPos; 153 | } 154 | if ( left > 1 && top > 1 && this._tooltip.innerHTML ) { 155 | this._tooltip.className = this._options.tipClass + ' ' + clss; 156 | this._tooltip.style['left'] = ( Viewport.scrollLeft() + left ) +'px'; 157 | this._tooltip.style['top'] = ( Viewport.scrollTop() + top ) +'px'; 158 | this._tooltip.style['z-index'] = '666'; 159 | this._visible = true; 160 | } 161 | } 162 | } 163 | 164 | // move tooltip object off screen, reset content and class 165 | _hideTooltip() { 166 | if ( this._tooltip ) { 167 | this._tooltip.innerHTML = ''; 168 | this._tooltip.className = this._options.tipClass; 169 | this._tooltip.style['left'] = '-1000px'; 170 | this._tooltip.style['top'] = '-1000px'; 171 | this._tooltip.style['z-index'] = '-666'; 172 | this._visible = false; 173 | } 174 | } 175 | 176 | // when mouse enters target element 177 | _onEnter( e ) { 178 | let item = e.target; 179 | let title = item.getAttribute( 'data-tip' ); 180 | 181 | if ( title ) { 182 | this._hovItem = item; 183 | this._tooltip.innerHTML = title; 184 | 185 | if ( this._timeout ) clearTimeout( this._timeout ); 186 | this._timeout = setTimeout( this._showTooltip.bind( this ), this._options.showDelay ); 187 | 188 | if ( this._autohide ) clearTimeout( this._autohide ); 189 | this._autohide = setTimeout( this._hideTooltip.bind( this ), this._options.hideDelay ); 190 | } 191 | } 192 | 193 | // when mouse leaves target element 194 | _onLeave( e ) { 195 | if ( this._timeout ) { 196 | clearTimeout( this._timeout ); 197 | this._timeout = null; 198 | } 199 | this._hovItem = null; 200 | this._hideTooltip(); 201 | } 202 | 203 | // hide tooltip over fixed elements when scrolled 204 | _onScroll( e ) { 205 | if ( this._visible ) { 206 | this._hideTooltip(); 207 | } 208 | } 209 | 210 | } 211 | -------------------------------------------------------------------------------- /client/components/SideBar.vue: -------------------------------------------------------------------------------- 1 | 69 | 70 | 139 | 140 | 229 | --------------------------------------------------------------------------------