diff --git a/.editorconfig b/.editorconfig new file mode 100644 index 0000000..0020fc0 --- /dev/null +++ b/.editorconfig @@ -0,0 +1,5 @@ +root = true + +[*] +indent_style = space +indent_size = 2 diff --git a/.gitignore b/.gitignore index c5ea0d4..fd500f3 100644 --- a/.gitignore +++ b/.gitignore @@ -1,3 +1,6 @@ node_modules/ dist/ npm-debug.log +.db.json +data +incoming/ diff --git a/README.md b/README.md index 8b0fae6..cf8eda8 100644 --- a/README.md +++ b/README.md @@ -4,12 +4,21 @@ 1. Install [NVM](https://github.com/creationix/nvm) 1. Install Node.js 4+ +1. Create `./incoming` and `./data` for uploaded videos and psersistence, or + configure `ARCHIVE_DATA_ROOT` 1. Install NPM dependencies +1. Run test suite 1. Start a webserver ``` nvm install 4 ; nvm alias default 4 # One-time thing to get the right Node. cd path/to/project -npm install # The tests should pass, and the command shouldn't fail. + +mkdir incoming +# Alternatively, if you are uploading to a remote location, export this instead: +export ARCHIVE_INCOMING="/var/path/to/uploaded/vidoes" + +npm install # The command shouldn't fail. +npm test # The tests should pass, and the command shouldn't fail. npm start # The server will come up on localhost:8080, and print a banner. ``` diff --git a/gulpfile.js b/gulpfile.js new file mode 100644 index 0000000..f49492b --- /dev/null +++ b/gulpfile.js @@ -0,0 +1,116 @@ +'use strict'; + +let gulp = require('gulp'); +let gutil = require('gulp-util'); +let path = require('path'); + +const INDEX = 'src/client/index.jade'; +const SOURCES = 'src/**/*.ts'; +const TEST_SOURCES = 'src/**/*.{mock,spec}.ts'; +const MOCKS = 'src/**/*mock.ts'; +const TYPEDEPS = 'src/typings/**/*.d.ts'; +const COMPILED_TEST_SOURCES = [ + 'dist/**/*.spec.js', + '!dist/client/**/*.spec.js' +]; +const STATIC_FILES = 'src/static/**/*.css'; +const CLIENT_ROOT = 'src/client'; +const TEMPLATES = CLIENT_ROOT + '/**/*.jade'; +const STYLES = CLIENT_ROOT + '/**/*.scss'; +const STATIC = 'dist/_static'; + +const DEFAULT_TASKS = ['build', 'lint']; + + +/** + * Typescript gets compiled from src/ to dist/ + */ +let gts = require('gulp-typescript'); +let tsProject = gts.createProject('tsconfig.json'); +let ts = gts(tsProject); +gulp.task('build:tsc', function() { + let destination = gulp.dest('dist'); + return gulp.src([SOURCES]).pipe(ts).pipe(destination); +}); + +/** + * Copy static files + */ +gulp.task('build:copy:static', function() { + let destination = gulp.dest(STATIC); + return gulp.src([STATIC_FILES]).pipe(destination); +}); + +/** + * Bundle client app. + */ +let webpack = require('webpack'); +gulp.task('bundle:client', ['build:tsc', 'build:copy:static'], function(done) { + const INDEX_ROOT = './dist/client/index.js'; + const WPOPTS = require('./webpack.config.js'); + WPOPTS.entry = INDEX_ROOT; + WPOPTS.output.path = path.resolve(STATIC); + webpack(WPOPTS, function(err, stats) { + if(err) { + throw new gutil.PluginError('webpack', err); + } + gutil.log('[webpack]', stats.toString({ + colors: true, + chunks: false + })); + done(); + }); +}); + +/** + * Compile all templates + */ +let jade = require('gulp-jade'); +let cache = require('gulp-angular-templatecache'); +gulp.task('index', function() { + let STATIC_DEST = gulp.dest(STATIC); + return gulp.src([INDEX]).pipe(jade()).pipe(STATIC_DEST); +}); +gulp.task('templates', function() { + const TEMPL_DEST = gulp.dest(STATIC); + const CACHE_OPTS = { + module: 'mtna.templates', + standalone: true, + transformUrl: _ => '/' + _ + }; + return gulp.src([TEMPLATES]).pipe(jade()).pipe(cache(CACHE_OPTS)).pipe(TEMPL_DEST); +}); + +/** + * Put together custom styles. + */ +let sass = require('gulp-sass'); +let concatcss = require('gulp-concat-css'); +gulp.task('styles', function() { + let STATIC_DEST = gulp.dest(STATIC); + return gulp.src([STYLES]).pipe(sass({outputStyle: 'compressed'})).pipe(concatcss('styles.css')).pipe(STATIC_DEST); +}); + +/** + * Lint checks all of our code. + */ +let tslintconfig = require('./tslint.json'); +let tslint = require('gulp-tslint'); +gulp.task('lint:tslint', function() { + return gulp.src([SOURCES, `!${TYPEDEPS}`, `!${MOCKS}`]) + .pipe(tslint({configuration: tslintconfig})) + .pipe(tslint.report('prose')); +}); + +/** + * Some default tasks. + */ +const DEFAULT = ['default']; +gulp.task('default', DEFAULT_TASKS); +gulp.task('build', ['build:tsc', 'bundle:client', 'templates', 'styles', 'index']); +gulp.task('lint', ['lint:tslint']); +gulp.task('watch', DEFAULT, function() { gulp.watch([SOURCES], DEFAULT); }); + +if (require.main === module) { + gulp.runTask('default'); +} diff --git a/karma.config.js b/karma.config.js index aa47d10..b383ffc 100644 --- a/karma.config.js +++ b/karma.config.js @@ -1,68 +1,24 @@ -// Karma configuration -// Generated on Sun Oct 11 2015 10:11:58 GMT-0400 (EDT) - module.exports = function(config) { config.set({ - - // base path that will be used to resolve all patterns (eg. files, exclude) - basePath: '', - - - // frameworks to use - // available frameworks: https://npmjs.org/browse/keyword/karma-adapter - frameworks: ['mocha'], - - - // list of files / patterns to load in the browser - files: [ - './src/vendors/angular.js', - './src/vendors/angular-*.js', - './dist/templates.js', - './dist/clientTest.js' + "frameworks" : [ "mocha", "sinon" ], + "files" : [ + "./src/vendors/angular.js", + "./src/vendors/angular-*.js", + "./dist/_static/templates.js", + "./dist/client/**/*.spec.js" ], + "preprocessors" : + {"./dist/client/**/*.spec.js" : [ "webpack", "sourcemap" ]}, - // list of files to exclude - exclude: [ - ], - - - // preprocess matching files before serving them to the browser - // available preprocessors: https://npmjs.org/browse/keyword/karma-preprocessor - preprocessors: { - }, - - - // test results reporter to use - // possible values: 'dots', 'progress' - // available reporters: https://npmjs.org/browse/keyword/karma-reporter - reporters: ['progress'], - - - // web server port - port: 9876, - - - // enable / disable colors in the output (reporters and logs) - colors: true, - - - // level of logging - // possible values: config.LOG_DISABLE || config.LOG_ERROR || config.LOG_WARN || config.LOG_INFO || config.LOG_DEBUG - logLevel: config.LOG_INFO, - - - // enable / disable watching file and executing tests whenever any file changes - autoWatch: true, - + "webpackMiddleware" : {"noInfo" : true}, - // start these browsers - // available browser launchers: https://npmjs.org/browse/keyword/karma-launcher - browsers: ['Firefox'], + "reporters" : [ "progress" ], + "port" : 9876, + "colors" : true, + "browsers" : [ "Firefox" ], - // Continuous Integration mode - // if true, Karma captures browsers, runs the tests and exits - singleRun: false - }) -} + "singleRun" : false + }); +}; diff --git a/package.json b/package.json index 2112b03..f86eea9 100644 --- a/package.json +++ b/package.json @@ -15,60 +15,45 @@ ], "license": "ISC", "scripts": { - "watch": "watch 'npm run test --silent' src/ # run this for real-time build and test", "start": "node dist/api/app.js", - "test": "npm run test:api --silent ; npm run test:browser --silent && echo 'All test suites pass!'", - "test:api": "mocha $(node ./src/scripts/glob './dist/{api,shared}/**/*.spec.js')", + "pretest": "gulp", + "test": "npm run test:mocha; npm run test:browser", "test:browser": "karma start ./karma.config.js --single-run", - "pretest": "npm run build --silent", - "build": "mkdir -p dist/_static && npm run build:scripts && npm run build:styles && npm run build:markup && echo 'MTNA ready!'", - "build:scripts": "npm run build:tsc && npm run build:bundle && echo 'Scripts bundled!'", - "build:tsc": "echo 'Building Typscript... ' ; tsc --declaration --experimentalDecorators --module commonjs --noEmitOnError --noImplicitAny --outDir ./dist --preserveConstEnums --rootDir ./src --sourceMap --suppressImplicitAnyIndexErrors --target ES5 $(node ./src/scripts/glob './src/**/*.ts')", - "build:tsc:scripts": "tsc -p ./src/scripts", - "build:bundle": "npm run build:bundle:tests && npm run build:bundle:prod", - "build:bundle:prod": "echo 'Bundling prod... ' ; webpack -d dist/client/index.js ./dist/_static/bundle.js >/dev/null", - "build:bundle:tests": "echo 'Bundling tests... ' ; webpack -d $(node ./src/scripts/glob 'dist/client/**/*.spec.js') ./dist/clientTest.js >/dev/null", - "build:markup": "npm run build:jade:templates && npm run build:markup:cache && npm run build:jade:index && echo 'Markup emitted!'", - "build:jade:templates": "echo 'Jading HTML... ' ; jade --out dist/client --hierarchy src/client >/dev/null", - "build:jade:index": "cp ./dist/client/index.html ./dist/_static/index.html", - "build:markup:cache": "node ./src/scripts/templateCache.js -m mtna.templates -p ./dist/client $(node ./src/scripts/glob './dist/client/**/*.html') > ./dist/_static/templates.js", - "build:styles": "npm run build:styles:scss && npm run build:styles:join && echo 'Styles joined!'", - "build:styles:scss": "echo 'Compiling CSS... ' ; node-sass --quiet --recursive --output dist/client src/client", - "build:styles:join": "echo 'Joining CSS... ' ; cleancss --output dist/_static/styles.css $(node ./src/scripts/glob './dist/client/**/*.css')", - "prebuild": "[ -f ./src/scripts/glob.js ] || npm run build:tsc:scripts", - "postbuild": "npm run lint", - "lint": "npm run lint:tsc && npm run lint:sass && echo 'Local style guide enforced!'", - "lint:tsc": "echo 'Linting Typescript... ' ; tslint $(node ./src/scripts/glob './src/{api,client,scripts}/**/*.ts')", + "test:mocha": "mocha dist/{api,shared}/**/*.spec.js", "lint:sass": "echo 'Linting Sass... ' ; sass-lint --verbose --no-exit $(node ./src/scripts/glob './src/**/*.scss')", - "lint:jade": "echo 'Linting Jade... ' ; ", - "clean": "rm -rf dist/ src/scripts/*.js", - "list": "echo $(node ./src/scripts/glob './src/**/*.scss')" + "clean": "rm -rf dist/" }, "dependencies": { + "busboy": "^0.2.11", + "mkdirp": "^0.5.1", "ts-rupert": "0.4.1-beta6" }, "devDependencies": { - "chai": "3.2.0", - "clean-css": "3.4.5", - "commander": "2.8.1", - "glob": "5.0.15", + "chai": "^3.5.0", + "gulp": "^3.9.0", + "gulp-angular-templatecache": "^1.8.0", + "gulp-concat-css": "^2.2.0", + "gulp-jade": "^1.1.0", + "gulp-mocha": "^2.2.0", + "gulp-sass": "^2.1.0", + "gulp-tslint": "^4.3.2", + "gulp-typescript": "^2.9.2", + "gulp-util": "^3.0.7", "jade": "1.11.0", - "karma": "0.13.10", - "karma-chrome-launcher": "0.2.1", - "karma-cli": "0.1.1", + "karma": "^0.13.21", + "karma-chrome-launcher": "^0.2.2", + "karma-cli": "^0.1.2", "karma-firefox-launcher": "^0.1.6", - "karma-mocha": "0.2.0", - "mocha": "2.2.5", - "node-sass": "3.3.3", - "sass-lint": "1.2.3", - "sinon": "1.17.1", + "karma-mocha": "^0.2.2", + "karma-sinon": "^1.0.4", + "karma-sourcemap-loader": "^0.3.6", + "karma-webpack": "^1.7.0", + "mocha": "^2.3.4", + "mock-fs": "^3.6.0", + "sinon": "^1.17.3", "sinon-chai": "2.8.0", "source-map-loader": "^0.1.5", - "supertest": "1.1.0", - "ts-loader": "^0.6.0", - "tslint": "2.5.1", - "typescript": "1.6.2", - "watch": "0.16.0", - "webpack": "^1.12.2" + "supertest": "^1.2.0", + "webpack": "^1.12.8" } } diff --git a/src/api/app.ts b/src/api/app.ts index 8e4a2ed..5becfc2 100644 --- a/src/api/app.ts +++ b/src/api/app.ts @@ -5,19 +5,31 @@ import { RecordHandler } from './record/record'; +import{ + VideoHandler +} from './videos/video'; + const defaults: any = { log: {level: 'info'}, + uploads: { size: '15Mb' }, static: { routes: { '/': normalize(join(__dirname, '../_static')), + '/images/': normalize( + // TODO If and when Rupert gets referential configs, set that here. + join(process.env['ARCHIVE_DATA_ROOT'] || process.cwd(), 'data')) } + }, + archive: { + data_root: normalize(process.cwd()) } }; export const server = Rupert.createApp(defaults, [ Plugins.Healthz, Plugins.Static, - RecordHandler + RecordHandler, + VideoHandler ]); if (require.main === module) { diff --git a/src/api/record/record.spec.ts b/src/api/record/record.spec.ts index 7822fd0..b5097f9 100644 --- a/src/api/record/record.spec.ts +++ b/src/api/record/record.spec.ts @@ -1,15 +1,25 @@ import { expect, use as chaiUse } from 'chai'; import * as sinon from 'sinon'; + +import { + MOCK_RECORD_1, MOCK_RECORD_2, MOCK_RECORD_3, MOCK_STORY_1 +} from '../../shared/record/record.mock'; + /* tslint:disable */ chaiUse(require('sinon-chai')); +let mockfs = require('mock-fs'); +let fs = require('fs') /* tslint:enable */ import { - Request, Response -} from 'express'; + Request, Response, Config +} from 'ts-rupert'; + +import { getMockLogger } from '../../util/mockLogger'; import { - // Record, + Record, + Story, RecordDatabase } from '../../shared/record/record'; @@ -23,15 +33,131 @@ describe('Record Handler', function() { beforeEach(function() { recordMap = {}; - handler = new RecordHandler(recordMap); + handler = new RecordHandler(getMockLogger(), new Config(), recordMap); + mockfs({ './data/.db.json': '{}' }); + }); + + afterEach(function() { + mockfs.restore(); }); describe('Saving', function() { - it('saves', function() { + it('saves', function(done: Function) { let q: Request = { params: { id: 'clip-1' }, + body: { + label: 'Tape 1', + family: 'Tapes', + medium: '3/4"', + stories: [ MOCK_STORY_1 ] + } + }; + let s: Response = { + status: function(status: number): Response { + return this; + }, + send: sinon.spy() + }; + let statusSpy = sinon.spy(s, 'status'); + handler.save(q, s, (err: any) => { + try { + expect(err).to.not.exist; + expect(statusSpy).to.have.been.calledWithExactly(200); + expect('clip-1' in recordMap).to.be.true; + let record = recordMap['clip-1']; + expect(record.stories.length).to.equal(1); + done(); + } catch (e) { + done(e); + } + }); + }); + + it('replaces tapes with old ids', function(done: Function) { + recordMap['klip_1'] = new Record('Klip 1', 'tapes'); + let q: Request = { + params: { + id: 'tape-1' + }, + query: { + replaceId: 'klip_1' + }, + body: { + label: 'Tape 1', + family: 'Tapes', + medium: '3/4"', + stories: [ { + slug: 'Story 1', + date: new Date('10/15/2015'), + format: 'VO', + runtime: '5:30' + } ] + } + }; + let s: Response = { + status: function(status: number): Response { + return this; + }, + send: sinon.spy() + }; + let statusSpy = sinon.spy(s, 'status'); + handler.save(q, s, (err: any) => { + try { + expect(err).to.not.exist; + expect(statusSpy).to.have.been.calledWithExactly(200); + expect(Object.keys(recordMap)).to.deep.equal(['tape_1']); + done(); + } catch (e) { + done(e); + } + }); + }); + + it('errors when replacing existing tapes', function(done: Function) { + recordMap['klip_1'] = new Record('Klip 1', 'tapes'); + recordMap['klip_2'] = new Record('Klip 2', 'tapes'); + let q: Request = { + params: { + id: 'klip_2' + }, + query: { + replaceId: 'klip_1' + }, + body: { + label: 'Klip 2', + family: 'Tapes', + medium: '3/4"', + stories: [] + } + }; + let s: Response = { + status: function(status: number): Response { + return this; + }, + send: sinon.spy() + }; + let statusSpy = sinon.spy(s, 'status'); + handler.save(q, s, (err: any) => { + try { + expect(err).to.not.exist; + expect(statusSpy).to.have.been.calledWithExactly(409); + done(); + } catch (e) { + done(e); + } + }); + }); + + it('replaces missing tapes', function(done: Function) { + let q: Request = { + params: { + id: 'tape_1' + }, + query: { + replaceId: 'klip_1' + }, body: { label: 'Tape 1', family: 'Tapes', @@ -48,14 +174,194 @@ describe('Record Handler', function() { status: function(status: number): Response { return this; }, - end: sinon.spy() + send: sinon.spy() }; let statusSpy = sinon.spy(s, 'status'); - handler.save(q, s); - expect(statusSpy).to.have.been.calledWithExactly(204); - expect('tape_1' in recordMap).to.be.true; - let record = recordMap['tape_1']; - expect(record.stories.length).to.equal(1); + handler.save(q, s, (err: any) => { + try { + expect(err).to.not.exist; + expect(statusSpy).to.have.been.calledWithExactly(200); + expect(Object.keys(recordMap)).to.deep.equal(['tape_1']); + done(); + } catch (e) { + done(e); + } + }); + }); + }); + + describe('Search', function() { + beforeEach(function() { + recordMap['tape-1'] = Record.fromObj(MOCK_RECORD_1); + recordMap['tape-2'] = Record.fromObj(MOCK_RECORD_2); + recordMap['tape-3'] = Record.fromObj(MOCK_RECORD_3); + recordMap['tape-1'].addStories( + [Story.fromObj(MOCK_STORY_1)] + ); + }); + + it('finds records within labels, insensitive', function(done: Function) { + const q: Request = { + query: { + query: 'PE 1', + } + }; + let found: any; + const s: Response = { + status: function(status: number): Response { + return this; + }, + send: function(data: any): Response { + found = data; + return this; + } + }; + const statusSpy = sinon.spy(s, 'status'); + const sendSpy = sinon.spy(s, 'send'); + handler.find(q, s, (err: any) => { + try { + expect(err).to.not.exist; + expect(sendSpy).to.have.been.calledOnce; + expect(statusSpy).to.have.been.calledWith(200); + expect(found.length).to.equal(1); + done(); + } catch (e) { + done(e); + } + }); + }); + + it('finds records within a date range, inclusive', function(done: Function) { + const q: Request = { + query: { + before: '2010-04-01T00:00:00.000Z', + after: '2010-02-01T00:00:00.000Z' + } + }; + let found: any; + const s: Response = { + status: function(status: number): Response { + return this; + }, + send: function(data: any): Response { + found = data; + return this; + } + }; + const statusSpy = sinon.spy(s, 'status'); + const sendSpy = sinon.spy(s, 'send'); + handler.find(q, s, (err: any) => { + try { + expect(err).to.not.exist; + expect(sendSpy).to.have.been.calledOnce; + expect(statusSpy).to.have.been.calledWith(200); + expect(found.length).to.equal(2); + done(); + } catch (e) { + done(e); + } + }); + }); + + it('finds records within stories, insensitive', function(done: Function) { + const q: Request = { + query: { + query: 'ry 1', + } + }; + let found: any; + const s: Response = { + status: function(status: number): Response { + return this; + }, + send: function(data: any): Response { + found = data; + return this; + } + }; + const statusSpy = sinon.spy(s, 'status'); + const sendSpy = sinon.spy(s, 'send'); + handler.find(q, s, (err: any) => { + try { + expect(err).to.not.exist; + expect(sendSpy).to.have.been.calledOnce; + expect(statusSpy).to.have.been.calledWith(200); + expect(found.length).to.equal(1); + done(); + } catch (e) { + done(e); + } + }); + }); + }); + + describe('Associate', function() { + let renameSpy: Sinon.SinonSpy; + beforeEach(function() { + recordMap['tape-1'] = Record.fromObj(MOCK_RECORD_1); + recordMap['tape-2'] = Record.fromObj(MOCK_RECORD_2); + let associateFs = { + '/var/archives/incoming': {'path1': 'Video 1', 'path2': 'Video 2'}, + '/var/archives/data/.db.json': '{}' + }; + associateFs[handler.dataPath + '/tape-1'] = {}; + mockfs(associateFs); + renameSpy = sinon.spy(fs, 'rename'); + }); + afterEach(function() { + renameSpy.restore(); + }); + it('associates videos with records', function(done: Function) { + let q: Request = { + params: { id: 'tape-1' }, + body: ['path1', 'path2'] + }; + let s: Response = { + status: function(status: number): Response { + return this; + }, + send: sinon.spy() + }; + let statusSpy = sinon.spy(s, 'status'); + handler.associate(q, s, (err: any) => { + if (err) { return done(err); } + try { + expect(renameSpy).to.have.been.calledTwice; + let videoPaths = recordMap['tape-1'].videos.map((_) => _.path); + expect(videoPaths).to.deep.equal(['tape-1/path1', 'tape-1/path2']); + expect(statusSpy).to.have.been.calledWith(200); + // expect(s.send).to.have.been.called.with(MOCK_RECORD_1); + done(); + } catch (e) { + done(e); + } + }); + }); + it('associates videos with uncreated folders', function(done: Function) { + let q: Request = { + params: { id: 'tape-2' }, + body: ['path1', 'path2'] + }; + let s: Response = { + status: function(status: number): Response { + return this; + }, + send: sinon.spy() + }; + let statusSpy = sinon.spy(s, 'status'); + handler.associate(q, s, (err: any) => { + if (err) { return done(err); } + try { + expect(renameSpy).to.have.been.calledTwice; + let videoPaths = recordMap['tape-2'].videos.map((_) => _.path); + expect(videoPaths).to.deep.equal(['tape-2/path1', 'tape-2/path2']); + expect(statusSpy).to.have.been.calledWith(200); + // expect(s.send).to.have.been.called.with(MOCK_RECORD_1); + done(); + } catch (e) { + done(e); + } + }); }); }); }); diff --git a/src/api/record/record.ts b/src/api/record/record.ts index 64c230f..8a6b3c2 100644 --- a/src/api/record/record.ts +++ b/src/api/record/record.ts @@ -1,10 +1,12 @@ -import { writeFile, readFile } from 'fs'; +import { writeFile, readFile, stat, Stats, rename, rmdir, unlink } from 'fs'; import { join } from 'path'; +import * as mkdirp from 'mkdirp'; import { + Image, Record, - // RecordClip, - RecordDatabase + RecordDatabase, + Video } from '../../shared/record/record'; import { @@ -14,23 +16,38 @@ import { Route, Request, Response, - Methods + Methods, + ILogger, + Config } from 'ts-rupert'; @Route.prefix('/api/records') export class RecordHandler extends RupertPlugin { - public dbPath: string = join(process.cwd(), '.db.json'); + public basePath: string = this.config.find( + 'archive.data_root', + 'ARCHIVE_DATA_ROOT', + '/var/archives' + ); + public dataPath: string = join(this.basePath, 'data'); + public dbPath: string = join(this.dataPath, '.db.json'); + public incomingPath: string = join(this.basePath, 'incoming'); private cancelWrite: NodeJS.Timer; constructor( + @Inject(ILogger) public logger: ILogger, + @Inject(Config) public config: Config, @Optional() @Inject(RecordDatabase) private database: RecordDatabase = {} ) { super(); - this.cancelWrite = setInterval(() => this.write(), 1 * 1000); readFile(this.dbPath, 'utf-8', (err: any, persisted: string) => { - if ( err !== null ) { return; } + if ( err ) { + this.logger.info( + `No database found, creating a new one at ${this.dbPath}` + ); + persisted = '{}'; + } let db = JSON.parse(persisted); Object.keys(db).forEach((k: any) => { if (typeof k === 'string' ) { @@ -39,60 +56,245 @@ export class RecordHandler extends RupertPlugin { } } }); + this.cancelWrite = setInterval(() => this.write(), 1 * 1000); }); } write(): void { + this.logger.verbose(`Writing full database at ${this.dbPath}`); writeFile(this.dbPath, JSON.stringify(this.database)); } @Route.GET('/:id') - get(q: Request, s: Response): void { + get(q: Request, s: Response, n: Function): void { let id: string = q.params['id']; if (id in this.database) { let record: Record = this.database[id]; if (!record.deleted) { s.status(200).send(record); - return; } + } else { + s.status(404).send(`Record not found: ${id}`); } - s.status(404).send(`Record not found: ${id}`); + n(); } @Route.PUT('/:id') - save(q: Request, s: Response): void { + save(q: Request, s: Response, n: Function): void { let id: string = q.params['id']; + let replaceId: string = q.query && q.query['replaceId'] || null; let protoRecord: any = q.body; if (Record.isProtoRecord(protoRecord)) { let record = Record.fromObj(protoRecord); - if (record.id in this.database) { - record.merge(this.database[record.id]); + record.forceId(id); + if ( replaceId !== record.id && record.id in this.database ) { + s.status(409).send('The label is already used for a different record.'); + return n(); + } + if ( replaceId && replaceId in this.database ) { + record = this.database[replaceId].merge(record); + this.moveFiles(replaceId, record.id, (err: any) => { + if (err !== null) { return n(err); } + delete this.database[replaceId]; + // Update all media links + record.updateMedia(replaceId); + this.database[record.id] = record; + s.status(200).send(record); + n(); + }); + return; + } else { + this.database[record.id] = record; + s.status(200).send(record); + } + } else { + s.status(400).end(); + } + n(); + } + + moveFiles(oldId: string, newId: string, cb: (err: any) => void): void { + // Assert oldId is present + let oldPath = join(this.dataPath, oldId); + stat(oldPath, (statErr: any, stats: Stats) => { + if (statErr !== null) { + // Probably doesn't exist. + return cb(null); } - record.id = id; - this.database[record.id] = record; - return s.status(204).end(); + if (!stats.isDirectory()) { + return cb(null); + } + // Move from oldId to newId + let newPath = join(this.dataPath, newId); + rename(oldPath, newPath, (renameErr: any) => { + // Return CB + cb(renameErr); + }); + }); + } + + static b64image: RegExp = /data:(image)\/([^;]+);base64,(.*)/; + + @Route.POST('/:id/upload') + upload(q: Request, s: Response, n: Function): void { + let id: string = q.params['id']; + let [_, type, subtype, b64] = RecordHandler.b64image.exec(q.body.image); + if (_ === null || type !== 'image' ) { + return n(new Error('Need an image.')); + } + let root = join(this.dataPath, id); + mkdirp(root, (mkdirperr: any) => { + if (mkdirperr !== null) { return n(mkdirperr); } + let name = id + '_' + ('' + Math.random()).substr(2); + let path = join(id, name) + '.' + subtype; + this.logger.debug('Creating ' + path); + let buffer = new Buffer(b64, 'base64'); + this.logger.debug('Writing ' + buffer.length + ' bytes'); + writeFile(join(this.dataPath, path), buffer, (writeerr: any) => { + if (writeerr !== null) { return n(writeerr); } + this.logger.debug(`Wrote ${buffer.length} bytes to ${path}`); + s.status(200).send({path}); + n(); + }); + }); + } + + @Route.POST('/:id/remove') + remove(q: Request, s: Response, n: Function): void { + let path = q.body['path']; + unlink(join(this.dataPath, path), (err: any) => { + s.status(204).end(); + n(); + }); + } + + @Route.POST('/:id/associate') + associate(q: Request, s: Response, n: Function): void { + let id: string = q.params.id; + if (!(id in this.database)) { + s.status(404).send(`Record ${id} not in database.`); + n(); } else { - return s.status(400).end(); + this.moveIncoming(q.body, id).then(() => { + this.database[id].addVideos( + q.body.map((_: string) => join(id, _)) + .map((_: string) => Video.fromObj({path: _})) + ); + s.status(200).send(this.database[id]); + n(); + }).catch((err: any) => n(err)); } } + private moveIncoming(paths: string[], id: string): Promise { + let movePath = (path: string) => new Promise((s, j) => { + let oldPath = join(this.incomingPath, path); + let newRoot = join(this.dataPath, id); + let newPath = join(newRoot, path); + mkdirp(newRoot, (mkdirperr: any) => { + if (mkdirperr !== null) { return j(mkdirperr); } + rename(oldPath, newPath, (err: any) => { + if (err !== null) { return j(err); } + s(); + }); + }); + }); + return Promise.all(paths.map(movePath)); + } + @Route.GET('') - find(q: Request, s: Response): void { - s.send(Object.keys(this.database).map((id: string) => { + find(q: Request, s: Response, n: Function): void { + let before = q.query['before']; + let after = q.query['after']; + let query = q.query['query']; + + let records = Object.keys(this.database).map((id: string) => { return this.database[id]; - })); + }); + if (query) { + const searchString = query.toLowerCase(); + records = records.filter((a: Record) => { + let found = false; + ['label', 'family', 'notes'].forEach((k) => { + const sourceString = (a)[k].toLowerCase(); + found = found || (sourceString.indexOf(searchString) > -1); + }); + if (!found) { + const l = a.stories.length; + any: for (let i = 0 ; i < l; i++) { + if (a.stories[i].slug.toLowerCase().indexOf(searchString) > -1) { + found = true; + break any; + } + } + } + return found; + }); + } + if (before) { + before = new Date(before); + records = records.filter((a: Record) => a.first <= before); + } + if (after) { + after = new Date(after); + records = records.filter((a: Record) => a.last >= after); + } + s.status(200).send( + records.sort((a: Record, b: Record) => a.label.localeCompare(b.label)) + ); + n(); } @Route('/:id', {methods: [Methods.DELETE]}) - delete(q: Request, s: Response): void { + delete(q: Request, s: Response, n: Function): void { let id: string = q.params['id']; - if (id in this.database) { - let record = this.database[id]; - record.deleted = true; - this.database[record.id] = record; + let record = this.database[id]; + Promise.all([ + this.returnToIncoming(record.videos || []), + this.removeImages(record.images || []) + ]).then(() => { + return new Promise((r, j) => { + rmdir(join(this.dataPath, id), (err: any) => { + if (err !== null && err.code !== 'ENOENT') { return j(err); } + r(); + }); + }); + }).then(() => { + this.database[record.id] = null; + delete this.database[record.id]; s.status(204).end(); - return; - } - s.status(404).send(`Record not found: ${id}`); + }).catch((e) => { + s.status(500).send(e); + }); + } + + private getParts(path: string): {name: string, ext: string} { + let file = path.split('/')[1]; + let [name, ext] = file.split('.'); + return {name, ext}; + } + + private returnToIncoming(videos: Video[]): Promise { + const timestamp = new Date().toISOString(); + const moveVideo = (video: Video) => new Promise((s, j) => { + let currentPath = join(this.dataPath, video.path); + let {name, ext} = this.getParts(video.path); + let incomingPath = join(this.incomingPath, `${name}_recovered_${timestamp}.${ext}`); + rename(currentPath, incomingPath, (err: any) => { + if (err !== null) { return j(err); } + s(); + }); + }); + return Promise.all(videos.map(moveVideo)); + } + + private removeImages(images: Image[]): Promise { + const deleteImage = (image: Image) => new Promise((s, j) => { + unlink(join(this.dataPath, image.path), (err: any) => { + if (err !== null) { return j(err); } + s(); + }); + }); + return Promise.all(images.map(deleteImage)); } } diff --git a/src/api/videos/video.mock.ts b/src/api/videos/video.mock.ts new file mode 100644 index 0000000..326082d --- /dev/null +++ b/src/api/videos/video.mock.ts @@ -0,0 +1 @@ +export let mockIncoming: string[] = ['digital-file-1.mp4', 'digital-file-2.mp4']; diff --git a/src/api/videos/video.spec.ts b/src/api/videos/video.spec.ts new file mode 100644 index 0000000..8047160 --- /dev/null +++ b/src/api/videos/video.spec.ts @@ -0,0 +1,55 @@ +import { expect, use as chaiUse } from 'chai'; +import * as sinon from 'sinon'; + +/* tslint:disable */ +chaiUse(require('sinon-chai')); +let mock: any = require('mock-fs'); +/* tslint:enable */ + +import { + Request, Response, Config, ILogger +} from 'ts-rupert'; + +import { getMockLogger } from '../../util/mockLogger'; + +import { VideoHandler } from './video'; +import { mockIncoming } from './video.mock'; + +describe('VideoHandler', function() { + let handler: VideoHandler = null; + let config: Config = null; + let logger: ILogger = null; + + beforeEach(function() { + config = new Config({archive: { incoming: '/var/archives/incoming/' }}); + let files = {}; + mockIncoming.forEach((_) => files[_] = `Video ${_}`); + mock({'/var/archives/incoming/': files, './data/.db.json': '{}'}); + logger = getMockLogger(); + handler = new VideoHandler(logger, config); + }); + + afterEach(function() { + mock.restore(); + }); + + describe('Incoming', function() { + it('returns a list of videos in the incoming directoy.', function(done: Function) { + let q = { }; + let s: Response = { + status: function(status: number): Response { + return this; + }, + send: sinon.spy() + }; + let statusSpy = sinon.spy(s, 'status'); + + handler.incoming(q, s, (err: any) => { + expect(err).to.not.exist; + expect(statusSpy).to.have.been.calledWith(200); + expect(s.send).to.have.been.calledWith(mockIncoming); + done(); + }); + }); + }); +}); diff --git a/src/api/videos/video.ts b/src/api/videos/video.ts new file mode 100644 index 0000000..0c51c66 --- /dev/null +++ b/src/api/videos/video.ts @@ -0,0 +1,36 @@ +import { readdir } from 'fs'; +import { join } from 'path'; + +import { + Config, + Inject, + RupertPlugin, + Route, + Request, + Response, + ILogger +} from 'ts-rupert'; + +@Route.prefix('/api/videos') +export class VideoHandler extends RupertPlugin { + constructor( + @Inject(ILogger) public logger: ILogger, + @Inject(Config) public config: Config + ) { super(); } + + @Route.GET('/incoming') + incoming(q: Request, s: Response, n: Function): void { + const basePath: string = this.config.find( + 'archive.data_root', + 'ARCHIVE_DATA_ROOT', + '/var/archives' + ); + const incoming: string = join(basePath, 'incoming'); + readdir(incoming, (err: any, files: string[]) => { + if ( err ) { return n(err); } + s.status(200).send(files); + n(); + }); + } +} + diff --git a/src/client/index.jade b/src/client/index.jade index 8d5fe51..c5a658d 100644 --- a/src/client/index.jade +++ b/src/client/index.jade @@ -2,19 +2,25 @@ doctype html html(lang="en", ng-app="mtna") head title Montana News Archive - link(rel="stylesheet" href="//cdnjs.cloudflare.com/ajax/libs/angular-material/1.0.0-rc1/angular-material.min.css") + link(rel="stylesheet" href="//cdnjs.cloudflare.com/ajax/libs/angular-material/1.0.4/angular-material.min.css") link(rel="stylesheet" href="https://fonts.googleapis.com/icon?family=Material+Icons") + link(rel="stylesheet" href="/bootstrap.css") link(rel="stylesheet" href="/styles.css") body archive - script(src="//cdnjs.cloudflare.com/ajax/libs/angular.js/1.5.0-beta.1/angular.min.js") - script(src="//cdnjs.cloudflare.com/ajax/libs/angular.js/1.5.0-beta.1/angular-animate.min.js") - script(src="//cdnjs.cloudflare.com/ajax/libs/angular.js/1.5.0-beta.1/angular-aria.min.js") - script(src="//cdnjs.cloudflare.com/ajax/libs/angular.js/1.5.0-beta.1/angular-messages.min.js") - script(src="//cdnjs.cloudflare.com/ajax/libs/angular.js/1.5.0-beta.1/angular-resource.min.js") - script(src="//cdnjs.cloudflare.com/ajax/libs/angular.js/1.5.0-beta.1/angular-route.min.js") - script(src="//cdnjs.cloudflare.com/ajax/libs/angular-material/1.0.0-rc1/angular-material.min.js") - + script(src="//cdnjs.cloudflare.com/ajax/libs/angular.js/1.5.3/angular.min.js") + script(src="//cdnjs.cloudflare.com/ajax/libs/angular-filter/0.5.8/angular-filter.min.js") + script(src="//cdnjs.cloudflare.com/ajax/libs/angular.js/1.5.3/angular-animate.min.js") + script(src="//cdnjs.cloudflare.com/ajax/libs/angular.js/1.5.3/angular-aria.min.js") + script(src="//cdnjs.cloudflare.com/ajax/libs/angular.js/1.5.3/angular-messages.min.js") + script(src="//cdnjs.cloudflare.com/ajax/libs/angular.js/1.5.3/angular-resource.min.js") + script(src="//cdnjs.cloudflare.com/ajax/libs/angular.js/1.5.3/angular-route.min.js") + script(src="//cdnjs.cloudflare.com/ajax/libs/angular.js/1.5.3/angular-sanitize.min.js") + script(src="//cdnjs.cloudflare.com/ajax/libs/angular-material/1.0.6/angular-material.min.js") + script(src="/templates.js") script(src="/bundle.js") + + script(). + var myApp = angular.module('myApp', ['angular.filter']); diff --git a/src/client/index.ts b/src/client/index.ts index 83a16d3..7f44387 100644 --- a/src/client/index.ts +++ b/src/client/index.ts @@ -5,4 +5,12 @@ import { angular.module('mtna', [ 'mtna.templates', Archive.module.name -]); +]).config( ($sceDelegateProvider: any) => { + $sceDelegateProvider.resourceUrlWhitelist([ + // Allow same origin resource loads. + 'self', + // Allow loading from our video assets domain. + // TODO: Put CDN domain here. These are just for dev. + 'http://stanparker.net/**' + ]); +}); diff --git a/src/client/mtna/.DS_Store b/src/client/mtna/.DS_Store deleted file mode 100644 index 5008ddf..0000000 Binary files a/src/client/mtna/.DS_Store and /dev/null differ diff --git a/src/client/mtna/archive-component.spec.ts b/src/client/mtna/archive-component.spec.ts index 78ffca8..eec6eae 100644 --- a/src/client/mtna/archive-component.spec.ts +++ b/src/client/mtna/archive-component.spec.ts @@ -1,14 +1,10 @@ -import { - expect -} from 'chai'; +import {expect} from 'chai'; -import { - RecordResource -} from './record/record-resource'; +import {RecordResource} from './record/record-resource'; -import { - Record -} from '../../shared/record/record'; +import {LocationService} from './location/location-service'; + +import {Record} from '../../shared/record/record'; import { MOCK_RECORD_1, @@ -16,12 +12,33 @@ import { MOCK_RECORD_3 } from '../../shared/record/record.mock'; -import { - Archive -} from './archive-component'; +import {Archive} from './archive-component'; + +import {ToastService} from './toast/toast-service'; let $rootScope: ng.IScope = null; let $q: ng.IQService = null; +let $scope: ng.IScope = null; +let $anchorScroll: ng.IAnchorScrollService = null; +let $timeout: ng.ITimeoutService = null; +let $http: ng.IHttpService = null; +/* tslint:disable */ +let mockToastService = { + simple : function() { return this; }, + showSimple : function() { return this; }, + build : function() { return this; }, + content : function() { return this; }, + updateContent : function() { return this; }, + position : function() { return this; }, + action : function() { return this; }, + hideDelay : function() { return this; }, + show : function() { return this; }, + hide : function() { return this; }, + cancel : function() { return this; }, +}; +/* tslint:enable */ +let toast: ToastService = new ToastService(mockToastService); +let _location: LocationService; class MockRecordResource { [key: string]: any; @@ -30,68 +47,63 @@ class MockRecordResource { } $save() { return $q.resolve(); } + update() { return $q.resolve(); } static query(): {$promise: Thenable} { - return {$promise: $q.resolve([MOCK_RECORD_1])}; + return {$promise: $q.resolve([MOCK_RECORD_1 ])}; + } + static update(r: Record): {$promise: Thenable} { + return {$promise: $q.resolve()}; + } + + static get(r: Record): {$promise: Thenable} { + return {$promise: $q.resolve(r)}; } } describe('MTNA Archive', function() { - beforeEach(inject(function(_$q_: ng.IQService, _$rootScope_: ng.IScope){ + beforeEach(inject(function( + _$q_: ng.IQService, _$rootScope_: ng.IScope, + _$anchorScroll_: ng.IAnchorScrollService, + _$http_: ng.IHttpService, _$location_: ng.ILocationService, + _$timeout_: ng.ITimeoutService) { $q = _$q_; $rootScope = _$rootScope_; + $scope = $rootScope.$new(); + $anchorScroll = _$anchorScroll_; + $http = _$http_; + $timeout = _$timeout_; + _location = new LocationService(_$location_); })); + function buildArchive() { + let archive = new Archive($scope, $q, $anchorScroll, $timeout, + MockRecordResource, toast, + $http, _location); + return archive; + } + it('exposes a directive', function() { let directive = Archive.directive(); expect(directive.controller).to.equal(Archive); }); it('can add a tape', function() { - let archive = new Archive($q, MockRecordResource); + let archive = buildArchive(); expect(archive.records.length).to.equal(0); archive.addTape(); expect(archive.records.length).to.equal(1); - expect(archive.editing).to.equal(archive.records[0]); + $timeout.flush(); + expect(archive.current).to.equal(archive.records[0]); }); - describe('edit mode', function() { - it('transitions between editing and non-editing', function() { - let archive = new Archive($q, MockRecordResource); - let record = new MockRecordResource(MOCK_RECORD_1); - archive.edit(record); - expect(archive.editing).to.equal(record); - expect(archive.saving).to.be.false; - archive.edit(null); - expect(archive.saving).to.be.true; - $rootScope.$apply(); - expect(archive.saving).to.be.false; - }); - - it('saves when transitioning from one edit to another', function() { - let archive = new Archive($q, MockRecordResource); - let record1 = new MockRecordResource(MOCK_RECORD_1); - let record2 = new MockRecordResource(MOCK_RECORD_2); - archive.edit(record1); - expect(archive.editing).to.equal(record1); - expect(archive.saving).to.be.false; - archive.edit(record2); - // Stay on record1 until the save finishes. - // If the save errors, then the old data isn't erased. - expect(archive.editing).to.equal(record1); - expect(archive.saving).to.be.true; - $rootScope.$apply(); - expect(archive.editing).to.equal(record2); - expect(archive.saving).to.be.false; - }); - }); describe('selection', function() { it('changes pre/current/post lists', function() { - let archive = new Archive($q, MockRecordResource); - let record1: Record = new MockRecordResource(MOCK_RECORD_1); - let record2: Record = new MockRecordResource(MOCK_RECORD_2); - let record3: Record = new MockRecordResource(MOCK_RECORD_3); - archive.records = [record1, record2, record3]; + let archive = buildArchive(); + let record1: Record = new MockRecordResource(MOCK_RECORD_1); + let record2: Record = new MockRecordResource(MOCK_RECORD_2); + let record3: Record = new MockRecordResource(MOCK_RECORD_3); + archive.records = [ record1, record2, record3 ]; archive.select(null); expect(archive.pre.length).to.equal(3); expect(archive.current).to.equal(null); @@ -122,5 +134,43 @@ describe('MTNA Archive', function() { expect(archive.post.length).to.equal(0); }); }); -}); + describe('collapse', function() { + let archive: Archive; + let record1: Record; + let record2: Record; + let record3: Record; + let saveSpy: Sinon.SinonSpy; + + beforeEach(function() { + archive = buildArchive(); + saveSpy = sinon.spy(archive, 'save'); + record1 = new MockRecordResource(MOCK_RECORD_1); + record2 = new MockRecordResource(MOCK_RECORD_2); + record3 = new MockRecordResource(MOCK_RECORD_3); + archive.records = [ record1, record2, record3 ]; + archive.select(record2); + }); + + it('should set current to null', function() { + archive.collapse(); + expect(archive.current).to.equal(null); + }); + + it('should set pre to the whole record list', function() { + archive.collapse(); + expect(archive.pre).to.equal(archive.records); + }); + + it('should set post to null', function() { + archive.collapse(); + expect(archive.post).to.equal(null); + }); + + it('does not save while inFlight', function() { + archive.inFlight = true; + archive.collapse(); + expect(saveSpy).to.have.not.been.called; + }); + }); +}); diff --git a/src/client/mtna/archive-component.ts b/src/client/mtna/archive-component.ts index 2b687cd..21a9b95 100644 --- a/src/client/mtna/archive-component.ts +++ b/src/client/mtna/archive-component.ts @@ -4,40 +4,68 @@ import { RecordResource } from './record/record-resource'; -import { - Record -} from '../../shared/record/record'; - -import { - RecordViewer -} from './record/record-component'; - -import { - Searchbar -} from './searchbar/searchbar-component'; +import {IRecord, Record} from '../../shared/record/record'; +import {RecordViewer} from './record/record-component'; +import {Searchbar} from './searchbar/searchbar-component'; +import {ISearchQuery, SEARCH_EVENT} from './searchbar/searchbar-service'; +import {ElemClick} from './elem-click/elem-click-directive'; +import {LocationService} from './location/location-service'; +import {ToastService} from './toast/toast-service'; export class Archive { - public saving: boolean = false; + public inFlight: boolean = false; public searching: boolean = false; public search: string = ''; public records: Record[] = []; public pre: Record[] = []; public current: Record = null; + public lastRecordSaved: Record = null; public currentIndex: number = -1; public post: Record[] = []; - public editing: Record = null; - public error: any = null; - constructor( - private $q: ng.IQService, - private RecordResource: RecordResource - ) { - this.RecordResource.query().$promise.then((__: IRecordResource[]) => { - this.records = __.map(Record.fromObj); - this.select(null); + constructor(private $scope: ng.IScope, private $q: ng.IQService, + private $anchorScroll: ng.IAnchorScrollService, + private $timeout: ng.ITimeoutService, + private RecordResource: RecordResource, + private Toaster: ToastService, + private _http: ng.IHttpService, + private _location: LocationService) { + $scope.$on(SEARCH_EVENT, (event: any, query: ISearchQuery) => { + this.doSearch(query); }); + if (this._location.hasRecordId) { + // Do search and _force_ selection. + const id = this._location.currentId; + this.doSearch({ + after: this._location.startDate, + before: this._location.endDate, + query: this._location.queryString, + }).then(() => { + this.select(this.records.filter((_) => _.id === id)[0]); + }); + } else if (this._location.hasSearch) { + this.doSearch({ + after: this._location.startDate, + before: this._location.endDate, + query: this._location.queryString, + }); + } + } + + doSearch(query: ISearchQuery): Promise { + this.inFlight = true; + return this.RecordResource.query(query) + .$promise.then((__: IRecordResource[]) => { + this.inFlight = false; + this.records = __.map(Record.fromObj).sort(Record.comparator); + this.select(null); + this._location.startDate = query.after; + this._location.endDate = query.before; + this._location.queryString = query.query; + }) + .catch(() => { this.inFlight = false; }); } select(record: Record): void { @@ -49,62 +77,117 @@ export class Archive { this.post = []; } else { this.current = record; + this.RecordResource.get({id : record.id}) + .$promise.then((_: IRecord) => { + const updated = Record.fromObj(_); + this.current.merge(updated, true); + }); this.pre = this.records.slice(0, this.currentIndex); this.post = this.records.slice(this.currentIndex + 1); + this.$timeout(() => { + this.$anchorScroll(`record-${this.current.id}`); + }); } + this._location.current = this.current; } - save(record: Record): void { - let success = angular.noop; - let error = (err: any) => this.error = err; - let done = () => this.saving = false; - this.saving = true; - this.$q.all( - this.records.map((_: Record) => this.RecordResource.update({id: _.id}, _)) - ) - .then(success, error) - .then(done, done); - } - edit(record: Record): void { - let success = () => this.editing = record; - let error = (err: any) => this.error = err; - let done = () => this.saving = false; - if (this.editing && record !== this.editing) { - this.saving = true; - this.$q.all( - this.records.map( (_: Record) => (new this.RecordResource(_)).$save()) - ) + save(record: Record): Promise { + if (this.inFlight) { + // #74: Don't have more than one request at at time. + return; + } + let success = (saved: IRecord) => { + record.merge(Record.fromObj(saved), true); + this.Toaster.toast(`Saved ${record.label}`); + this.lastRecordSaved = record; + record.baseId = record.id; + record.isNewTape = false; + this._location.current = record; + }; + let error = (err: any) => { + this.error = err; + this.Toaster.error( + `${this.error.status} Error saving ${record.label}: ${this.error.data}` + ); + }; + let done = (err: any) => { this.inFlight = false; }; + this.inFlight = true; + let params = {id : record.id, replaceId : record.baseId}; + if (record.isNewTape || !record.modified) { + delete params.replaceId; + } + let update = this.RecordResource.update(params, record).$promise; + if (record.rawImage) { + update.then(() => { + return this._http.post(`/api/record/#{record.id}/upload`, { + filename : record.rawImage.name, + image : record.rawImage + }); + }); + } + update .then(success, error) .then(done, done); - } else { - success(); - } + return update; } addTape(): void { + this.collapse(); let record = new Record('', ''); + record.isNewTape = true; + if (this.lastRecordSaved) { + record.family = this.lastRecordSaved.family; + record.medium = this.lastRecordSaved.medium; + } this.records.push(record); - this.edit(record); + this.select(record); + } + + collapse(): void { + this.current = null; + this.pre = this.records; + this.post = null; + this._location.current = null; + } + + deleted(): void { + this.Toaster.toast('Tape successfully deleted.'); + this.records.splice(this.records.indexOf(this.current), 1); + this.select(null); } static directive(): angular.IDirective { return { - controller: Archive, - controllerAs: 'state', - bindToController: true, - scope: {}, - templateUrl: '/mtna/archive-template.html' + controller : Archive, + controllerAs : 'state', + bindToController : true, + scope : {}, + templateUrl : '/mtna/archive-template.html' }; } - static $inject: string[] = ['$q', 'RecordResource']; + static $inject: string[] = [ + '$scope', + '$q', + '$anchorScroll', + '$timeout', + 'RecordResource', + ToastService.serviceName, + '$http', + LocationService.serviceName, + '$timeout' + ]; static $depends: string[] = [ RecordModule.name, RecordViewer.module.name, Searchbar.module.name, - 'ngMaterial' + ElemClick.module.name, + LocationService.module.name, + ToastService.module.name, + 'ngMaterial', + 'angular.filter' ]; - static module: angular.IModule = angular.module( - 'mtna.archive', Archive.$depends - ).directive('archive', Archive.directive); + static module: angular.IModule = + angular.module('mtna.archive', Archive.$depends) + .directive('archive', Archive.directive); } diff --git a/src/client/mtna/archive-style.scss b/src/client/mtna/archive-style.scss index 9aab28d..0782bfe 100644 --- a/src/client/mtna/archive-style.scss +++ b/src/client/mtna/archive-style.scss @@ -7,6 +7,10 @@ md-content.md-default-theme { > md-card { margin-left: auto; margin-right: auto; + + md-list-item:nth-child(odd) { + background-color: #fafafa; + } } } @@ -25,6 +29,11 @@ md-toolbar { } } +md-list-item { + color:red; + height:5px; +} + input.search { color: $white; } diff --git a/src/client/mtna/archive-template.jade b/src/client/mtna/archive-template.jade index d039ee9..acf9214 100644 --- a/src/client/mtna/archive-template.jade +++ b/src/client/mtna/archive-template.jade @@ -1,9 +1,9 @@ -// Sidenav - div.relative(role="main" - layout="column" layout-fill + layout="column" layout-fill ) - md-button.md-fab.md-fab-bottom-right(ng-click="state.addTape()") + md-button.md-fab.md-fab-bottom-right( + ng-show="state.current == null" ng-click="state.addTape()" + ) md-icon library_add section.md-whiteframe-glow-z1 @@ -12,41 +12,46 @@ div.relative(role="main" // Page transitioning md-progress-linear( - md-mode="{{ state.saving ? 'indeterminate' : 'determinate' }}" value="100" + md-mode="{{ state.inFlight ? 'indeterminate' : 'determinate' }}" value="100" ) - md-content.md-default-theme(flex md-scroll-y) + md-content.md-default-theme( flex md-scroll-y elem-click="state.collapse()" ) // main content - - md-card( + + md-card.firefox-tweak( ng-if="state.pre.length > 0" flex-gt-sm="90" flex-gt-md="80" ) md-list - md-list-item(ng-repeat="item in state.pre track by item.id") - record-viewer( - ng-click="state.select(item)" - record="item" - ) - md-divider + md-subheader.md-no-sticky(ng-repeat-start="(key,value) in state.pre | groupBy: 'family' ") + h2 {{ key }} + md-list-item(id="record-{{item.id}}" ng-repeat="item in value track by item.id") + record-viewer( + ng-click="state.select(item)" + record="item" + ) + md-divider(ng-repeat-end) - md-card.md-padding( + md-card.md-padding.firefox-tweak(id="record-{{state.current.id}}" ng-if="state.current" flex-gt-sm="95" flex-gt-md="85" ) record-viewer( record="state.current" selected="true" done-editing="state.save(state.current)" + remove-record="state.deleted()" ) - md-card( + md-card.firefox-tweak( ng-if="state.post.length > 0" flex-gt-sm="90" flex-gt-md="80" ) md-list - md-list-item(ng-repeat="item in state.post track by item.id") - record-viewer( - ng-click="state.select(item)" - record="item" - ) - md-divider + md-subheader.md-no-sticky(ng-repeat-start="(key,value) in state.post | groupBy: 'family' ") + h2 {{ key }} + md-list-item(id="record-{{item.id}}" ng-repeat="item in value track by item.id") + record-viewer( + ng-click="state.select(item)" + record="item" + ) + md-divider(ng-repeat-end) diff --git a/src/client/mtna/elem-click/elem-click-directive.ts b/src/client/mtna/elem-click/elem-click-directive.ts new file mode 100644 index 0000000..cedde70 --- /dev/null +++ b/src/client/mtna/elem-click/elem-click-directive.ts @@ -0,0 +1,27 @@ +export class ElemClick { + static directive(): angular.IDirective { + return { + restrict: 'A', + scope: { + clickHandler: `&${ElemClick.selector}` + }, + link: function(scope: angular.IScope, element: angular.IAugmentedJQuery) { + element.on('click', function(event) { + if (event.target !== element[0]) { + return; + } + scope.$apply(function() { + (scope).clickHandler(); + }); + }); + } + }; + } + + static selector: string = 'elemClick'; + static $depends: string[] = []; + + static module: angular.IModule = angular.module( + 'mtna.elem-click', ElemClick.$depends + ).directive(ElemClick.selector, ElemClick.directive); +} diff --git a/src/client/mtna/location/location-service.ts b/src/client/mtna/location/location-service.ts new file mode 100644 index 0000000..eccf646 --- /dev/null +++ b/src/client/mtna/location/location-service.ts @@ -0,0 +1,107 @@ +import { Record } from '../../../shared/record/record'; +import { dToS, sToD } from '../../util/date'; + +export class LocationService { + private _queryString: string = ''; + private _startDate: string = ''; + private _endDate: string = ''; + private _recordId: string = ''; + + private _dateRegex: RegExp = /(\d\d\d\d-\d\d-\d\d)?\+(\d\d\d\d-\d\d-\d\d)?/; + + /** + * The URLs for archives look like: + * + * /this+is+a+query+with+plus+for+space/01-02-1988+01-04-1988/tape_id + */ + constructor(private _location: ng.ILocationService) { + const path = this._location.path() || '//+/'; + const pathParts = path.substring(1).split('/'); + if (pathParts.length === 3) { + // /query/start+end/id + this._queryString = this.decodeQueryString(pathParts[0]); + this.dates = pathParts[1]; + this._recordId = pathParts[2]; + } else if (pathParts.length === 2) { + if (this._dateRegex.test(pathParts[0])) { + // /start+end/id? + this.dates = pathParts[0]; + } else { + // /query/id? + this._queryString = this.decodeQueryString(pathParts[0]); + } + this._recordId = pathParts[1] || ''; + } else if (pathParts.length === 1) { + // /id? + this._recordId = pathParts[0] || ''; + } + } + + set dates(s: string) { + const dateParts = s.split('+'); + this._startDate = dateParts[0]; + this._endDate = dateParts[1]; + } + + get hasRecordId(): boolean { + return this._recordId !== ''; + } + + get hasSearch(): boolean { + return this._queryString !== '' || + this._startDate !== '' || + this._endDate !== ''; + } + + get queryString(): string { + return this._queryString; + } + + set queryString(query: string) { + this._queryString = query; + this._location.path(this.buildPath()); + } + + set current(record: Record) { + this._recordId = record ? record.id : ''; + this._location.path(this.buildPath()); + } + + get currentId(): string { + return this._recordId; + } + + set startDate(d: Date) { + this._startDate = d ? dToS(d) : ''; + } + get startDate(): Date { + return sToD(this._startDate); + } + + set endDate(d: Date) { + this._endDate = d ? dToS(d) : ''; + } + + get endDate(): Date { + return sToD(this._endDate); + } + + buildPath(): string { + const datePart = `${this._startDate}+${this._endDate}`; + const recordId = this.hasRecordId ? `${this._recordId}` : ''; + return `/${this.encodeQueryString()}/${datePart}/${recordId}`.replace('//', '/'); + } + + decodeQueryString(s: string): string { + return s.replace('+', ' '); + } + + encodeQueryString(): string { + return this._queryString.replace(' ', '+'); + } + + static $inject: string[] = ['$location']; + static serviceName: string = 'ArchiveLocationService'; + static module: ng.IModule = angular.module('LocationServiceModule', []) + .service(LocationService.serviceName, LocationService); +} diff --git a/src/client/mtna/record/associate/associate-component.ts b/src/client/mtna/record/associate/associate-component.ts new file mode 100644 index 0000000..904e797 --- /dev/null +++ b/src/client/mtna/record/associate/associate-component.ts @@ -0,0 +1,103 @@ +import { IncomingService } from './incoming-service'; +import { AssociateService } from './associate-service'; + +type IVideo = { + name: string, + selected: boolean +} + +class AssociateModalController { + static $inject: string[] = ['$mdDialog']; + public isAllSelected: boolean = false; + public videoNames: string[]; + public videos: IVideo[]; + constructor( + private _$mdDialog: angular.material.IDialogService + ) { + this.videos = this.videoNames.map(name => { + return { + name, + selected: false + }; + }); + } + + public cancel() { + this._$mdDialog.cancel(); + } + + public associateSelection() { + const selectedVideos = this.videos.filter(video => video.selected) + .map(video => video.name); + this._$mdDialog.hide(selectedVideos); + } + + public selectAll() { + this.videos = this.videos.map((video) => { + return { + name: video.name, + selected: this.isAllSelected + }; + }); + } +} + +export class Associate { + public recordId: string; + + static $inject: string[] = ['$mdDialog', 'incomingService', 'associateService']; + constructor( + private _$mdDialog: ng.material.IDialogService, + private _incomingService: IncomingService, + private _associateService: AssociateService + ) {} + + openVideoList() { + this._incomingService.getIncoming().then((videoNames: string[]) => { + this._$mdDialog.show({ + controller: AssociateModalController, + controllerAs: 'state', + bindToController: true, + locals: {videoNames}, + templateUrl: '/mtna/record/associate/incoming-template.html' + }) + .then(this.associateVideos.bind(this)); + }); + } + + onUpdateRecord(record: any) { + throw new Error('Abstract method onUpdateRecord called with no overload.'); + } + + associateVideos(videoNames: string[]) { + if (!videoNames.length) { + return; + } + this._associateService.associateVideos(this.recordId, videoNames) + .then((updatedRecordData) => { + this.onUpdateRecord({updatedRecordData}); + }); + } + + static directive(): angular.IDirective { + return { + template: `Add Video`, + scope: {}, + controller: Associate, + controllerAs: 'state', + bindToController: { + recordId: '@', + onUpdateRecord: '&' + } + }; + } + static $depends: string[] = [ + 'ngMaterial', + IncomingService.module.name, + AssociateService.module.name + ]; + static module: angular.IModule = angular.module( + 'mtna.record.associate', Associate.$depends + ) + .directive('associate', Associate.directive); +} diff --git a/src/client/mtna/record/associate/associate-service.ts b/src/client/mtna/record/associate/associate-service.ts new file mode 100644 index 0000000..72e21e4 --- /dev/null +++ b/src/client/mtna/record/associate/associate-service.ts @@ -0,0 +1,21 @@ +export class AssociateService { + static $inject: string[] = ['$http']; + constructor( + private _$http: angular.IHttpService + ) {} + + public associateVideos(recordId: string, videoNames: string[]) { + return this._$http({ + method: 'POST', + url: `/api/records/${recordId}/associate`, + data: videoNames + }) + .then((res) => res.data); + } + + static $depends: string[] = []; + static module: angular.IModule = angular.module( + 'mtna.record.associateService', AssociateService.$depends + ) + .service('associateService', AssociateService); +} diff --git a/src/client/mtna/record/associate/incoming-service.ts b/src/client/mtna/record/associate/incoming-service.ts new file mode 100644 index 0000000..e99ae15 --- /dev/null +++ b/src/client/mtna/record/associate/incoming-service.ts @@ -0,0 +1,18 @@ +export class IncomingService { + static $inject: string[] = ['$http']; + constructor( + private _$http: angular.IHttpService + ) {} + + public getIncoming() { + return this._$http({ + url: '/api/videos/incoming', + method: 'GET' + }).then(res => res.data); + } + + static $depends: string[] = []; + static module: angular.IModule = angular.module( + 'mtna.record.associate.incomingService', IncomingService.$depends + ).service('incomingService', IncomingService); +} diff --git a/src/client/mtna/record/associate/incoming-style.scss b/src/client/mtna/record/associate/incoming-style.scss new file mode 100644 index 0000000..de6e919 --- /dev/null +++ b/src/client/mtna/record/associate/incoming-style.scss @@ -0,0 +1,7 @@ +.incoming-dialog { + md-toolbar { + h3 { + padding-left: 20px; + } + } +} diff --git a/src/client/mtna/record/associate/incoming-template.jade b/src/client/mtna/record/associate/incoming-template.jade new file mode 100644 index 0000000..d3081a2 --- /dev/null +++ b/src/client/mtna/record/associate/incoming-template.jade @@ -0,0 +1,12 @@ +md-dialog.incoming-dialog(flex="50") + md-toolbar + h3 Link Video + md-dialog-content.md-dialog-content + p This is a list of all videos in the incoming folder. Contact your system administrator for more information. + md-list + md-list-item(ng-repeat="video in state.videos") + p {{ video.name }} + md-checkbox(ng-model="video.selected") + md-dialog-actions + md-button(ng-click="state.cancel()") Cancel + md-button.md-primary(ng-click="state.associateSelection()") Link diff --git a/src/client/mtna/record/record-component.ts b/src/client/mtna/record/record-component.ts index 7e92a10..58e153c 100644 --- a/src/client/mtna/record/record-component.ts +++ b/src/client/mtna/record/record-component.ts @@ -1,45 +1,167 @@ -import { - Record, Story -} from '../../../shared/record/record'; +import {Record, Story, Image, Video} from '../../../shared/record/record'; +import {Associate} from './associate/associate-component'; +import {Archive} from '../archive-component'; + +import {FileReaderService} from '../../util/fileinput/fileinput-service'; +import {FileInput} from '../../util/fileinput/fileinput-directive'; +import {SetFocus} from '../../util/setfocus/setfocus-directive'; +import {ToastService} from '../toast/toast-service'; export class RecordViewer { public record: Record; - public editing: boolean; + public archive: Archive; + public editing: boolean = false; + private editingStory: Story = null; public selected: boolean; public doneEditing: any; + public removeRecord: any; + public savedLast: boolean; public newStory: Story = null; + public image: File = null; + + static $inject: string[] = [ + FileReaderService.serviceName, + '$http', + '$scope', + '$mdDialog', + ToastService.serviceName + ]; + constructor(private _fileReader: FileReaderService, + private _http: ng.IHttpService, private _scope: ng.IScope, + private $dialog: ng.material.IDialogService, + private Toaster: ToastService) {} + + updateRecord({updatedRecordData}) { this.record.merge(updatedRecordData); } + + addStory(): void { + const newStory = new Story('', new Date); + this.record.addStories([ newStory ]); + this.toggleEditing(newStory); + } + + removeStory(story: Story): void { + this.record.removeStory(story); + this.doneEditing(); + } + + toggleEditing(story: Story): void { + if (this.editingStory != null) { + // Were editing something, not anymore! + this.doneEditing(); + } + this.savedLast = this.record.stories.indexOf(this.editingStory) === + this.record.stories.length - 1; + if (this.editingStory === story) { + this.editingStory = null; + } else { + this.editingStory = story; + } + } + + isEditing(story: Story): boolean { return this.editingStory === story; } + + done() { + if (this.editing) { + this.doneEditing().then(() => { this.editing = false; }); + } + } - constructor() { - this.resetStory(); + loadImage() { + this._fileReader.readAsDataURL(this.image, this._scope) + .then((result: string) => { this.saveImage(result); }); } - public addStory(story: Story): void { - this.record.addStories([story]); - this.resetStory(); + saveImage(image: string) { + this._http.post(`/api/records/${this.record.id}/upload`, {image}) + .then((result: any) => { + this.image = null; + this.record.images.push(Image.fromObj(result.data)); + this.doneEditing(); + }); } - private resetStory(): void { - this.newStory = new Story('', new Date); + delete (): void { + this.$dialog + .show(this.$dialog.confirm() + .htmlContent( + `

Are you sure you want to delete this tape, permanently?

+

This will remove ${this.record.images.length} + image${this.record.images.length === 1 ? '' : 's'}, and + move ${this.record.videos.length} + video${this.record.videos.length === 1 ? '' : 's'} to + incoming.

`) + .ok('Yes') + .cancel('No')) + .then(() => { + this._http.delete(`/api/records/${this.record.id}`) + .then(() => { this.removeRecord(); }) + .catch((err: any) => { this.Toaster.toast(err); }); + }); } - static directive(): angular.IDirective { + removeImage(image: Image): void { + this.$dialog.show(this.$dialog.confirm() + .textContent( + 'Are you sure you want to delete this image?') + .ok('Ok') + .cancel('Cancel')) + .then(() => { + this._http.post(`/api/records/${this.record.id}/remove`, + image.toJSON()) + .then(() => { + this.record.removeImage(image); + this.doneEditing(); + }) + .catch((err: any) => { this.Toaster.toast(err); }); + }); + } + + safeVideoUrl(video: Video): string { return '/images/' + video.path; } + + static directive($timeout: ng.ITimeoutService): angular.IDirective { return { - controller: RecordViewer, - controllerAs: 'state', - bindToController: true, - scope: { - record: '=', - selected: '=', - doneEditing: '&' + controller : RecordViewer, + controllerAs : 'state', + bindToController : true, + scope : { + record : '=', + selected : '=', + doneEditing : '&', + removeRecord : '&', }, - templateUrl: '/mtna/record/record-template.html' + templateUrl : '/mtna/record/record-template.html', + link : { + post : function($scope: ng.IScope, $element: JQuery) { + if ($scope['state'].record.isNewTape === true) { + $scope['state'].record.isNewTape = false; + $scope['state'].editing = true; + } + if ($scope['state'].selected) { + $timeout(function() { + let input: HTMLInputElement = + $element[0].querySelector( + '[ng-model="state.record.label"]'); + if (input) { + input.focus(); + } + }); + } + } + } }; } - static $inject: string[] = []; - static $depends: string[] = []; - static module: angular.IModule = angular.module( - 'mtna.recordViewer', RecordViewer.$depends - ).directive('recordViewer', RecordViewer.directive); + static $depends: string[] = [ + FileReaderService.module.name, + FileInput.module.name, + Associate.module.name, + SetFocus.module.name, + ToastService.module.name, + 'ngSanitize' + ]; + static module: angular.IModule = + angular.module('mtna.recordViewer', RecordViewer.$depends) + .directive('recordViewer', [ '$timeout', RecordViewer.directive ]); } + diff --git a/src/client/mtna/record/record-resource.ts b/src/client/mtna/record/record-resource.ts index 237f135..0a5863e 100644 --- a/src/client/mtna/record/record-resource.ts +++ b/src/client/mtna/record/record-resource.ts @@ -8,14 +8,14 @@ export interface IRecordResource export interface RecordResource extends ng.resource.IResourceClass { - update(params: any, record: IRecord): ng.IPromise; + update(params: any, record: IRecord): {$promise: ng.IPromise}; } function recordResourceFactory( $resource: ng.resource.IResourceService ): RecordResource { return $resource('/api/records/:id', {id: '@id'}, { - 'update': { method: 'PUT' } + 'update': { method: 'PUT', params: { replaceId: '@baseId' } } }); } @@ -23,3 +23,4 @@ export default angular .module('mtna.record', ['ngResource']) .factory('RecordResource', ['$resource', recordResourceFactory]) ; + diff --git a/src/client/mtna/record/record-style.scss b/src/client/mtna/record/record-style.scss index b56e391..f8896de 100644 --- a/src/client/mtna/record/record-style.scss +++ b/src/client/mtna/record/record-style.scss @@ -7,7 +7,81 @@ record-viewer { user-select: none; } - h2 { + md-select[ng-model="state.record.medium"] { + padding-bottom: 24px; + } + + h2, h3 { font-weight: 400; + margin: 0; + } + + h3 { + padding-top: 8px; + } + + .super { + margin: 10px 0; + } + + .md-chips { + box-shadow: none; + } + + .recordImage { + position: relative; + + &:hover { + .deleteImageWrapper { + display: block; + } + } + .deleteImageWrapper { + border-radius: 3px 0 0 0; + display: none; + position: absolute; + bottom: 0; + right: 0; + background: rgba(255, 255, 255, 1); + + .md-button { + margin: 0; + } + } + + } + + video { + display: -webkit-flex; + display: -ms-flexbox; + display: flex; + -webkit-flex: 0 0 auto; + -ms-flex: 0 0 auto; + flex: 0 0 auto; + width: 100%; + height: auto; + padding-right: 24px; + padding-bottom: 24px; } + + table { + tr { + td, th { + &:last-child { + width: 36px; + + .md-button { + margin: 0; + min-width: 24px; + } + } + } + } + } +} + +sup { + text-transform: uppercase; + font-weight: bold; + color:gray; } diff --git a/src/client/mtna/record/record-template.jade b/src/client/mtna/record/record-template.jade index 10664de..4625967 100644 --- a/src/client/mtna/record/record-template.jade +++ b/src/client/mtna/record/record-template.jade @@ -3,69 +3,135 @@ div(layout="column") layout="row" layout-align="space-between end" ng-if="!state.selected" ) - h2 {{ state.record.label }} - div {{ state.record.family }} - div {{ state.record.medium }} - md-chips(ng-if="!state.selected") - md-chip {{ state.record.stories.length }} {{ state.record.stories.length == 1 ? 'Story' : 'Stories' }} - form(name="recordEditor" ng-if="state.selected") - div(layout="row" layout-align="space-between end") - md-input-container(style = "width: 20em;") - label Label - input(type="text" ng-model="state.record.label" placeholder="Tape Label") - md-input-container(style = "width: 20em;") - label Family - input(type="text" ng-model="state.record.family" placeholder="Tape Family") - md-input-container(style = "width: 10em;") - label Medium - md-select(ng-model="state.record.medium") - md-option(value="1\" Tape") 1" Tape - md-option(value="3/4\" Tape") 3/4" Tape - md-option(value="Film") Film - md-button(ng-click="state.doneEditing()") Save - table.mtna-table(ng-if="state.selected") - thead - tr - th Slug - th Date - th Format - th Runtime - th Reporter - th Photographer - tbody - tr(ng-repeat="story in state.record.stories") - td {{ story.slug }} - td {{ story.date | date:'fullDate' }} - td {{ story.format || "Unknown" }} - td {{ story.runtime || "Unknown" }} - td {{ story.reporter || "Unknown" }} - td {{ story.photographer || "Unknown" }} - tfoot - tr - td - md-input-container - label Slug - input(ng-model="state.newStory.slug") - td - md-datepicker(ng-model="state.newStory.date" md-placeholder="Story date") - td - md-input-container - label Format - input(ng-model="state.newStory.format") - td - md-input-container - label Runtime - input(ng-model="state.newStory.runtime") - td - md-input-container - label Reporter - input(ng-model="state.newStory.reporter") - td - md-input-container - label Photographer - input(ng-model="state.newStory.photographer") - tr - td(colspan="5") - td - md-button(ng-click="state.addStory(state.newStory)") - md-icon add + div(layout="column" flex) + p.label {{ state.record.label }} + div(layout="column" flex) + p(ng-if='state.record.first') + | {{ state.record.first | date : 'mediumDate' }} + div(layout="column" flex) + p(ng-if='state.record.last') + | {{ state.record.last | date: 'mediumDate' }} + .column(layout="row" flex ng-if="!state.selected") + p + span + md-icon(ng-if="state.record.stories.length") list + md-icon(ng-if="!state.record.stories.length") remove + span + md-icon(ng-if="state.record.images.length") photo + md-icon(ng-if="!state.record.images.length") remove + span + md-icon(ng-if="state.record.notes") insert_comment + md-icon(ng-if="!state.record.notes.length") remove + span + md-icon(ng-if="state.record.videos.length") ondemand_video + md-icon(ng-if="!state.record.videos.length") remove + div(ng-if="state.selected") + form(name="recordEditor") + div(layout="row" layout-xs="column" layout-align="space-between end" ng-if="state.editing") + md-input-container(flex="15") + label Label + input(type="text" ng-model="state.record.label" placeholder="Tape Label") + md-input-container(flex="15") + label Family + input(type="text" ng-model="state.record.family" placeholder="Tape Family") + md-input-container(flex="10") + label Medium + md-select(ng-model="state.record.medium") + md-option(value="1\" Tape") 1" Tape + md-option(value="3/4\" Tape") 3/4" Tape + md-option(value="Film") Film + md-input-container(flex="15") + label Start Date + input(type="date" ng-model="state.record.first") + md-input-container(flex="15") + label End + input(type="date" ng-model="state.record.last") + div(flex) + md-input-container + md-button(ng-click="state.done() ; recordEditor.$setPristine()") Done + md-button.md-warn(ng-disabled="!recordEditor.$pristine" ng-click="state.delete()") Delete + div(layout="row" layout-xs="column" layout-align="space-between start" ng-if="!state.editing") + div(flex="30") + sup.md-subheader + | {{ state.record.family }} + h2 + | {{ state.record.label }} + h3(flex="20" hide-gt-xs show-xs) {{ state.record.first | date:'shortDate' }} + h3(flex="20" hide-gt-xs show-xs) {{ state.record.last | date: 'shortDate' }} + h3(flex="15" hide-xs) {{ state.record.first | date:'mediumDate' }} + h3(flex="15" hide-xs) {{ state.record.last | date:'mediumDate' }} + h3(flex="10") {{ state.record.medium }} + div(flex) + md-input-container + md-button(ng-click="state.editing = true") Edit + associate(record-id="{{state.record.id}}" on-update-record="state.updateRecord({updatedRecordData: updatedRecordData})") + file-input(model="state.image" on-change="state.loadImage()") + div(layout="row" layout-md="column" layout-sm="column" layout-xs="column") + div(flex-gt-md=25 layout="column" layout-md="row" layout-sm="row") + div(flex-md=50 ng-repeat="video in state.record.videos") + video(controls) + source(ng-src="{{ state.safeVideoUrl(video) }}" type="video/mp4") + | Can't play video: {{ video | json }} + //- md-input-container + div(flex-md=50 ng-if="!state.editing") + md-input-container(style = "width: 15em;") + h4.md-subheader Notes + p {{state.record.notes}} + div(flex-md=50 ng-if="state.editing") + md-input-container(style = "width: 15em;") + label Notes + textarea(ng-model="state.record.notes" placeholder="Notes") + div(flex-gt-md=75 layout="column") + table.table.table-striped.table-bordered + thead + tr + th Slug + th Date + th Format + th Runtime + th + tbody + tr(ng-repeat="story in state.record.stories") + td(ng-if="!state.isEditing(story)") + | {{ story.slug }} + td(ng-if="state.isEditing(story)") + md-input-container + label Slug + input(ng-model="story.slug" set-focus="state.isEditing(story)") + td(ng-if="!state.isEditing(story)") + | {{ story.date | date:'fullDate' }} + td(ng-if="state.isEditing(story)") + md-input-container + label Story date + input(type="date" ng-model="story.date") + td(ng-if="!state.isEditing(story)") + | {{ story.format || "Unknown" }} + td(ng-if="state.isEditing(story)") + md-input-container + label Format + input(ng-model="story.format") + td(ng-if="!state.isEditing(story)") + | {{ story.runtime || "Unknown" }} + td(ng-if="state.isEditing(story)") + md-input-container + label Runtime + input(ng-model="story.runtime") + td(ng-if="state.isEditing(story)") + md-button(ng-if="story.slug != ''" ng-click="state.toggleEditing(story)") + md-icon save + md-button.md-warn(ng-if="story.slug == ''" ng-click="state.removeStory(story)") + md-icon delete + td(ng-if="!state.isEditing(story)") + md-button(ng-click="state.toggleEditing(story)") + md-icon edit + tfoot + td(colspan="4") + td + md-button(set-focus="state.savedLast" ng-click="state.addStory()") + md-icon add + .recordImage(ng-repeat="image in state.record.images" flex) + img.img-responsive.img-rounded.center-block( + ng-src="/images/{{ image.path }}" + ) + .deleteImageWrapper + md-button(ng-click="state.removeImage(image)") Delete diff --git a/src/client/mtna/searchbar/searchbar-component.ts b/src/client/mtna/searchbar/searchbar-component.ts index 366e7e6..3c2a595 100644 --- a/src/client/mtna/searchbar/searchbar-component.ts +++ b/src/client/mtna/searchbar/searchbar-component.ts @@ -2,21 +2,34 @@ import { SearchService, ISearchQuery } from './searchbar-service'; +import { + LocationService +} from '../location/location-service'; + export class Searchbar { public searching: boolean = false; public searchString: string = ''; + public after: Date; + public before: Date; + static $inject: string[] = [SearchService.serviceName, '$log', LocationService.serviceName]; constructor( private _searchService: SearchService, - private _$log: ng.ILogService + private _$log: ng.ILogService, + _location: LocationService ) { + this.searchString = _location.queryString; + this.after = _location.startDate; + this.before = _location.endDate; } search() { const searchQuery: ISearchQuery = { - query: this.searchString + query: this.searchString, + before: this.before, + after: this.after }; - this._searchService.search(searchQuery).then(this._$log.info); + this._searchService.search(searchQuery); } static directive(): angular.IDirective { @@ -30,10 +43,10 @@ export class Searchbar { } static selector: string = 'mtnaSearchbar'; - static $inject: string[] = [SearchService.serviceName, '$log']; static $depends: string[] = [ 'ngMaterial', - SearchService.module.name + SearchService.module.name, + LocationService.module.name ]; static module: angular.IModule = angular.module( 'mtna.archive.searchbar', Searchbar.$depends diff --git a/src/client/mtna/searchbar/searchbar-service.spec.ts b/src/client/mtna/searchbar/searchbar-service.spec.ts index 885fbd3..18c49f9 100644 --- a/src/client/mtna/searchbar/searchbar-service.spec.ts +++ b/src/client/mtna/searchbar/searchbar-service.spec.ts @@ -2,7 +2,7 @@ import { expect } from 'chai'; import { - SearchService + SearchService, ISearchQuery } from './searchbar-service'; const mock = angular.mock; @@ -11,6 +11,8 @@ describe('SearchbarService', () => { let sut: SearchService; let $httpBackend: ng.IHttpBackendService; + const apiUrl = '/api/search'; + beforeEach(mock.module(SearchService.module.name)); beforeEach(mock.inject(( mtnaSearchService: SearchService, @@ -24,14 +26,42 @@ describe('SearchbarService', () => { expect(sut).to.exist; }); - describe('search', () => { + describe.skip('search', () => { it('should call the correct url with the correct parameters', () => { - $httpBackend.expectGET('/api/search?query=test-search').respond(200); - sut.search({query: 'test-search'}); + $httpBackend.expectGET(`${apiUrl}?query=test-search`).respond(200); + sut.search({query: 'test-search', before: null, after: null}); + $httpBackend.flush(); + }); + + it('should convert the before date to a string', () => { + const searchParams: ISearchQuery = { + query: 'test-search', + before: new Date(), + after: null + }; + const beforeDateUTC = encodeURI(searchParams.before.toISOString()); + $httpBackend.expectGET( + `${apiUrl}?before=${beforeDateUTC}&query=test-search` + ).respond(200); + sut.search(searchParams); $httpBackend.flush(); }); + it('should convert the after date to a string', () => { + const searchParams: ISearchQuery = { + query: 'test-search', + before: null, + after: new Date() + }; + const afterDateUTC = encodeURI(searchParams.after.toUTCString()) + .replace(/%20/g, '+'); + $httpBackend.expectGET( + `${apiUrl}?after=${afterDateUTC}&query=test-search` + ).respond(200); + sut.search(searchParams); + $httpBackend.flush(); + }); }); }); diff --git a/src/client/mtna/searchbar/searchbar-service.ts b/src/client/mtna/searchbar/searchbar-service.ts index d00d99e..0f707ba 100644 --- a/src/client/mtna/searchbar/searchbar-service.ts +++ b/src/client/mtna/searchbar/searchbar-service.ts @@ -1,23 +1,53 @@ +import { dToS } from '../../util/date'; + export interface ISearchQuery { query: string; + before: Date; + after: Date; +} + +interface ISearchParams { + query: string; + before: string; + after: string; +} + +class SearchQuery implements ISearchQuery { + public query: string = ''; + public before: Date = null; + public after: Date = null; + + constructor( + sq: ISearchQuery + ) { + this.query = sq.query; + this.before = sq.before; + this.after = sq.after; + } + toJSON(): ISearchParams { + return { + query: this.query, + before: this.before ? dToS(this.before) : null, + after: this.after ? dToS(this.after) : null, + }; + } } +export const SEARCH_EVENT = 'mtnaSearchService NewSearch'; + export class SearchService { - constructor(private _http: angular.IHttpService) { + constructor(private _scope: angular.IScope) { } - search(searchQuery: ISearchQuery): angular.IPromise { - return this._http({ - url: '/api/search', - method: 'GET', - params: searchQuery - }); + search(sq: ISearchQuery): void { + const searchQuery = new SearchQuery(sq); + this._scope.$broadcast(SEARCH_EVENT, searchQuery); } static serviceName: string = 'mtnaSearchService'; - static $inject: string[] = ['$http']; + static $inject: string[] = ['$rootScope']; static $depends: string[] = []; static module: angular.IModule = angular.module( diff --git a/src/client/mtna/searchbar/searchbar-style.scss b/src/client/mtna/searchbar/searchbar-style.scss new file mode 100644 index 0000000..579fa09 --- /dev/null +++ b/src/client/mtna/searchbar/searchbar-style.scss @@ -0,0 +1,19 @@ +// todo keep an eye on https://github.com/angular/material/issues/6338 + +mtna-searchbar { + .md-toolbar-tools { + md-input-container { + label { + color: rgba(255, 255, 255, 0.76); + } + + input { + color: white; + } + + .md-errors-spacer { + display: none; + } + } + } +} diff --git a/src/client/mtna/searchbar/searchbar-template.jade b/src/client/mtna/searchbar/searchbar-template.jade index 441add8..b18bcb4 100644 --- a/src/client/mtna/searchbar/searchbar-template.jade +++ b/src/client/mtna/searchbar/searchbar-template.jade @@ -1,16 +1,23 @@ -md-toolbar(ng-show="!state.searching") +md-toolbar(ng-show="!state.searching" ng-click="state.searching = !state.searching") .md-toolbar-tools h1 MT News Archive span(flex) - md-button(aria-label="search" ng-click="state.searching = !state.searching") + md-button(aria-label="search") md-icon search md-toolbar.md-hue-2(ng-show="state.searching") - .md-toolbar-tools - md-button(ng-click="state.searching = !state.searching" aria-label="Back") - md-icon arrow_back - h1(flex="10") Back - md-input-container(md-theme="input" flex) - label   - input.search(ng-model="state.searchString" placeholder="enter search") - md-button(aria-label="search" ng-click="state.search()") - md-icon search + form(ng-submit="state.search()") + .md-toolbar-tools + md-button(ng-click="state.searching = !state.searching" aria-label="Back") + md-icon arrow_back + h1(flex="10") Back + md-input-container(flex) + label Search + input.search(ng-model="state.searchString") + md-input-container + label After + input(ng-model="state.after" type="date") + md-input-container + label Before + input(ng-model="state.before" type="date") + md-button(aria-label="search" ng-click="state.search()") + md-icon search diff --git a/src/client/mtna/toast/toast-service.ts b/src/client/mtna/toast/toast-service.ts new file mode 100644 index 0000000..7c5f981 --- /dev/null +++ b/src/client/mtna/toast/toast-service.ts @@ -0,0 +1,37 @@ +export class ToastService { + constructor(private _mdToast: angular.material.IToastService) { } + + private _position: string = 'bottom right'; + get position(): string { return this._position; } + + _config( + message: string, + hideDelay: number + ): angular.material.ISimpleToastPreset { + return this._mdToast.simple() + .textContent(message) + .position(this.position) + .hideDelay(hideDelay) + .action('OK'); + } + + toast(message: string, hideDelay: number = 3000) { + let opts = this._config(message, hideDelay); + this._mdToast.show(opts); + } + + error(message: string) { + let opts = this._config(message, 0) + // TODO: Add this after 1.1.0 is available + // .highlightClass('md-warn') + ; + this._mdToast.show(opts); + } + + static serviceName: string = 'ToastService'; + static $inject: string[] = ['$mdToast']; + static $depends: string[] = ['ngMaterial']; + static module: angular.IModule = + angular.module(ToastService.serviceName, ToastService.$depends) + .service(ToastService.serviceName, ToastService); +} diff --git a/src/client/util/date.ts b/src/client/util/date.ts new file mode 100644 index 0000000..845bde9 --- /dev/null +++ b/src/client/util/date.ts @@ -0,0 +1,25 @@ +export function dPad(s: string): string { + if (s.length === 1) { + return '0' + s; + } + return s; +} +export function dToS(d: Date): string { + const month = dPad('' + (d.getMonth() + 1)); + const day = dPad('' + d.getDate()); + return `${d.getFullYear()}-${month}-${day}`; +} + +export function sToD(s: string): Date { + const parts = s.split('-'); + if (parts.length !== 3) { + return null; + } else { + return new Date( + parseInt(parts[0], 10), + parseInt(parts[1], 10) - 1, + parseInt(parts[2], 10) + ); + } +} + diff --git a/src/client/util/fileinput/fileinput-directive.ts b/src/client/util/fileinput/fileinput-directive.ts new file mode 100644 index 0000000..704cbce --- /dev/null +++ b/src/client/util/fileinput/fileinput-directive.ts @@ -0,0 +1,44 @@ +export class FileInput { + static $depends: string[] = []; + static module: ng.IModule = angular.module( + 'fileInput', FileInput.$depends + ).directive('fileInput', ['$parse', FileInput.directive]); + + static directive($parse: ng.IParseService): angular.IDirective { + return { + restrict: 'E', + template: ` + Add Image`, + replace: false, + scope: false, + link: { post: function( + scope: ng.IScope, + element: ng.IAugmentedJQuery, + attrs: ng.IAttributes + ) { + let input = element.find('input')[0]; + let modelGet = $parse(attrs['model']); + let modelSet = modelGet.assign; + let onChange = $parse(attrs['onChange']); + + scope['open'] = ($event: any): void => { + input.click(); + }; + + element.bind('change', (event: any) => { + scope.$apply(() => { + modelSet(scope, event.target.files[0]); + onChange(scope); + }); + }); + + scope.$watch(modelGet, (newVal: any) => { + if (!newVal) { + input.value = ''; + } + }); + }} + }; + } +} + diff --git a/src/client/util/fileinput/fileinput-service.ts b/src/client/util/fileinput/fileinput-service.ts new file mode 100644 index 0000000..13d6fc1 --- /dev/null +++ b/src/client/util/fileinput/fileinput-service.ts @@ -0,0 +1,66 @@ +export class FileReaderService { + static serviceName: string = 'FileReader'; + static $inject: string[] = ['$q']; + constructor(private _q: ng.IQService) {} + static $depends: string[] = []; + static module: angular.IModule = angular.module( + 'util.FileReaderService', FileReaderService.$depends + ).service(FileReaderService.serviceName, ['$q', FileReaderService]); + + readAsText(file: Blob|File, scope: ng.IScope) { + let deferred = this._q.defer(); + let reader = getReader(deferred, scope); + reader.readAsText(file); + return deferred.promise; + }; + readAsArrayBuffer(file: Blob|File, scope: ng.IScope) { + let deferred = this._q.defer(); + let reader = getReader(deferred, scope); + reader.readAsArrayBuffer(file); + return deferred.promise; + }; + readAsDataURL(file: Blob|File, scope: ng.IScope) { + let deferred = this._q.defer(); + let reader = getReader(deferred, scope); + reader.readAsDataURL(file); + return deferred.promise; + }; +} + +function getReader(deferred: ng.IDeferred, scope: ng.IScope) { + let reader = new FileReader(); + reader.onload = onLoad(reader, deferred.resolve, scope); + reader.onerror = onError(reader, deferred.reject, scope); + reader.onprogress = onProgress(scope); + return reader; +}; + +function onLoad( + r: FileReader, resolve: (a: any) => void, scope: ng.IScope +): (ev: Event) => any { + return function() { + scope.$apply(function() { + resolve(r.result); + }); + }; + }; + +function onError( + r: FileReader, reject: (a: any) => void, scope: ng.IScope +): (ev: Event) => any { + return function() { + scope.$apply(function() { + reject(r.result); + }); + }; +}; + +function onProgress(scope: ng.IScope): (ev: ProgressEvent) => any { + return function(event: {total: any, loaded: any}) { + scope.$broadcast('fileProgress', { + total: event.total, + loaded: event.loaded + }); + }; +}; + diff --git a/src/client/util/setfocus/setfocus-directive.ts b/src/client/util/setfocus/setfocus-directive.ts new file mode 100644 index 0000000..dc2b410 --- /dev/null +++ b/src/client/util/setfocus/setfocus-directive.ts @@ -0,0 +1,29 @@ +export class SetFocus { + static $depends: string[] = []; + static module: ng.IModule = angular.module( + 'SetFocus', SetFocus.$depends + ).directive('setFocus', ['$parse', SetFocus.directive]); + + static directive($parse: ng.IParseService): angular.IDirective { + return { + restrict: 'A', + replace: false, + scope: false, + link: { post: function( + scope: ng.IScope, + $element: ng.IAugmentedJQuery, + attrs: ng.IAttributes + ) { + let element = $element[0]; + let setFocus = $parse(attrs['setFocus']); + + scope.$watch(setFocus, (newVal: any, oldVal: any) => { + if (!!newVal) { + element.focus(); + } + }); + }} + }; + } +} + diff --git a/src/client/util/table.scss b/src/client/util/table.scss index 44e5a22..bdd839c 100644 --- a/src/client/util/table.scss +++ b/src/client/util/table.scss @@ -3,6 +3,10 @@ $grey_400: #ddd; $grey_138: rgb(138, 138, 138); $grey_33: rgb(33, 33, 33); +.firefox-tweak { + max-height: inherit; +} + table.mtna-table { border: 0; border-collapse: collapse; @@ -28,13 +32,12 @@ table.mtna-table { th, td { - padding: 0 24px; - + padding: 0 12px; } tbody { td { - padding: 24px; + padding: 12px; } tr:hover { diff --git a/src/scripts/glob.js b/src/scripts/glob.js deleted file mode 100644 index e6f7c76..0000000 --- a/src/scripts/glob.js +++ /dev/null @@ -1,13 +0,0 @@ -var glob = require('glob'); -var i = 1; -var matcher = new RegExp(__filename + "$"); -process.argv.forEach(function (_, j) { - if (matcher.test(_)) { - i = j; - } -}); -process.argv.slice(i).forEach(function (_) { return glob(_, printAll); }); -function printAll(err, files) { - files.map(function (_) { return process.stdout.write(_ + ' '); }); -} -//# sourceMappingURL=glob.js.map \ No newline at end of file diff --git a/src/scripts/glob.js.map b/src/scripts/glob.js.map deleted file mode 100644 index 4a7761b..0000000 --- a/src/scripts/glob.js.map +++ /dev/null @@ -1 +0,0 @@ -{"version":3,"file":"glob.js","sourceRoot":"","sources":["glob.ts"],"names":["printAll"],"mappings":"AAAA,IAAY,IAAI,WAAM,MAAM,CAAC,CAAA;AAE7B,IAAI,CAAC,GAAG,CAAC,CAAC;AACV,IAAI,OAAO,GAAG,IAAI,MAAM,CAAI,UAAU,MAAG,CAAC,CAAC;AAC3C,OAAO,CAAC,IAAI,CAAC,OAAO,CAAC,UAAC,CAAC,EAAE,CAAC;IACxB,EAAE,CAAC,CAAC,OAAO,CAAC,IAAI,CAAC,CAAC,CAAC,CAAC,CAAC,CAAC;QAAC,CAAC,GAAG,CAAC,CAAC;IAAC,CAAC;AACjC,CAAC,CAAC,CAAC;AACH,OAAO,CAAC,IAAI,CAAC,KAAK,CAAC,CAAC,CAAC,CAAC,OAAO,CAAC,UAAA,CAAC,IAAI,OAAA,IAAI,CAAC,CAAC,EAAE,QAAQ,CAAC,EAAjB,CAAiB,CAAC,CAAC;AAEtD,kBAAkB,GAAQ,EAAE,KAAe;IACzCA,KAAKA,CAACA,GAAGA,CAACA,UAAAA,CAACA,IAAIA,OAAAA,OAAOA,CAACA,MAAMA,CAACA,KAAKA,CAACA,CAACA,GAAGA,GAAGA,CAACA,EAA7BA,CAA6BA,CAACA,CAACA;AAChDA,CAACA"} \ No newline at end of file diff --git a/src/scripts/glob.ts b/src/scripts/glob.ts deleted file mode 100644 index f9a46d8..0000000 --- a/src/scripts/glob.ts +++ /dev/null @@ -1,12 +0,0 @@ -import * as glob from 'glob'; - -let i = 1; -let matcher = new RegExp(`${__filename}$`); -process.argv.forEach((_, j) => { - if (matcher.test(_)) { i = j; } -}); -process.argv.slice(i).forEach(_ => glob(_, printAll)); - -function printAll(err: any, files: string[]): void { - files.map(_ => process.stdout.write(_ + ' ')); -} diff --git a/src/scripts/templateCache.js b/src/scripts/templateCache.js deleted file mode 100644 index 27e8b1c..0000000 --- a/src/scripts/templateCache.js +++ /dev/null @@ -1,32 +0,0 @@ -var fs_1 = require('fs'); -var commander = require('commander'); -var files = commander - .option('-o, --out ', 'Output file') - .option('-p, --prefix ', 'Prefix to strip') - .option('-m, --module ', 'Module name') - .parseOptions(process.argv).args; -var outStream = process.stdout; -if ('out' in commander) { - outStream = fs_1.createWriteStream(commander['out']); -} -var moduleName = 'templates'; -if ('module' in commander) { - moduleName = commander['module']; -} -var prefix = ''; -if ('prefix' in commander) { - prefix = commander['prefix']; -} -var prefixFilter = new RegExp("^" + prefix); -outStream.write("angular.module('" + moduleName + "', []);\n"); -files - .filter(function (_) { return _.match(/\.html$/) !== null; }) - .forEach(function (file) { - var content = fs_1.readFileSync(file, 'utf-8') - .replace(/\\/g, '\\\\') - .replace(/'/g, '\\\'') - .replace(/\r?\n/g, '\\n'); - var tplPath = file.replace(prefixFilter, ''); - outStream.write("angular.module('" + moduleName + "').run(['$templateCache', function(cache) { cache.put('" + tplPath + "', '" + content + "');}]);\n"); -}); -//# sourceMappingURL=templateCache.js.map \ No newline at end of file diff --git a/src/scripts/templateCache.js.map b/src/scripts/templateCache.js.map deleted file mode 100644 index 2270404..0000000 --- a/src/scripts/templateCache.js.map +++ /dev/null @@ -1 +0,0 @@ -{"version":3,"file":"templateCache.js","sourceRoot":"","sources":["templateCache.ts"],"names":[],"mappings":"AAIA,mBAGO,IAAI,CAAC,CAAA;AAEZ,IAAY,SAAS,WAAM,WAAW,CAAC,CAAA;AAEvC,IAAI,KAAK,GAAa,SAAS;KAC5B,MAAM,CAAC,kBAAkB,EAAE,aAAa,CAAC;KACzC,MAAM,CAAC,uBAAuB,EAAE,iBAAiB,CAAC;KAClD,MAAM,CAAC,qBAAqB,EAAE,aAAa,CAAC;KAC5C,YAAY,CAAC,OAAO,CAAC,IAAI,CAAC,CAAC,IAAI,CAAC;AAEnC,IAAI,SAAS,GAAuB,OAAO,CAAC,MAAM,CAAC;AACnD,EAAE,CAAC,CAAC,KAAK,IAAI,SAAS,CAAC,CAAC,CAAC;IACvB,SAAS,GAAG,sBAAiB,CAAC,SAAS,CAAC,KAAK,CAAC,CAAC,CAAC;AAClD,CAAC;AAED,IAAI,UAAU,GAAW,WAAW,CAAC;AACrC,EAAE,CAAC,CAAC,QAAQ,IAAI,SAAS,CAAC,CAAC,CAAC;IAC1B,UAAU,GAAG,SAAS,CAAC,QAAQ,CAAC,CAAC;AACnC,CAAC;AAED,IAAI,MAAM,GAAW,EAAE,CAAC;AACxB,EAAE,CAAC,CAAC,QAAQ,IAAI,SAAS,CAAC,CAAC,CAAC;IAC1B,MAAM,GAAG,SAAS,CAAC,QAAQ,CAAC,CAAC;AAC/B,CAAC;AACD,IAAI,YAAY,GAAG,IAAI,MAAM,CAAC,MAAI,MAAQ,CAAC,CAAC;AAE5C,SAAS,CAAC,KAAK,CAAC,qBAAmB,UAAU,cAAW,CAAC,CAAC;AAE1D,KAAK;KACJ,MAAM,CAAC,UAAC,CAAC,IAAK,OAAA,CAAC,CAAC,KAAK,CAAC,SAAS,CAAC,KAAK,IAAI,EAA3B,CAA2B,CAAC;KAC1C,OAAO,CAAC,UAAC,IAAI;IACZ,IAAI,OAAO,GAAW,iBAAY,CAAC,IAAI,EAAE,OAAO,CAAC;SAC9C,OAAO,CAAC,KAAK,EAAE,MAAM,CAAC;SACtB,OAAO,CAAC,IAAI,EAAE,MAAM,CAAC;SACrB,OAAO,CAAC,QAAQ,EAAE,KAAK,CAAC,CACxB;IACH,IAAI,OAAO,GAAW,IAAI,CAAC,OAAO,CAAC,YAAY,EAAE,EAAE,CAAC,CAAC;IAErD,SAAS,CAAC,KAAK,CAAC,qBAAmB,UAAU,+DAA0D,OAAO,YAAO,OAAO,cAAW,CAAC,CAAC;AAE3I,CAAC,CAAC,CAAC"} \ No newline at end of file diff --git a/src/scripts/templateCache.ts b/src/scripts/templateCache.ts deleted file mode 100644 index 543bb57..0000000 --- a/src/scripts/templateCache.ts +++ /dev/null @@ -1,48 +0,0 @@ -import { - Writable -} from 'stream'; - -import { - createWriteStream, - readFileSync -} from 'fs'; - -import * as commander from 'commander'; - -let files: string[] = commander - .option('-o, --out ', 'Output file') - .option('-p, --prefix ', 'Prefix to strip') - .option('-m, --module ', 'Module name') - .parseOptions(process.argv).args; - -let outStream: Writable = process.stdout; -if ('out' in commander) { - outStream = createWriteStream(commander['out']); -} - -let moduleName: string = 'templates'; -if ('module' in commander) { - moduleName = commander['module']; -} - -let prefix: string = ''; -if ('prefix' in commander) { - prefix = commander['prefix']; -} -let prefixFilter = new RegExp(`^${prefix}`); - -outStream.write(`angular.module('${moduleName}', []);\n`); - -files -.filter((_) => _.match(/\.html$/) !== null) -.forEach((file) => { - let content: string = readFileSync(file, 'utf-8') - .replace(/\\/g, '\\\\') - .replace(/'/g, '\\\'') - .replace(/\r?\n/g, '\\n') - ; - let tplPath: string = file.replace(prefixFilter, ''); - /* tslint:disable */ - outStream.write(`angular.module('${moduleName}').run(['$templateCache', function(cache) { cache.put('${tplPath}', '${content}');}]);\n`); - /* tslint:enable */ -}); diff --git a/src/scripts/tsconfig.json b/src/scripts/tsconfig.json deleted file mode 100644 index ae3e7b7..0000000 --- a/src/scripts/tsconfig.json +++ /dev/null @@ -1,27 +0,0 @@ -{ - "version": "next", - "compilerOptions": { - "experimentalDecorators": true, - "module": "commonjs", - "noImplicitAny": true, - "outDir": "./", - "preserveConstEnums": true, - "removeComments": true, - "sourceMap": true, - "suppressImplicitAnyIndexErrors": true, - "target": "ES5" - }, - "filesGlob": [ - "./**/*.ts", - "../typings/commander/*", - "../typings/glob/*", - "../typings/node/*" - ], - "files": [ - "./glob.ts", - "./templateCache.ts", - "../typings/commander/commander.d.ts", - "../typings/glob/glob.d.ts", - "../typings/node/node.d.ts" - ] -} diff --git a/src/shared/record/record.mock.ts b/src/shared/record/record.mock.ts index 858b020..3b00b55 100644 --- a/src/shared/record/record.mock.ts +++ b/src/shared/record/record.mock.ts @@ -1,22 +1,45 @@ export const MOCK_RECORD_1 = { label: 'Tape 1', family: 'Tapes', - medium: '3/4"' + medium: '3/4"', + first: new Date("2010-01-01T00:00:00.000Z"), + last: new Date("2010-02-01T00:00:00.000Z") }; export const MOCK_RECORD_2 = { label: 'Tape 2', family: 'Tapes', - medium: '3/4"' + medium: '3/4"', + first: new Date("2010-03-01T00:00:00.000Z"), + last: new Date("2010-04-01T00:00:00.000Z") }; export const MOCK_RECORD_3 = { - lavel: 'Tape 3', - familyt: 'Tapes', - medium: '3/4"' + label: 'Tape 3', + family: 'Tapes', + medium: '3/4"', + first: new Date("2011-02-01T00:00:00.000Z"), + last: new Date("2011-04-01T00:00:00.000Z") }; export const MOCK_RECORDS = [ MOCK_RECORD_1, MOCK_RECORD_2, MOCK_RECORD_3 ]; +export const MOCK_STORY_1 = { + slug: 'Story 1', + date: new Date('10/15/2015'), + format: 'VO', + runtime: '5:30' +}; + +export const MOCK_STORY_2 = { + slug: 'Story 2', + date: new Date('10/17/2015'), + format: 'FEATURE', + runtime: '2:30' +}; + +export const MOCK_STORIES = [ + MOCK_STORY_1, MOCK_STORY_2 +]; diff --git a/src/shared/record/record.spec.ts b/src/shared/record/record.spec.ts index c57d3fa..4fecd6c 100644 --- a/src/shared/record/record.spec.ts +++ b/src/shared/record/record.spec.ts @@ -3,12 +3,12 @@ import { } from 'chai'; import { + Image, Record, - Story + Story, + Video } from './record'; -import { MOCK_RECORD_1 } from './record.mock'; - describe('Record', function() { describe('factory', function() { it('tests proto objects', function() { @@ -52,7 +52,7 @@ describe('Record', function() { expect(record.id).to.equal('tape_1'); expect(record.stories.length).to.equal(1); Object.keys(protoRecord) - .filter(_ => _ != 'stories') + .filter(_ => _ !== 'stories') .forEach(_ => { expect(record).to.have.property(_).that.equals(protoRecord[_]); }); @@ -81,7 +81,8 @@ describe('Record', function() { }); describe('Date ranges', function() { - it('manages ranges of story dates', function() { + // This functionality is deprecated while we edit the record date directly. + it.skip('manages ranges of story dates', function() { let record = new Record('Tape 1', 'Test', '3/4"'); record.addStories([ new Story('Story 1', new Date('10/15/2015'), 'VO', '5:30') @@ -108,6 +109,99 @@ describe('Record', function() { .to.equal(new Date('10/16/2015').toDateString()); }); }); + + describe('Merging', function() { + it('merges records sanely', function() { + let record1 = new Record('Tape 1', 'Test', '3/4"'); + record1.first = new Date('10/15/2015'); + record1.last = new Date('10/16/2015'); + record1.addStories([ + new Story('Story 1', new Date('10/15/2015'), 'VO', '5:30') + ]); + let record2 = new Record('Tape 1', 'Test', '3/4"'); + record2.first = new Date('10/14/2015'); + record2.addStories([ + new Story('Story 1', new Date('10/15/2015'), 'VO', '5:30'), + new Story('Story 2', new Date('10/15/2015'), 'VO', '5:30') + ]); + record1.merge(record2); + let record3 = new Record('Tape 1', 'Test', '3/4"'); + record1.merge(record3); + expect(record1.first.toString()) + .to.equal(new Date('10/14/2015').toString()); + expect(record1.stories.length).to.equal(2); + }); + it('can replace media', function() { + let record1 = new Record('Tape 1', 'Test', '3/4"'); + record1.addImages([new Image('image.jpg')]); + record1.addVideos([new Video('video.mp4')]); + let record2 = new Record('Tape 1', 'Test', '3/4"'); + record2.addImages([new Image('other.jpg')]); + record2.addVideos([new Video('other.mp4')]); + record1.merge(record2); + expect(record1.images.length).to.equal(2); + record1.merge(record2, true); + expect(record1.images.length).to.equal(1); + }); + }); + + describe('Other functionality', function() { + it('keeps track of basic modified state', function() { + let record1 = new Record('Tape 1', 'Test', '3/4"'); + record1.label = 'David 1'; + expect(record1.modified).to.be.true; + }); + + it('allows stories with duplicate slugs', function() { + let record = new Record('Tape 1', 'Test', '3/4"'); + record.addStories([ + new Story('Story 1', new Date('10/15/2015'), 'VO', '5:30'), + new Story('Story 1', new Date('10/15/2015'), 'VO', '8:30') + ]); + expect(record.stories.length).to.equal(2); + }); + + it('can rename all media', function() { + let record = new Record('Renamed Tape', 'Test', '3/4"'); + record.addImages([ + new Image('tape_1/tape_1_1234.jpeg'), + new Image('tape_1/tape_1_5678.jpeg'), + ]); + record.addVideos([ + new Video('tape_1/tape_1_1234.mov'), + new Video('tape_1/foo.mov'), + ]); + record.updateMedia('tape_1'); + expect(record.images.map((_) => _.path)).to.deep.equal([ + 'renamed_tape/tape_1_1234.jpeg', + 'renamed_tape/tape_1_5678.jpeg', + ]); + expect(record.videos.map((_) => _.path)).to.deep.equal([ + 'renamed_tape/tape_1_1234.mov', + 'renamed_tape/foo.mov' + ]); + }); + + it('sorts records first by family, then by date', function() { + let recorda1 = new Record('A1', 'A', '3/4"'); + recorda1.first = new Date('10/15/2015'); + let recorda2 = new Record('A2', 'A', '3/4"'); + recorda2.first = new Date('10/16/2015'); + let recordb1 = new Record('B1', 'B', '3/4"'); + recordb1.first = new Date('10/15/2015'); + let recordb2 = new Record('B2', 'B', '3/4"'); + recordb2.first = new Date('10/16/2015'); + + let records: Record[] = [ + recorda2, recordb1, recordb2, recorda1 + ].sort(Record.comparator); + + expect(records[0]).to.equal(recorda1); + expect(records[1]).to.equal(recorda2); + expect(records[2]).to.equal(recordb1); + expect(records[3]).to.equal(recordb2); + }); + }); }); describe('Story', function() { diff --git a/src/shared/record/record.ts b/src/shared/record/record.ts index b0177d3..3cf5aa3 100644 --- a/src/shared/record/record.ts +++ b/src/shared/record/record.ts @@ -3,41 +3,74 @@ export class RecordDatabase { } export interface IRecord { - label: string, - family: string, - medium: string, - notes?: string, - stories?: IStory[], - deleted?: boolean, + label: string; + family: string; + medium: string; + first?: Date; + last?: Date; + notes?: string; + stories?: IStory[]; + images?: Image[]; + videos?: Video[]; + deleted?: boolean; + rawImage?: any; + baseId?: string; } export interface IStory { - slug: string, - date: string|Date, - format?: string, - runtime?: string, - notes?: string, - reporter?: string, - photographer?: string + slug: string; + date: string|Date; + format?: string; + runtime?: string; + notes?: string; + reporter?: string; + photographer?: string; } -export class Record { +export interface IImage { + path: string; +} + +export interface IVideo { + path: string; +} + +export class Record implements IRecord { + public isNewTape: boolean = false; + public rawImage: any = null; + private _id: string; private _label: string; private _first: Date; private _last: Date; private _stories: Story[] = []; + public images: Image[] = []; + public videos: Video[] = []; + public baseId: string = ''; constructor( label: string, public family: string, public medium: string = '', public notes: string = '', + first: string|Date = '', + last: string|Date = '', public deleted: boolean = false, - stories: Story[] = [] + stories: Story[] = [], + images: Image[] = [], + videos: Video[] = [] ) { + if (first !== '') { + this.setFirst(first); + } + if (last !== '') { + this.setLast(last); + } this.label = label; + this.baseId = this.id; this.addStories(stories); + this.addImages(images); + this.addVideos(videos); } get id(): string { return this._id; } @@ -47,55 +80,189 @@ export class Record { this._id = makeRecordId(label); } get first(): Date { return this._first; } + set first(date: Date) { this.setFirst(date); } get last(): Date { return this._last; } + set last(date: Date) { this.setLast(date); } + get combinedDate(): string { return this.first + ' ' + this.last; } get stories(): Story[] { return this._stories; } + get modified(): boolean { return this.id !== this.baseId; } + + forceId(id: string): void { + // Here be dragons. + this._id = id; + this.baseId = id; + } + setFirst(date: string|Date): void { + if (typeof date === 'string') { + if (date === '') { + date = new Date(); + } else { + date = new Date(date); + } + } + this._first = date; + } + setLast(date: string|Date): void { + if (typeof date === 'string') { + if (date === '') { + date = new Date(); + } else { + date = new Date(date); + } + } + this._last = date; + } addStories(stories: Story[]): Record { - this._stories = this._stories.concat(stories).sort(Story.compare); - if (this._stories.length > 0) { - this._first = this.stories[0].date; - this._last = this.stories[this.stories.length - 1].date; + this._stories = this._stories.concat(stories).reduce( + (p: Story[], c: Story) => { + if (indexOfC(p, c, (a, b) => a.equals(b)) < 0) { + p.push(c); + } + return p; + }, + []) + .sort(Story.compare); + return this; + } + + removeStory(story: Story): Record { + const i = this._stories.indexOf(story); + if (i > -1) { + const head = this._stories.slice(0, i); + const tail = this._stories.slice(i + 1); + this._stories = head.concat(tail); + } + return this; + } + + addImages(images: Image[]): Record { + this.images = this.images.concat(images).reduce( + (p: Image[], c: Image) => { + for (let i = 0; i < p.length; i++) { + if (p[i].path === c.path) { + return p; + } + } + p.push(c); + return p; + }, + [] + ); + return this; + } + + removeImage(image: Image): Record { + const i = this.images.indexOf(image); + if (i > -1) { + const head = this.images.slice(0, i); + const tail = this.images.slice(i + 1); + this.images = head.concat(tail); } return this; } + addVideos(videos: Video[]): Record { + this.videos = this.videos.concat(videos).reduce( + (p: Video[], c: Video) => { + for (let i = 0; i < p.length; i++) { + if (p[i].path === c.path) { + return p; + } + } + p.push(c); + return p; + }, + [] + ); + return this; + } + + updateMedia(oldId: string) { + this.videos.forEach((_: Video) => { + _.path = _.path.replace(oldId, this.id); + }); + this.images.forEach((_: Image) => { + _.path = _.path.replace(oldId, this.id); + }); + } + /** * Merges the contents of the specified Record into current Record. * * This method merges the contents of the specified Record into the current * Record. Fields that are set in the specified record overwrite - * the corresponding fields in the current record. Storys are - * appended. Record date ranges are expanded. + * the corresponding fields in the current record. Stories are replaced, + * unless they are not present in `other`. Record date ranges are expanded. */ - merge(other: Record): Record { + merge(other: Record, replaceMedia = false): Record { this.label = other.label || this.label; this.family = other.family || this.family; this.medium = other.medium || this.medium; this.notes = other.notes || this.notes; - this.addStories(other.stories); + this.first = other.first || this.first; + this.last = other.last || this.last; + // Stories can be edited, so merging would duplicate them. Instead, copy the + // new stories, and trust that the user calls this correctly. + this._stories = other.stories.length > 0 ? other.stories : this.stories; + if (replaceMedia) { + this.images = other.images || []; + this.videos = other.videos || []; + } else { + this.addImages(other.images || []); + this.addVideos(other.videos || []); + } return this; } toJSON(): IRecord { - return { - label: this.label, - family: this.family, - medium: this.medium, - notes: this.notes, - stories: this.stories, - deleted: this.deleted - }; + return { + label: this.label, + family: this.family, + medium: this.medium, + first: this.first, + last: this.last, + notes: this.notes, + stories: this.stories, + images: this.images, + videos: this.videos, + deleted: this.deleted + }; + } + + compareTo(other: Record): number { + if (this.family === other.family) { + return compareDates(this.first, other.first); + } else { + return this.family.localeCompare(other.family); + } + } + + static comparator(a: Record, b: Record): number { + return a.compareTo(b); } static fromObj(obj: IRecord): Record { - let {label, family, medium, notes, deleted} = obj; - let record = new Record(label, family, medium, notes, deleted); + let {label, family, medium, notes, deleted, first, last} = obj; + let record = new Record(label, family, medium, notes, first, last, deleted); if ('stories' in obj && obj.stories instanceof Array) { record.addStories( obj.stories .filter(Story.isProtoStory) .map(Story.fromObj)); } + if ('images' in obj && obj.images instanceof Array) { + record.addImages( + obj.images + .filter(Image.isProtoImage) + .map(Image.fromObj)); + } + if ('videos' in obj && obj.videos instanceof Array) { + record.addVideos( + obj.videos + .filter(Video.isProtoVideo) + .map(Video.fromObj)); + } return record; } @@ -105,6 +272,50 @@ export class Record { } } +export class Image { + constructor( + public path: string + ) { } + + toJSON(): IImage { + return { + path: this.path + }; + } + + static fromObj(obj: IImage): Image { + let {path} = obj; + return new Image(path); + } + + static isProtoImage(obj: any): boolean { + // We can't have an image without a URL. + return 'path' in obj; + } +} + +export class Video { + constructor( + public path: string + ) { } + + toJSON(): IVideo { + return { + path: this.path + }; + } + + static fromObj(obj: IVideo): Video { + let {path} = obj; + return new Video(path); + } + + static isProtoVideo(obj: any): boolean { + // We can't have an image without a URL. + return 'path' in obj; + } +} + export class Story { private _date: Date; constructor( @@ -123,7 +334,11 @@ export class Story { set date(date: Date) { this._date = date; } setDate(date: string|Date): void { if (typeof date === 'string') { - date = new Date(date); + if (date === '') { + date = new Date(); + } else { + date = new Date(date); + } } this.date = date; } @@ -140,19 +355,27 @@ export class Story { } compareTo(other: Story): number { - if (this.date > other.date) { - return 1; - } else if (this.date < other.date) { - return -1; - } else { - return 0; - } + return compareDates(this.date, other.date); } static compare(a: Story, b: Story): number { return a.compareTo(b); } + equals(other: Story): boolean { + return this.slug === other.slug && + this.date.getTime() === other.date.getTime() && + this.format === other.format && + this.runtime === other.runtime && + this.notes === other.notes && + this.reporter === other.reporter && + this.photographer === other.photographer; + } + + static equals(a: Story, b: Story): boolean { + return a.equals(b); + } + toJSON(): IStory { return { slug: this.slug, @@ -180,7 +403,7 @@ export class Story { } } -export function makeRecordId(label: string): string { +export function makeRecordId(label: string = ''): string { return label // .replace(/[0x00-0x1f]/g, '') // Strip low bytes // .replace(/[0x7f]/g, '') // Strip 127 @@ -191,3 +414,26 @@ export function makeRecordId(label: string): string { .toLowerCase() ; } + +export function indexOfC( + list: T[], + item: T, + comparator: (a: T, b: T) => boolean +): number { + return list.reduce(indexOfComparator, -1); + function indexOfComparator(found: number, value: T, index: number): number { + if (found > -1) { return found; } + if (comparator(value, item)) { return index; } + return -1; + } +} + +export function compareDates(a: Date, b: Date): number { + if (a > b) { + return 1; + } else if (a < b) { + return -1; + } else { + return 0; + } +} diff --git a/src/static/bootstrap-theme.css b/src/static/bootstrap-theme.css new file mode 100755 index 0000000..76986cb --- /dev/null +++ b/src/static/bootstrap-theme.css @@ -0,0 +1,596 @@ +/*! + * Bootstrap v3.3.5 (http://getbootstrap.com) + * Copyright 2011-2016 Twitter, Inc. + * Licensed under MIT (https://github.com/twbs/bootstrap/blob/master/LICENSE) + */ + +/*! + * Generated using the Bootstrap Customizer (http://getbootstrap.com/customize/?id=2b71cceb85b313486c11) + * Config saved to config.json and https://gist.github.com/2b71cceb85b313486c11 + */ +/*! + * Bootstrap v3.3.6 (http://getbootstrap.com) + * Copyright 2011-2015 Twitter, Inc. + * Licensed under MIT (https://github.com/twbs/bootstrap/blob/master/LICENSE) + */ +.btn-default, +.btn-primary, +.btn-success, +.btn-info, +.btn-warning, +.btn-danger { + text-shadow: 0 -1px 0 rgba(0, 0, 0, 0.2); + -webkit-box-shadow: inset 0 1px 0 rgba(255, 255, 255, 0.15), 0 1px 1px rgba(0, 0, 0, 0.075); + box-shadow: inset 0 1px 0 rgba(255, 255, 255, 0.15), 0 1px 1px rgba(0, 0, 0, 0.075); +} +.btn-default:active, +.btn-primary:active, +.btn-success:active, +.btn-info:active, +.btn-warning:active, +.btn-danger:active, +.btn-default.active, +.btn-primary.active, +.btn-success.active, +.btn-info.active, +.btn-warning.active, +.btn-danger.active { + -webkit-box-shadow: inset 0 3px 5px rgba(0, 0, 0, 0.125); + box-shadow: inset 0 3px 5px rgba(0, 0, 0, 0.125); +} +.btn-default.disabled, +.btn-primary.disabled, +.btn-success.disabled, +.btn-info.disabled, +.btn-warning.disabled, +.btn-danger.disabled, +.btn-default[disabled], +.btn-primary[disabled], +.btn-success[disabled], +.btn-info[disabled], +.btn-warning[disabled], +.btn-danger[disabled], +fieldset[disabled] .btn-default, +fieldset[disabled] .btn-primary, +fieldset[disabled] .btn-success, +fieldset[disabled] .btn-info, +fieldset[disabled] .btn-warning, +fieldset[disabled] .btn-danger { + -webkit-box-shadow: none; + box-shadow: none; +} +.btn-default .badge, +.btn-primary .badge, +.btn-success .badge, +.btn-info .badge, +.btn-warning .badge, +.btn-danger .badge { + text-shadow: none; +} +.btn:active, +.btn.active { + background-image: none; +} +.btn-default { + background-image: -webkit-linear-gradient(top, #ffffff 0%, #e0e0e0 100%); + background-image: -o-linear-gradient(top, #ffffff 0%, #e0e0e0 100%); + background-image: -webkit-gradient(linear, left top, left bottom, from(#ffffff), to(#e0e0e0)); + background-image: linear-gradient(to bottom, #ffffff 0%, #e0e0e0 100%); + filter: progid:DXImageTransform.Microsoft.gradient(startColorstr='#ffffffff', endColorstr='#ffe0e0e0', GradientType=0); + filter: progid:DXImageTransform.Microsoft.gradient(enabled = false); + background-repeat: repeat-x; + border-color: #dbdbdb; + text-shadow: 0 1px 0 #fff; + border-color: #ccc; +} +.btn-default:hover, +.btn-default:focus { + background-color: #e0e0e0; + background-position: 0 -15px; +} +.btn-default:active, +.btn-default.active { + background-color: #e0e0e0; + border-color: #dbdbdb; +} +.btn-default.disabled, +.btn-default[disabled], +fieldset[disabled] .btn-default, +.btn-default.disabled:hover, +.btn-default[disabled]:hover, +fieldset[disabled] .btn-default:hover, +.btn-default.disabled:focus, +.btn-default[disabled]:focus, +fieldset[disabled] .btn-default:focus, +.btn-default.disabled.focus, +.btn-default[disabled].focus, +fieldset[disabled] .btn-default.focus, +.btn-default.disabled:active, +.btn-default[disabled]:active, +fieldset[disabled] .btn-default:active, +.btn-default.disabled.active, +.btn-default[disabled].active, +fieldset[disabled] .btn-default.active { + background-color: #e0e0e0; + background-image: none; +} +.btn-primary { + background-image: -webkit-linear-gradient(top, #337ab7 0%, #265a88 100%); + background-image: -o-linear-gradient(top, #337ab7 0%, #265a88 100%); + background-image: -webkit-gradient(linear, left top, left bottom, from(#337ab7), to(#265a88)); + background-image: linear-gradient(to bottom, #337ab7 0%, #265a88 100%); + filter: progid:DXImageTransform.Microsoft.gradient(startColorstr='#ff337ab7', endColorstr='#ff265a88', GradientType=0); + filter: progid:DXImageTransform.Microsoft.gradient(enabled = false); + background-repeat: repeat-x; + border-color: #245580; +} +.btn-primary:hover, +.btn-primary:focus { + background-color: #265a88; + background-position: 0 -15px; +} +.btn-primary:active, +.btn-primary.active { + background-color: #265a88; + border-color: #245580; +} +.btn-primary.disabled, +.btn-primary[disabled], +fieldset[disabled] .btn-primary, +.btn-primary.disabled:hover, +.btn-primary[disabled]:hover, +fieldset[disabled] .btn-primary:hover, +.btn-primary.disabled:focus, +.btn-primary[disabled]:focus, +fieldset[disabled] .btn-primary:focus, +.btn-primary.disabled.focus, +.btn-primary[disabled].focus, +fieldset[disabled] .btn-primary.focus, +.btn-primary.disabled:active, +.btn-primary[disabled]:active, +fieldset[disabled] .btn-primary:active, +.btn-primary.disabled.active, +.btn-primary[disabled].active, +fieldset[disabled] .btn-primary.active { + background-color: #265a88; + background-image: none; +} +.btn-success { + background-image: -webkit-linear-gradient(top, #5cb85c 0%, #419641 100%); + background-image: -o-linear-gradient(top, #5cb85c 0%, #419641 100%); + background-image: -webkit-gradient(linear, left top, left bottom, from(#5cb85c), to(#419641)); + background-image: linear-gradient(to bottom, #5cb85c 0%, #419641 100%); + filter: progid:DXImageTransform.Microsoft.gradient(startColorstr='#ff5cb85c', endColorstr='#ff419641', GradientType=0); + filter: progid:DXImageTransform.Microsoft.gradient(enabled = false); + background-repeat: repeat-x; + border-color: #3e8f3e; +} +.btn-success:hover, +.btn-success:focus { + background-color: #419641; + background-position: 0 -15px; +} +.btn-success:active, +.btn-success.active { + background-color: #419641; + border-color: #3e8f3e; +} +.btn-success.disabled, +.btn-success[disabled], +fieldset[disabled] .btn-success, +.btn-success.disabled:hover, +.btn-success[disabled]:hover, +fieldset[disabled] .btn-success:hover, +.btn-success.disabled:focus, +.btn-success[disabled]:focus, +fieldset[disabled] .btn-success:focus, +.btn-success.disabled.focus, +.btn-success[disabled].focus, +fieldset[disabled] .btn-success.focus, +.btn-success.disabled:active, +.btn-success[disabled]:active, +fieldset[disabled] .btn-success:active, +.btn-success.disabled.active, +.btn-success[disabled].active, +fieldset[disabled] .btn-success.active { + background-color: #419641; + background-image: none; +} +.btn-info { + background-image: -webkit-linear-gradient(top, #5bc0de 0%, #2aabd2 100%); + background-image: -o-linear-gradient(top, #5bc0de 0%, #2aabd2 100%); + background-image: -webkit-gradient(linear, left top, left bottom, from(#5bc0de), to(#2aabd2)); + background-image: linear-gradient(to bottom, #5bc0de 0%, #2aabd2 100%); + filter: progid:DXImageTransform.Microsoft.gradient(startColorstr='#ff5bc0de', endColorstr='#ff2aabd2', GradientType=0); + filter: progid:DXImageTransform.Microsoft.gradient(enabled = false); + background-repeat: repeat-x; + border-color: #28a4c9; +} +.btn-info:hover, +.btn-info:focus { + background-color: #2aabd2; + background-position: 0 -15px; +} +.btn-info:active, +.btn-info.active { + background-color: #2aabd2; + border-color: #28a4c9; +} +.btn-info.disabled, +.btn-info[disabled], +fieldset[disabled] .btn-info, +.btn-info.disabled:hover, +.btn-info[disabled]:hover, +fieldset[disabled] .btn-info:hover, +.btn-info.disabled:focus, +.btn-info[disabled]:focus, +fieldset[disabled] .btn-info:focus, +.btn-info.disabled.focus, +.btn-info[disabled].focus, +fieldset[disabled] .btn-info.focus, +.btn-info.disabled:active, +.btn-info[disabled]:active, +fieldset[disabled] .btn-info:active, +.btn-info.disabled.active, +.btn-info[disabled].active, +fieldset[disabled] .btn-info.active { + background-color: #2aabd2; + background-image: none; +} +.btn-warning { + background-image: -webkit-linear-gradient(top, #f0ad4e 0%, #eb9316 100%); + background-image: -o-linear-gradient(top, #f0ad4e 0%, #eb9316 100%); + background-image: -webkit-gradient(linear, left top, left bottom, from(#f0ad4e), to(#eb9316)); + background-image: linear-gradient(to bottom, #f0ad4e 0%, #eb9316 100%); + filter: progid:DXImageTransform.Microsoft.gradient(startColorstr='#fff0ad4e', endColorstr='#ffeb9316', GradientType=0); + filter: progid:DXImageTransform.Microsoft.gradient(enabled = false); + background-repeat: repeat-x; + border-color: #e38d13; +} +.btn-warning:hover, +.btn-warning:focus { + background-color: #eb9316; + background-position: 0 -15px; +} +.btn-warning:active, +.btn-warning.active { + background-color: #eb9316; + border-color: #e38d13; +} +.btn-warning.disabled, +.btn-warning[disabled], +fieldset[disabled] .btn-warning, +.btn-warning.disabled:hover, +.btn-warning[disabled]:hover, +fieldset[disabled] .btn-warning:hover, +.btn-warning.disabled:focus, +.btn-warning[disabled]:focus, +fieldset[disabled] .btn-warning:focus, +.btn-warning.disabled.focus, +.btn-warning[disabled].focus, +fieldset[disabled] .btn-warning.focus, +.btn-warning.disabled:active, +.btn-warning[disabled]:active, +fieldset[disabled] .btn-warning:active, +.btn-warning.disabled.active, +.btn-warning[disabled].active, +fieldset[disabled] .btn-warning.active { + background-color: #eb9316; + background-image: none; +} +.btn-danger { + background-image: -webkit-linear-gradient(top, #d9534f 0%, #c12e2a 100%); + background-image: -o-linear-gradient(top, #d9534f 0%, #c12e2a 100%); + background-image: -webkit-gradient(linear, left top, left bottom, from(#d9534f), to(#c12e2a)); + background-image: linear-gradient(to bottom, #d9534f 0%, #c12e2a 100%); + filter: progid:DXImageTransform.Microsoft.gradient(startColorstr='#ffd9534f', endColorstr='#ffc12e2a', GradientType=0); + filter: progid:DXImageTransform.Microsoft.gradient(enabled = false); + background-repeat: repeat-x; + border-color: #b92c28; +} +.btn-danger:hover, +.btn-danger:focus { + background-color: #c12e2a; + background-position: 0 -15px; +} +.btn-danger:active, +.btn-danger.active { + background-color: #c12e2a; + border-color: #b92c28; +} +.btn-danger.disabled, +.btn-danger[disabled], +fieldset[disabled] .btn-danger, +.btn-danger.disabled:hover, +.btn-danger[disabled]:hover, +fieldset[disabled] .btn-danger:hover, +.btn-danger.disabled:focus, +.btn-danger[disabled]:focus, +fieldset[disabled] .btn-danger:focus, +.btn-danger.disabled.focus, +.btn-danger[disabled].focus, +fieldset[disabled] .btn-danger.focus, +.btn-danger.disabled:active, +.btn-danger[disabled]:active, +fieldset[disabled] .btn-danger:active, +.btn-danger.disabled.active, +.btn-danger[disabled].active, +fieldset[disabled] .btn-danger.active { + background-color: #c12e2a; + background-image: none; +} +.thumbnail, +.img-thumbnail { + -webkit-box-shadow: 0 1px 2px rgba(0, 0, 0, 0.075); + box-shadow: 0 1px 2px rgba(0, 0, 0, 0.075); +} +.dropdown-menu > li > a:hover, +.dropdown-menu > li > a:focus { + background-image: -webkit-linear-gradient(top, #f5f5f5 0%, #e8e8e8 100%); + background-image: -o-linear-gradient(top, #f5f5f5 0%, #e8e8e8 100%); + background-image: -webkit-gradient(linear, left top, left bottom, from(#f5f5f5), to(#e8e8e8)); + background-image: linear-gradient(to bottom, #f5f5f5 0%, #e8e8e8 100%); + background-repeat: repeat-x; + filter: progid:DXImageTransform.Microsoft.gradient(startColorstr='#fff5f5f5', endColorstr='#ffe8e8e8', GradientType=0); + background-color: #e8e8e8; +} +.dropdown-menu > .active > a, +.dropdown-menu > .active > a:hover, +.dropdown-menu > .active > a:focus { + background-image: -webkit-linear-gradient(top, #337ab7 0%, #2e6da4 100%); + background-image: -o-linear-gradient(top, #337ab7 0%, #2e6da4 100%); + background-image: -webkit-gradient(linear, left top, left bottom, from(#337ab7), to(#2e6da4)); + background-image: linear-gradient(to bottom, #337ab7 0%, #2e6da4 100%); + background-repeat: repeat-x; + filter: progid:DXImageTransform.Microsoft.gradient(startColorstr='#ff337ab7', endColorstr='#ff2e6da4', GradientType=0); + background-color: #2e6da4; +} +.navbar-default { + background-image: -webkit-linear-gradient(top, #ffffff 0%, #f8f8f8 100%); + background-image: -o-linear-gradient(top, #ffffff 0%, #f8f8f8 100%); + background-image: -webkit-gradient(linear, left top, left bottom, from(#ffffff), to(#f8f8f8)); + background-image: linear-gradient(to bottom, #ffffff 0%, #f8f8f8 100%); + background-repeat: repeat-x; + filter: progid:DXImageTransform.Microsoft.gradient(startColorstr='#ffffffff', endColorstr='#fff8f8f8', GradientType=0); + filter: progid:DXImageTransform.Microsoft.gradient(enabled = false); + border-radius: 2px; + -webkit-box-shadow: inset 0 1px 0 rgba(255, 255, 255, 0.15), 0 1px 5px rgba(0, 0, 0, 0.075); + box-shadow: inset 0 1px 0 rgba(255, 255, 255, 0.15), 0 1px 5px rgba(0, 0, 0, 0.075); +} +.navbar-default .navbar-nav > .open > a, +.navbar-default .navbar-nav > .active > a { + background-image: -webkit-linear-gradient(top, #dbdbdb 0%, #e2e2e2 100%); + background-image: -o-linear-gradient(top, #dbdbdb 0%, #e2e2e2 100%); + background-image: -webkit-gradient(linear, left top, left bottom, from(#dbdbdb), to(#e2e2e2)); + background-image: linear-gradient(to bottom, #dbdbdb 0%, #e2e2e2 100%); + background-repeat: repeat-x; + filter: progid:DXImageTransform.Microsoft.gradient(startColorstr='#ffdbdbdb', endColorstr='#ffe2e2e2', GradientType=0); + -webkit-box-shadow: inset 0 3px 9px rgba(0, 0, 0, 0.075); + box-shadow: inset 0 3px 9px rgba(0, 0, 0, 0.075); +} +.navbar-brand, +.navbar-nav > li > a { + text-shadow: 0 1px 0 rgba(255, 255, 255, 0.25); +} +.navbar-inverse { + background-image: -webkit-linear-gradient(top, #3c3c3c 0%, #222222 100%); + background-image: -o-linear-gradient(top, #3c3c3c 0%, #222222 100%); + background-image: -webkit-gradient(linear, left top, left bottom, from(#3c3c3c), to(#222222)); + background-image: linear-gradient(to bottom, #3c3c3c 0%, #222222 100%); + background-repeat: repeat-x; + filter: progid:DXImageTransform.Microsoft.gradient(startColorstr='#ff3c3c3c', endColorstr='#ff222222', GradientType=0); + filter: progid:DXImageTransform.Microsoft.gradient(enabled = false); + border-radius: 2px; +} +.navbar-inverse .navbar-nav > .open > a, +.navbar-inverse .navbar-nav > .active > a { + background-image: -webkit-linear-gradient(top, #080808 0%, #0f0f0f 100%); + background-image: -o-linear-gradient(top, #080808 0%, #0f0f0f 100%); + background-image: -webkit-gradient(linear, left top, left bottom, from(#080808), to(#0f0f0f)); + background-image: linear-gradient(to bottom, #080808 0%, #0f0f0f 100%); + background-repeat: repeat-x; + filter: progid:DXImageTransform.Microsoft.gradient(startColorstr='#ff080808', endColorstr='#ff0f0f0f', GradientType=0); + -webkit-box-shadow: inset 0 3px 9px rgba(0, 0, 0, 0.25); + box-shadow: inset 0 3px 9px rgba(0, 0, 0, 0.25); +} +.navbar-inverse .navbar-brand, +.navbar-inverse .navbar-nav > li > a { + text-shadow: 0 -1px 0 rgba(0, 0, 0, 0.25); +} +.navbar-static-top, +.navbar-fixed-top, +.navbar-fixed-bottom { + border-radius: 0; +} +@media (max-width: 767px) { + .navbar .navbar-nav .open .dropdown-menu > .active > a, + .navbar .navbar-nav .open .dropdown-menu > .active > a:hover, + .navbar .navbar-nav .open .dropdown-menu > .active > a:focus { + color: #fff; + background-image: -webkit-linear-gradient(top, #337ab7 0%, #2e6da4 100%); + background-image: -o-linear-gradient(top, #337ab7 0%, #2e6da4 100%); + background-image: -webkit-gradient(linear, left top, left bottom, from(#337ab7), to(#2e6da4)); + background-image: linear-gradient(to bottom, #337ab7 0%, #2e6da4 100%); + background-repeat: repeat-x; + filter: progid:DXImageTransform.Microsoft.gradient(startColorstr='#ff337ab7', endColorstr='#ff2e6da4', GradientType=0); + } +} +.alert { + text-shadow: 0 1px 0 rgba(255, 255, 255, 0.2); + -webkit-box-shadow: inset 0 1px 0 rgba(255, 255, 255, 0.25), 0 1px 2px rgba(0, 0, 0, 0.05); + box-shadow: inset 0 1px 0 rgba(255, 255, 255, 0.25), 0 1px 2px rgba(0, 0, 0, 0.05); +} +.alert-success { + background-image: -webkit-linear-gradient(top, #dff0d8 0%, #c8e5bc 100%); + background-image: -o-linear-gradient(top, #dff0d8 0%, #c8e5bc 100%); + background-image: -webkit-gradient(linear, left top, left bottom, from(#dff0d8), to(#c8e5bc)); + background-image: linear-gradient(to bottom, #dff0d8 0%, #c8e5bc 100%); + background-repeat: repeat-x; + filter: progid:DXImageTransform.Microsoft.gradient(startColorstr='#ffdff0d8', endColorstr='#ffc8e5bc', GradientType=0); + border-color: #b2dba1; +} +.alert-info { + background-image: -webkit-linear-gradient(top, #d9edf7 0%, #b9def0 100%); + background-image: -o-linear-gradient(top, #d9edf7 0%, #b9def0 100%); + background-image: -webkit-gradient(linear, left top, left bottom, from(#d9edf7), to(#b9def0)); + background-image: linear-gradient(to bottom, #d9edf7 0%, #b9def0 100%); + background-repeat: repeat-x; + filter: progid:DXImageTransform.Microsoft.gradient(startColorstr='#ffd9edf7', endColorstr='#ffb9def0', GradientType=0); + border-color: #9acfea; +} +.alert-warning { + background-image: -webkit-linear-gradient(top, #fcf8e3 0%, #f8efc0 100%); + background-image: -o-linear-gradient(top, #fcf8e3 0%, #f8efc0 100%); + background-image: -webkit-gradient(linear, left top, left bottom, from(#fcf8e3), to(#f8efc0)); + background-image: linear-gradient(to bottom, #fcf8e3 0%, #f8efc0 100%); + background-repeat: repeat-x; + filter: progid:DXImageTransform.Microsoft.gradient(startColorstr='#fffcf8e3', endColorstr='#fff8efc0', GradientType=0); + border-color: #f5e79e; +} +.alert-danger { + background-image: -webkit-linear-gradient(top, #f2dede 0%, #e7c3c3 100%); + background-image: -o-linear-gradient(top, #f2dede 0%, #e7c3c3 100%); + background-image: -webkit-gradient(linear, left top, left bottom, from(#f2dede), to(#e7c3c3)); + background-image: linear-gradient(to bottom, #f2dede 0%, #e7c3c3 100%); + background-repeat: repeat-x; + filter: progid:DXImageTransform.Microsoft.gradient(startColorstr='#fff2dede', endColorstr='#ffe7c3c3', GradientType=0); + border-color: #dca7a7; +} +.progress { + background-image: -webkit-linear-gradient(top, #ebebeb 0%, #f5f5f5 100%); + background-image: -o-linear-gradient(top, #ebebeb 0%, #f5f5f5 100%); + background-image: -webkit-gradient(linear, left top, left bottom, from(#ebebeb), to(#f5f5f5)); + background-image: linear-gradient(to bottom, #ebebeb 0%, #f5f5f5 100%); + background-repeat: repeat-x; + filter: progid:DXImageTransform.Microsoft.gradient(startColorstr='#ffebebeb', endColorstr='#fff5f5f5', GradientType=0); +} +.progress-bar { + background-image: -webkit-linear-gradient(top, #337ab7 0%, #286090 100%); + background-image: -o-linear-gradient(top, #337ab7 0%, #286090 100%); + background-image: -webkit-gradient(linear, left top, left bottom, from(#337ab7), to(#286090)); + background-image: linear-gradient(to bottom, #337ab7 0%, #286090 100%); + background-repeat: repeat-x; + filter: progid:DXImageTransform.Microsoft.gradient(startColorstr='#ff337ab7', endColorstr='#ff286090', GradientType=0); +} +.progress-bar-success { + background-image: -webkit-linear-gradient(top, #5cb85c 0%, #449d44 100%); + background-image: -o-linear-gradient(top, #5cb85c 0%, #449d44 100%); + background-image: -webkit-gradient(linear, left top, left bottom, from(#5cb85c), to(#449d44)); + background-image: linear-gradient(to bottom, #5cb85c 0%, #449d44 100%); + background-repeat: repeat-x; + filter: progid:DXImageTransform.Microsoft.gradient(startColorstr='#ff5cb85c', endColorstr='#ff449d44', GradientType=0); +} +.progress-bar-info { + background-image: -webkit-linear-gradient(top, #5bc0de 0%, #31b0d5 100%); + background-image: -o-linear-gradient(top, #5bc0de 0%, #31b0d5 100%); + background-image: -webkit-gradient(linear, left top, left bottom, from(#5bc0de), to(#31b0d5)); + background-image: linear-gradient(to bottom, #5bc0de 0%, #31b0d5 100%); + background-repeat: repeat-x; + filter: progid:DXImageTransform.Microsoft.gradient(startColorstr='#ff5bc0de', endColorstr='#ff31b0d5', GradientType=0); +} +.progress-bar-warning { + background-image: -webkit-linear-gradient(top, #f0ad4e 0%, #ec971f 100%); + background-image: -o-linear-gradient(top, #f0ad4e 0%, #ec971f 100%); + background-image: -webkit-gradient(linear, left top, left bottom, from(#f0ad4e), to(#ec971f)); + background-image: linear-gradient(to bottom, #f0ad4e 0%, #ec971f 100%); + background-repeat: repeat-x; + filter: progid:DXImageTransform.Microsoft.gradient(startColorstr='#fff0ad4e', endColorstr='#ffec971f', GradientType=0); +} +.progress-bar-danger { + background-image: -webkit-linear-gradient(top, #d9534f 0%, #c9302c 100%); + background-image: -o-linear-gradient(top, #d9534f 0%, #c9302c 100%); + background-image: -webkit-gradient(linear, left top, left bottom, from(#d9534f), to(#c9302c)); + background-image: linear-gradient(to bottom, #d9534f 0%, #c9302c 100%); + background-repeat: repeat-x; + filter: progid:DXImageTransform.Microsoft.gradient(startColorstr='#ffd9534f', endColorstr='#ffc9302c', GradientType=0); +} +.progress-bar-striped { + background-image: -webkit-linear-gradient(45deg, rgba(255, 255, 255, 0.15) 25%, transparent 25%, transparent 50%, rgba(255, 255, 255, 0.15) 50%, rgba(255, 255, 255, 0.15) 75%, transparent 75%, transparent); + background-image: -o-linear-gradient(45deg, rgba(255, 255, 255, 0.15) 25%, transparent 25%, transparent 50%, rgba(255, 255, 255, 0.15) 50%, rgba(255, 255, 255, 0.15) 75%, transparent 75%, transparent); + background-image: linear-gradient(45deg, rgba(255, 255, 255, 0.15) 25%, transparent 25%, transparent 50%, rgba(255, 255, 255, 0.15) 50%, rgba(255, 255, 255, 0.15) 75%, transparent 75%, transparent); +} +.list-group { + border-radius: 2px; + -webkit-box-shadow: 0 1px 2px rgba(0, 0, 0, 0.075); + box-shadow: 0 1px 2px rgba(0, 0, 0, 0.075); +} +.list-group-item.active, +.list-group-item.active:hover, +.list-group-item.active:focus { + text-shadow: 0 -1px 0 #286090; + background-image: -webkit-linear-gradient(top, #337ab7 0%, #2b669a 100%); + background-image: -o-linear-gradient(top, #337ab7 0%, #2b669a 100%); + background-image: -webkit-gradient(linear, left top, left bottom, from(#337ab7), to(#2b669a)); + background-image: linear-gradient(to bottom, #337ab7 0%, #2b669a 100%); + background-repeat: repeat-x; + filter: progid:DXImageTransform.Microsoft.gradient(startColorstr='#ff337ab7', endColorstr='#ff2b669a', GradientType=0); + border-color: #2b669a; +} +.list-group-item.active .badge, +.list-group-item.active:hover .badge, +.list-group-item.active:focus .badge { + text-shadow: none; +} +.panel { + -webkit-box-shadow: 0 1px 2px rgba(0, 0, 0, 0.05); + box-shadow: 0 1px 2px rgba(0, 0, 0, 0.05); +} +.panel-default > .panel-heading { + background-image: -webkit-linear-gradient(top, #f5f5f5 0%, #e8e8e8 100%); + background-image: -o-linear-gradient(top, #f5f5f5 0%, #e8e8e8 100%); + background-image: -webkit-gradient(linear, left top, left bottom, from(#f5f5f5), to(#e8e8e8)); + background-image: linear-gradient(to bottom, #f5f5f5 0%, #e8e8e8 100%); + background-repeat: repeat-x; + filter: progid:DXImageTransform.Microsoft.gradient(startColorstr='#fff5f5f5', endColorstr='#ffe8e8e8', GradientType=0); +} +.panel-primary > .panel-heading { + background-image: -webkit-linear-gradient(top, #337ab7 0%, #2e6da4 100%); + background-image: -o-linear-gradient(top, #337ab7 0%, #2e6da4 100%); + background-image: -webkit-gradient(linear, left top, left bottom, from(#337ab7), to(#2e6da4)); + background-image: linear-gradient(to bottom, #337ab7 0%, #2e6da4 100%); + background-repeat: repeat-x; + filter: progid:DXImageTransform.Microsoft.gradient(startColorstr='#ff337ab7', endColorstr='#ff2e6da4', GradientType=0); +} +.panel-success > .panel-heading { + background-image: -webkit-linear-gradient(top, #dff0d8 0%, #d0e9c6 100%); + background-image: -o-linear-gradient(top, #dff0d8 0%, #d0e9c6 100%); + background-image: -webkit-gradient(linear, left top, left bottom, from(#dff0d8), to(#d0e9c6)); + background-image: linear-gradient(to bottom, #dff0d8 0%, #d0e9c6 100%); + background-repeat: repeat-x; + filter: progid:DXImageTransform.Microsoft.gradient(startColorstr='#ffdff0d8', endColorstr='#ffd0e9c6', GradientType=0); +} +.panel-info > .panel-heading { + background-image: -webkit-linear-gradient(top, #d9edf7 0%, #c4e3f3 100%); + background-image: -o-linear-gradient(top, #d9edf7 0%, #c4e3f3 100%); + background-image: -webkit-gradient(linear, left top, left bottom, from(#d9edf7), to(#c4e3f3)); + background-image: linear-gradient(to bottom, #d9edf7 0%, #c4e3f3 100%); + background-repeat: repeat-x; + filter: progid:DXImageTransform.Microsoft.gradient(startColorstr='#ffd9edf7', endColorstr='#ffc4e3f3', GradientType=0); +} +.panel-warning > .panel-heading { + background-image: -webkit-linear-gradient(top, #fcf8e3 0%, #faf2cc 100%); + background-image: -o-linear-gradient(top, #fcf8e3 0%, #faf2cc 100%); + background-image: -webkit-gradient(linear, left top, left bottom, from(#fcf8e3), to(#faf2cc)); + background-image: linear-gradient(to bottom, #fcf8e3 0%, #faf2cc 100%); + background-repeat: repeat-x; + filter: progid:DXImageTransform.Microsoft.gradient(startColorstr='#fffcf8e3', endColorstr='#fffaf2cc', GradientType=0); +} +.panel-danger > .panel-heading { + background-image: -webkit-linear-gradient(top, #f2dede 0%, #ebcccc 100%); + background-image: -o-linear-gradient(top, #f2dede 0%, #ebcccc 100%); + background-image: -webkit-gradient(linear, left top, left bottom, from(#f2dede), to(#ebcccc)); + background-image: linear-gradient(to bottom, #f2dede 0%, #ebcccc 100%); + background-repeat: repeat-x; + filter: progid:DXImageTransform.Microsoft.gradient(startColorstr='#fff2dede', endColorstr='#ffebcccc', GradientType=0); +} +.well { + background-image: -webkit-linear-gradient(top, #e8e8e8 0%, #f5f5f5 100%); + background-image: -o-linear-gradient(top, #e8e8e8 0%, #f5f5f5 100%); + background-image: -webkit-gradient(linear, left top, left bottom, from(#e8e8e8), to(#f5f5f5)); + background-image: linear-gradient(to bottom, #e8e8e8 0%, #f5f5f5 100%); + background-repeat: repeat-x; + filter: progid:DXImageTransform.Microsoft.gradient(startColorstr='#ffe8e8e8', endColorstr='#fff5f5f5', GradientType=0); + border-color: #dcdcdc; + -webkit-box-shadow: inset 0 1px 3px rgba(0, 0, 0, 0.05), 0 1px 0 rgba(255, 255, 255, 0.1); + box-shadow: inset 0 1px 3px rgba(0, 0, 0, 0.05), 0 1px 0 rgba(255, 255, 255, 0.1); +} diff --git a/src/static/bootstrap-theme.min.css b/src/static/bootstrap-theme.min.css new file mode 100755 index 0000000..be39ca0 --- /dev/null +++ b/src/static/bootstrap-theme.min.css @@ -0,0 +1,14 @@ +/*! + * Bootstrap v3.3.5 (http://getbootstrap.com) + * Copyright 2011-2016 Twitter, Inc. + * Licensed under MIT (https://github.com/twbs/bootstrap/blob/master/LICENSE) + */ + +/*! + * Generated using the Bootstrap Customizer (http://getbootstrap.com/customize/?id=2b71cceb85b313486c11) + * Config saved to config.json and https://gist.github.com/2b71cceb85b313486c11 + *//*! + * Bootstrap v3.3.6 (http://getbootstrap.com) + * Copyright 2011-2015 Twitter, Inc. + * Licensed under MIT (https://github.com/twbs/bootstrap/blob/master/LICENSE) + */.btn-default,.btn-primary,.btn-success,.btn-info,.btn-warning,.btn-danger{text-shadow:0 -1px 0 rgba(0,0,0,0.2);-webkit-box-shadow:inset 0 1px 0 rgba(255,255,255,0.15),0 1px 1px rgba(0,0,0,0.075);box-shadow:inset 0 1px 0 rgba(255,255,255,0.15),0 1px 1px rgba(0,0,0,0.075)}.btn-default:active,.btn-primary:active,.btn-success:active,.btn-info:active,.btn-warning:active,.btn-danger:active,.btn-default.active,.btn-primary.active,.btn-success.active,.btn-info.active,.btn-warning.active,.btn-danger.active{-webkit-box-shadow:inset 0 3px 5px rgba(0,0,0,0.125);box-shadow:inset 0 3px 5px rgba(0,0,0,0.125)}.btn-default.disabled,.btn-primary.disabled,.btn-success.disabled,.btn-info.disabled,.btn-warning.disabled,.btn-danger.disabled,.btn-default[disabled],.btn-primary[disabled],.btn-success[disabled],.btn-info[disabled],.btn-warning[disabled],.btn-danger[disabled],fieldset[disabled] .btn-default,fieldset[disabled] .btn-primary,fieldset[disabled] .btn-success,fieldset[disabled] .btn-info,fieldset[disabled] .btn-warning,fieldset[disabled] .btn-danger{-webkit-box-shadow:none;box-shadow:none}.btn-default .badge,.btn-primary .badge,.btn-success .badge,.btn-info .badge,.btn-warning .badge,.btn-danger .badge{text-shadow:none}.btn:active,.btn.active{background-image:none}.btn-default{background-image:-webkit-linear-gradient(top, #fff 0, #e0e0e0 100%);background-image:-o-linear-gradient(top, #fff 0, #e0e0e0 100%);background-image:-webkit-gradient(linear, left top, left bottom, color-stop(0, #fff), to(#e0e0e0));background-image:linear-gradient(to bottom, #fff 0, #e0e0e0 100%);filter:progid:DXImageTransform.Microsoft.gradient(startColorstr='#ffffffff', endColorstr='#ffe0e0e0', GradientType=0);filter:progid:DXImageTransform.Microsoft.gradient(enabled = false);background-repeat:repeat-x;border-color:#dbdbdb;text-shadow:0 1px 0 #fff;border-color:#ccc}.btn-default:hover,.btn-default:focus{background-color:#e0e0e0;background-position:0 -15px}.btn-default:active,.btn-default.active{background-color:#e0e0e0;border-color:#dbdbdb}.btn-default.disabled,.btn-default[disabled],fieldset[disabled] .btn-default,.btn-default.disabled:hover,.btn-default[disabled]:hover,fieldset[disabled] .btn-default:hover,.btn-default.disabled:focus,.btn-default[disabled]:focus,fieldset[disabled] .btn-default:focus,.btn-default.disabled.focus,.btn-default[disabled].focus,fieldset[disabled] .btn-default.focus,.btn-default.disabled:active,.btn-default[disabled]:active,fieldset[disabled] .btn-default:active,.btn-default.disabled.active,.btn-default[disabled].active,fieldset[disabled] .btn-default.active{background-color:#e0e0e0;background-image:none}.btn-primary{background-image:-webkit-linear-gradient(top, #337ab7 0, #265a88 100%);background-image:-o-linear-gradient(top, #337ab7 0, #265a88 100%);background-image:-webkit-gradient(linear, left top, left bottom, color-stop(0, #337ab7), to(#265a88));background-image:linear-gradient(to bottom, #337ab7 0, #265a88 100%);filter:progid:DXImageTransform.Microsoft.gradient(startColorstr='#ff337ab7', endColorstr='#ff265a88', GradientType=0);filter:progid:DXImageTransform.Microsoft.gradient(enabled = false);background-repeat:repeat-x;border-color:#245580}.btn-primary:hover,.btn-primary:focus{background-color:#265a88;background-position:0 -15px}.btn-primary:active,.btn-primary.active{background-color:#265a88;border-color:#245580}.btn-primary.disabled,.btn-primary[disabled],fieldset[disabled] .btn-primary,.btn-primary.disabled:hover,.btn-primary[disabled]:hover,fieldset[disabled] .btn-primary:hover,.btn-primary.disabled:focus,.btn-primary[disabled]:focus,fieldset[disabled] .btn-primary:focus,.btn-primary.disabled.focus,.btn-primary[disabled].focus,fieldset[disabled] .btn-primary.focus,.btn-primary.disabled:active,.btn-primary[disabled]:active,fieldset[disabled] .btn-primary:active,.btn-primary.disabled.active,.btn-primary[disabled].active,fieldset[disabled] .btn-primary.active{background-color:#265a88;background-image:none}.btn-success{background-image:-webkit-linear-gradient(top, #5cb85c 0, #419641 100%);background-image:-o-linear-gradient(top, #5cb85c 0, #419641 100%);background-image:-webkit-gradient(linear, left top, left bottom, color-stop(0, #5cb85c), to(#419641));background-image:linear-gradient(to bottom, #5cb85c 0, #419641 100%);filter:progid:DXImageTransform.Microsoft.gradient(startColorstr='#ff5cb85c', endColorstr='#ff419641', GradientType=0);filter:progid:DXImageTransform.Microsoft.gradient(enabled = false);background-repeat:repeat-x;border-color:#3e8f3e}.btn-success:hover,.btn-success:focus{background-color:#419641;background-position:0 -15px}.btn-success:active,.btn-success.active{background-color:#419641;border-color:#3e8f3e}.btn-success.disabled,.btn-success[disabled],fieldset[disabled] .btn-success,.btn-success.disabled:hover,.btn-success[disabled]:hover,fieldset[disabled] .btn-success:hover,.btn-success.disabled:focus,.btn-success[disabled]:focus,fieldset[disabled] .btn-success:focus,.btn-success.disabled.focus,.btn-success[disabled].focus,fieldset[disabled] .btn-success.focus,.btn-success.disabled:active,.btn-success[disabled]:active,fieldset[disabled] .btn-success:active,.btn-success.disabled.active,.btn-success[disabled].active,fieldset[disabled] .btn-success.active{background-color:#419641;background-image:none}.btn-info{background-image:-webkit-linear-gradient(top, #5bc0de 0, #2aabd2 100%);background-image:-o-linear-gradient(top, #5bc0de 0, #2aabd2 100%);background-image:-webkit-gradient(linear, left top, left bottom, color-stop(0, #5bc0de), to(#2aabd2));background-image:linear-gradient(to bottom, #5bc0de 0, #2aabd2 100%);filter:progid:DXImageTransform.Microsoft.gradient(startColorstr='#ff5bc0de', endColorstr='#ff2aabd2', GradientType=0);filter:progid:DXImageTransform.Microsoft.gradient(enabled = false);background-repeat:repeat-x;border-color:#28a4c9}.btn-info:hover,.btn-info:focus{background-color:#2aabd2;background-position:0 -15px}.btn-info:active,.btn-info.active{background-color:#2aabd2;border-color:#28a4c9}.btn-info.disabled,.btn-info[disabled],fieldset[disabled] .btn-info,.btn-info.disabled:hover,.btn-info[disabled]:hover,fieldset[disabled] .btn-info:hover,.btn-info.disabled:focus,.btn-info[disabled]:focus,fieldset[disabled] .btn-info:focus,.btn-info.disabled.focus,.btn-info[disabled].focus,fieldset[disabled] .btn-info.focus,.btn-info.disabled:active,.btn-info[disabled]:active,fieldset[disabled] .btn-info:active,.btn-info.disabled.active,.btn-info[disabled].active,fieldset[disabled] .btn-info.active{background-color:#2aabd2;background-image:none}.btn-warning{background-image:-webkit-linear-gradient(top, #f0ad4e 0, #eb9316 100%);background-image:-o-linear-gradient(top, #f0ad4e 0, #eb9316 100%);background-image:-webkit-gradient(linear, left top, left bottom, color-stop(0, #f0ad4e), to(#eb9316));background-image:linear-gradient(to bottom, #f0ad4e 0, #eb9316 100%);filter:progid:DXImageTransform.Microsoft.gradient(startColorstr='#fff0ad4e', endColorstr='#ffeb9316', GradientType=0);filter:progid:DXImageTransform.Microsoft.gradient(enabled = false);background-repeat:repeat-x;border-color:#e38d13}.btn-warning:hover,.btn-warning:focus{background-color:#eb9316;background-position:0 -15px}.btn-warning:active,.btn-warning.active{background-color:#eb9316;border-color:#e38d13}.btn-warning.disabled,.btn-warning[disabled],fieldset[disabled] .btn-warning,.btn-warning.disabled:hover,.btn-warning[disabled]:hover,fieldset[disabled] .btn-warning:hover,.btn-warning.disabled:focus,.btn-warning[disabled]:focus,fieldset[disabled] .btn-warning:focus,.btn-warning.disabled.focus,.btn-warning[disabled].focus,fieldset[disabled] .btn-warning.focus,.btn-warning.disabled:active,.btn-warning[disabled]:active,fieldset[disabled] .btn-warning:active,.btn-warning.disabled.active,.btn-warning[disabled].active,fieldset[disabled] .btn-warning.active{background-color:#eb9316;background-image:none}.btn-danger{background-image:-webkit-linear-gradient(top, #d9534f 0, #c12e2a 100%);background-image:-o-linear-gradient(top, #d9534f 0, #c12e2a 100%);background-image:-webkit-gradient(linear, left top, left bottom, color-stop(0, #d9534f), to(#c12e2a));background-image:linear-gradient(to bottom, #d9534f 0, #c12e2a 100%);filter:progid:DXImageTransform.Microsoft.gradient(startColorstr='#ffd9534f', endColorstr='#ffc12e2a', GradientType=0);filter:progid:DXImageTransform.Microsoft.gradient(enabled = false);background-repeat:repeat-x;border-color:#b92c28}.btn-danger:hover,.btn-danger:focus{background-color:#c12e2a;background-position:0 -15px}.btn-danger:active,.btn-danger.active{background-color:#c12e2a;border-color:#b92c28}.btn-danger.disabled,.btn-danger[disabled],fieldset[disabled] .btn-danger,.btn-danger.disabled:hover,.btn-danger[disabled]:hover,fieldset[disabled] .btn-danger:hover,.btn-danger.disabled:focus,.btn-danger[disabled]:focus,fieldset[disabled] .btn-danger:focus,.btn-danger.disabled.focus,.btn-danger[disabled].focus,fieldset[disabled] .btn-danger.focus,.btn-danger.disabled:active,.btn-danger[disabled]:active,fieldset[disabled] .btn-danger:active,.btn-danger.disabled.active,.btn-danger[disabled].active,fieldset[disabled] .btn-danger.active{background-color:#c12e2a;background-image:none}.thumbnail,.img-thumbnail{-webkit-box-shadow:0 1px 2px rgba(0,0,0,0.075);box-shadow:0 1px 2px rgba(0,0,0,0.075)}.dropdown-menu>li>a:hover,.dropdown-menu>li>a:focus{background-image:-webkit-linear-gradient(top, #f5f5f5 0, #e8e8e8 100%);background-image:-o-linear-gradient(top, #f5f5f5 0, #e8e8e8 100%);background-image:-webkit-gradient(linear, left top, left bottom, color-stop(0, #f5f5f5), to(#e8e8e8));background-image:linear-gradient(to bottom, #f5f5f5 0, #e8e8e8 100%);background-repeat:repeat-x;filter:progid:DXImageTransform.Microsoft.gradient(startColorstr='#fff5f5f5', endColorstr='#ffe8e8e8', GradientType=0);background-color:#e8e8e8}.dropdown-menu>.active>a,.dropdown-menu>.active>a:hover,.dropdown-menu>.active>a:focus{background-image:-webkit-linear-gradient(top, #337ab7 0, #2e6da4 100%);background-image:-o-linear-gradient(top, #337ab7 0, #2e6da4 100%);background-image:-webkit-gradient(linear, left top, left bottom, color-stop(0, #337ab7), to(#2e6da4));background-image:linear-gradient(to bottom, #337ab7 0, #2e6da4 100%);background-repeat:repeat-x;filter:progid:DXImageTransform.Microsoft.gradient(startColorstr='#ff337ab7', endColorstr='#ff2e6da4', GradientType=0);background-color:#2e6da4}.navbar-default{background-image:-webkit-linear-gradient(top, #fff 0, #f8f8f8 100%);background-image:-o-linear-gradient(top, #fff 0, #f8f8f8 100%);background-image:-webkit-gradient(linear, left top, left bottom, color-stop(0, #fff), to(#f8f8f8));background-image:linear-gradient(to bottom, #fff 0, #f8f8f8 100%);background-repeat:repeat-x;filter:progid:DXImageTransform.Microsoft.gradient(startColorstr='#ffffffff', endColorstr='#fff8f8f8', GradientType=0);filter:progid:DXImageTransform.Microsoft.gradient(enabled = false);border-radius:2px;-webkit-box-shadow:inset 0 1px 0 rgba(255,255,255,0.15),0 1px 5px rgba(0,0,0,0.075);box-shadow:inset 0 1px 0 rgba(255,255,255,0.15),0 1px 5px rgba(0,0,0,0.075)}.navbar-default .navbar-nav>.open>a,.navbar-default .navbar-nav>.active>a{background-image:-webkit-linear-gradient(top, #dbdbdb 0, #e2e2e2 100%);background-image:-o-linear-gradient(top, #dbdbdb 0, #e2e2e2 100%);background-image:-webkit-gradient(linear, left top, left bottom, color-stop(0, #dbdbdb), to(#e2e2e2));background-image:linear-gradient(to bottom, #dbdbdb 0, #e2e2e2 100%);background-repeat:repeat-x;filter:progid:DXImageTransform.Microsoft.gradient(startColorstr='#ffdbdbdb', endColorstr='#ffe2e2e2', GradientType=0);-webkit-box-shadow:inset 0 3px 9px rgba(0,0,0,0.075);box-shadow:inset 0 3px 9px rgba(0,0,0,0.075)}.navbar-brand,.navbar-nav>li>a{text-shadow:0 1px 0 rgba(255,255,255,0.25)}.navbar-inverse{background-image:-webkit-linear-gradient(top, #3c3c3c 0, #222 100%);background-image:-o-linear-gradient(top, #3c3c3c 0, #222 100%);background-image:-webkit-gradient(linear, left top, left bottom, color-stop(0, #3c3c3c), to(#222));background-image:linear-gradient(to bottom, #3c3c3c 0, #222 100%);background-repeat:repeat-x;filter:progid:DXImageTransform.Microsoft.gradient(startColorstr='#ff3c3c3c', endColorstr='#ff222222', GradientType=0);filter:progid:DXImageTransform.Microsoft.gradient(enabled = false);border-radius:2px}.navbar-inverse .navbar-nav>.open>a,.navbar-inverse .navbar-nav>.active>a{background-image:-webkit-linear-gradient(top, #080808 0, #0f0f0f 100%);background-image:-o-linear-gradient(top, #080808 0, #0f0f0f 100%);background-image:-webkit-gradient(linear, left top, left bottom, color-stop(0, #080808), to(#0f0f0f));background-image:linear-gradient(to bottom, #080808 0, #0f0f0f 100%);background-repeat:repeat-x;filter:progid:DXImageTransform.Microsoft.gradient(startColorstr='#ff080808', endColorstr='#ff0f0f0f', GradientType=0);-webkit-box-shadow:inset 0 3px 9px rgba(0,0,0,0.25);box-shadow:inset 0 3px 9px rgba(0,0,0,0.25)}.navbar-inverse .navbar-brand,.navbar-inverse .navbar-nav>li>a{text-shadow:0 -1px 0 rgba(0,0,0,0.25)}.navbar-static-top,.navbar-fixed-top,.navbar-fixed-bottom{border-radius:0}@media (max-width:767px){.navbar .navbar-nav .open .dropdown-menu>.active>a,.navbar .navbar-nav .open .dropdown-menu>.active>a:hover,.navbar .navbar-nav .open .dropdown-menu>.active>a:focus{color:#fff;background-image:-webkit-linear-gradient(top, #337ab7 0, #2e6da4 100%);background-image:-o-linear-gradient(top, #337ab7 0, #2e6da4 100%);background-image:-webkit-gradient(linear, left top, left bottom, color-stop(0, #337ab7), to(#2e6da4));background-image:linear-gradient(to bottom, #337ab7 0, #2e6da4 100%);background-repeat:repeat-x;filter:progid:DXImageTransform.Microsoft.gradient(startColorstr='#ff337ab7', endColorstr='#ff2e6da4', GradientType=0)}}.alert{text-shadow:0 1px 0 rgba(255,255,255,0.2);-webkit-box-shadow:inset 0 1px 0 rgba(255,255,255,0.25),0 1px 2px rgba(0,0,0,0.05);box-shadow:inset 0 1px 0 rgba(255,255,255,0.25),0 1px 2px rgba(0,0,0,0.05)}.alert-success{background-image:-webkit-linear-gradient(top, #dff0d8 0, #c8e5bc 100%);background-image:-o-linear-gradient(top, #dff0d8 0, #c8e5bc 100%);background-image:-webkit-gradient(linear, left top, left bottom, color-stop(0, #dff0d8), to(#c8e5bc));background-image:linear-gradient(to bottom, #dff0d8 0, #c8e5bc 100%);background-repeat:repeat-x;filter:progid:DXImageTransform.Microsoft.gradient(startColorstr='#ffdff0d8', endColorstr='#ffc8e5bc', GradientType=0);border-color:#b2dba1}.alert-info{background-image:-webkit-linear-gradient(top, #d9edf7 0, #b9def0 100%);background-image:-o-linear-gradient(top, #d9edf7 0, #b9def0 100%);background-image:-webkit-gradient(linear, left top, left bottom, color-stop(0, #d9edf7), to(#b9def0));background-image:linear-gradient(to bottom, #d9edf7 0, #b9def0 100%);background-repeat:repeat-x;filter:progid:DXImageTransform.Microsoft.gradient(startColorstr='#ffd9edf7', endColorstr='#ffb9def0', GradientType=0);border-color:#9acfea}.alert-warning{background-image:-webkit-linear-gradient(top, #fcf8e3 0, #f8efc0 100%);background-image:-o-linear-gradient(top, #fcf8e3 0, #f8efc0 100%);background-image:-webkit-gradient(linear, left top, left bottom, color-stop(0, #fcf8e3), to(#f8efc0));background-image:linear-gradient(to bottom, #fcf8e3 0, #f8efc0 100%);background-repeat:repeat-x;filter:progid:DXImageTransform.Microsoft.gradient(startColorstr='#fffcf8e3', endColorstr='#fff8efc0', GradientType=0);border-color:#f5e79e}.alert-danger{background-image:-webkit-linear-gradient(top, #f2dede 0, #e7c3c3 100%);background-image:-o-linear-gradient(top, #f2dede 0, #e7c3c3 100%);background-image:-webkit-gradient(linear, left top, left bottom, color-stop(0, #f2dede), to(#e7c3c3));background-image:linear-gradient(to bottom, #f2dede 0, #e7c3c3 100%);background-repeat:repeat-x;filter:progid:DXImageTransform.Microsoft.gradient(startColorstr='#fff2dede', endColorstr='#ffe7c3c3', GradientType=0);border-color:#dca7a7}.progress{background-image:-webkit-linear-gradient(top, #ebebeb 0, #f5f5f5 100%);background-image:-o-linear-gradient(top, #ebebeb 0, #f5f5f5 100%);background-image:-webkit-gradient(linear, left top, left bottom, color-stop(0, #ebebeb), to(#f5f5f5));background-image:linear-gradient(to bottom, #ebebeb 0, #f5f5f5 100%);background-repeat:repeat-x;filter:progid:DXImageTransform.Microsoft.gradient(startColorstr='#ffebebeb', endColorstr='#fff5f5f5', GradientType=0)}.progress-bar{background-image:-webkit-linear-gradient(top, #337ab7 0, #286090 100%);background-image:-o-linear-gradient(top, #337ab7 0, #286090 100%);background-image:-webkit-gradient(linear, left top, left bottom, color-stop(0, #337ab7), to(#286090));background-image:linear-gradient(to bottom, #337ab7 0, #286090 100%);background-repeat:repeat-x;filter:progid:DXImageTransform.Microsoft.gradient(startColorstr='#ff337ab7', endColorstr='#ff286090', GradientType=0)}.progress-bar-success{background-image:-webkit-linear-gradient(top, #5cb85c 0, #449d44 100%);background-image:-o-linear-gradient(top, #5cb85c 0, #449d44 100%);background-image:-webkit-gradient(linear, left top, left bottom, color-stop(0, #5cb85c), to(#449d44));background-image:linear-gradient(to bottom, #5cb85c 0, #449d44 100%);background-repeat:repeat-x;filter:progid:DXImageTransform.Microsoft.gradient(startColorstr='#ff5cb85c', endColorstr='#ff449d44', GradientType=0)}.progress-bar-info{background-image:-webkit-linear-gradient(top, #5bc0de 0, #31b0d5 100%);background-image:-o-linear-gradient(top, #5bc0de 0, #31b0d5 100%);background-image:-webkit-gradient(linear, left top, left bottom, color-stop(0, #5bc0de), to(#31b0d5));background-image:linear-gradient(to bottom, #5bc0de 0, #31b0d5 100%);background-repeat:repeat-x;filter:progid:DXImageTransform.Microsoft.gradient(startColorstr='#ff5bc0de', endColorstr='#ff31b0d5', GradientType=0)}.progress-bar-warning{background-image:-webkit-linear-gradient(top, #f0ad4e 0, #ec971f 100%);background-image:-o-linear-gradient(top, #f0ad4e 0, #ec971f 100%);background-image:-webkit-gradient(linear, left top, left bottom, color-stop(0, #f0ad4e), to(#ec971f));background-image:linear-gradient(to bottom, #f0ad4e 0, #ec971f 100%);background-repeat:repeat-x;filter:progid:DXImageTransform.Microsoft.gradient(startColorstr='#fff0ad4e', endColorstr='#ffec971f', GradientType=0)}.progress-bar-danger{background-image:-webkit-linear-gradient(top, #d9534f 0, #c9302c 100%);background-image:-o-linear-gradient(top, #d9534f 0, #c9302c 100%);background-image:-webkit-gradient(linear, left top, left bottom, color-stop(0, #d9534f), to(#c9302c));background-image:linear-gradient(to bottom, #d9534f 0, #c9302c 100%);background-repeat:repeat-x;filter:progid:DXImageTransform.Microsoft.gradient(startColorstr='#ffd9534f', endColorstr='#ffc9302c', GradientType=0)}.progress-bar-striped{background-image:-webkit-linear-gradient(45deg, rgba(255,255,255,0.15) 25%, transparent 25%, transparent 50%, rgba(255,255,255,0.15) 50%, rgba(255,255,255,0.15) 75%, transparent 75%, transparent);background-image:-o-linear-gradient(45deg, rgba(255,255,255,0.15) 25%, transparent 25%, transparent 50%, rgba(255,255,255,0.15) 50%, rgba(255,255,255,0.15) 75%, transparent 75%, transparent);background-image:linear-gradient(45deg, rgba(255,255,255,0.15) 25%, transparent 25%, transparent 50%, rgba(255,255,255,0.15) 50%, rgba(255,255,255,0.15) 75%, transparent 75%, transparent)}.list-group{border-radius:2px;-webkit-box-shadow:0 1px 2px rgba(0,0,0,0.075);box-shadow:0 1px 2px rgba(0,0,0,0.075)}.list-group-item.active,.list-group-item.active:hover,.list-group-item.active:focus{text-shadow:0 -1px 0 #286090;background-image:-webkit-linear-gradient(top, #337ab7 0, #2b669a 100%);background-image:-o-linear-gradient(top, #337ab7 0, #2b669a 100%);background-image:-webkit-gradient(linear, left top, left bottom, color-stop(0, #337ab7), to(#2b669a));background-image:linear-gradient(to bottom, #337ab7 0, #2b669a 100%);background-repeat:repeat-x;filter:progid:DXImageTransform.Microsoft.gradient(startColorstr='#ff337ab7', endColorstr='#ff2b669a', GradientType=0);border-color:#2b669a}.list-group-item.active .badge,.list-group-item.active:hover .badge,.list-group-item.active:focus .badge{text-shadow:none}.panel{-webkit-box-shadow:0 1px 2px rgba(0,0,0,0.05);box-shadow:0 1px 2px rgba(0,0,0,0.05)}.panel-default>.panel-heading{background-image:-webkit-linear-gradient(top, #f5f5f5 0, #e8e8e8 100%);background-image:-o-linear-gradient(top, #f5f5f5 0, #e8e8e8 100%);background-image:-webkit-gradient(linear, left top, left bottom, color-stop(0, #f5f5f5), to(#e8e8e8));background-image:linear-gradient(to bottom, #f5f5f5 0, #e8e8e8 100%);background-repeat:repeat-x;filter:progid:DXImageTransform.Microsoft.gradient(startColorstr='#fff5f5f5', endColorstr='#ffe8e8e8', GradientType=0)}.panel-primary>.panel-heading{background-image:-webkit-linear-gradient(top, #337ab7 0, #2e6da4 100%);background-image:-o-linear-gradient(top, #337ab7 0, #2e6da4 100%);background-image:-webkit-gradient(linear, left top, left bottom, color-stop(0, #337ab7), to(#2e6da4));background-image:linear-gradient(to bottom, #337ab7 0, #2e6da4 100%);background-repeat:repeat-x;filter:progid:DXImageTransform.Microsoft.gradient(startColorstr='#ff337ab7', endColorstr='#ff2e6da4', GradientType=0)}.panel-success>.panel-heading{background-image:-webkit-linear-gradient(top, #dff0d8 0, #d0e9c6 100%);background-image:-o-linear-gradient(top, #dff0d8 0, #d0e9c6 100%);background-image:-webkit-gradient(linear, left top, left bottom, color-stop(0, #dff0d8), to(#d0e9c6));background-image:linear-gradient(to bottom, #dff0d8 0, #d0e9c6 100%);background-repeat:repeat-x;filter:progid:DXImageTransform.Microsoft.gradient(startColorstr='#ffdff0d8', endColorstr='#ffd0e9c6', GradientType=0)}.panel-info>.panel-heading{background-image:-webkit-linear-gradient(top, #d9edf7 0, #c4e3f3 100%);background-image:-o-linear-gradient(top, #d9edf7 0, #c4e3f3 100%);background-image:-webkit-gradient(linear, left top, left bottom, color-stop(0, #d9edf7), to(#c4e3f3));background-image:linear-gradient(to bottom, #d9edf7 0, #c4e3f3 100%);background-repeat:repeat-x;filter:progid:DXImageTransform.Microsoft.gradient(startColorstr='#ffd9edf7', endColorstr='#ffc4e3f3', GradientType=0)}.panel-warning>.panel-heading{background-image:-webkit-linear-gradient(top, #fcf8e3 0, #faf2cc 100%);background-image:-o-linear-gradient(top, #fcf8e3 0, #faf2cc 100%);background-image:-webkit-gradient(linear, left top, left bottom, color-stop(0, #fcf8e3), to(#faf2cc));background-image:linear-gradient(to bottom, #fcf8e3 0, #faf2cc 100%);background-repeat:repeat-x;filter:progid:DXImageTransform.Microsoft.gradient(startColorstr='#fffcf8e3', endColorstr='#fffaf2cc', GradientType=0)}.panel-danger>.panel-heading{background-image:-webkit-linear-gradient(top, #f2dede 0, #ebcccc 100%);background-image:-o-linear-gradient(top, #f2dede 0, #ebcccc 100%);background-image:-webkit-gradient(linear, left top, left bottom, color-stop(0, #f2dede), to(#ebcccc));background-image:linear-gradient(to bottom, #f2dede 0, #ebcccc 100%);background-repeat:repeat-x;filter:progid:DXImageTransform.Microsoft.gradient(startColorstr='#fff2dede', endColorstr='#ffebcccc', GradientType=0)}.well{background-image:-webkit-linear-gradient(top, #e8e8e8 0, #f5f5f5 100%);background-image:-o-linear-gradient(top, #e8e8e8 0, #f5f5f5 100%);background-image:-webkit-gradient(linear, left top, left bottom, color-stop(0, #e8e8e8), to(#f5f5f5));background-image:linear-gradient(to bottom, #e8e8e8 0, #f5f5f5 100%);background-repeat:repeat-x;filter:progid:DXImageTransform.Microsoft.gradient(startColorstr='#ffe8e8e8', endColorstr='#fff5f5f5', GradientType=0);border-color:#dcdcdc;-webkit-box-shadow:inset 0 1px 3px rgba(0,0,0,0.05),0 1px 0 rgba(255,255,255,0.1);box-shadow:inset 0 1px 3px rgba(0,0,0,0.05),0 1px 0 rgba(255,255,255,0.1)} \ No newline at end of file diff --git a/src/static/bootstrap.css b/src/static/bootstrap.css new file mode 100755 index 0000000..15f9a60 --- /dev/null +++ b/src/static/bootstrap.css @@ -0,0 +1,630 @@ +/*! + * Bootstrap v3.3.5 (http://getbootstrap.com) + * Copyright 2011-2016 Twitter, Inc. + * Licensed under MIT (https://github.com/twbs/bootstrap/blob/master/LICENSE) + */ + +/*! + * Generated using the Bootstrap Customizer (http://getbootstrap.com/customize/?id=2b71cceb85b313486c11) + * Config saved to config.json and https://gist.github.com/2b71cceb85b313486c11 + */ +/*! + * Bootstrap v3.3.6 (http://getbootstrap.com) + * Copyright 2011-2015 Twitter, Inc. + * Licensed under MIT (https://github.com/twbs/bootstrap/blob/master/LICENSE) + */ +/*! normalize.css v3.0.3 | MIT License | github.com/necolas/normalize.css */ +html { + font-family: sans-serif; + -ms-text-size-adjust: 100%; + -webkit-text-size-adjust: 100%; +} +body { + margin: 0; +} +article, +aside, +details, +figcaption, +figure, +footer, +header, +hgroup, +main, +menu, +nav, +section, +summary { + display: block; +} +audio, +canvas, +progress, +video { + display: inline-block; + vertical-align: baseline; +} +audio:not([controls]) { + display: none; + height: 0; +} +[hidden], +template { + display: none; +} +a { + background-color: transparent; +} +a:active, +a:hover { + outline: 0; +} +abbr[title] { + border-bottom: 1px dotted; +} +b, +strong { + font-weight: bold; +} +dfn { + font-style: italic; +} +h1 { + font-size: 2em; + margin: 0.67em 0; +} +mark { + background: #ff0; + color: #000; +} +small { + font-size: 80%; +} +sub, +sup { + font-size: 75%; + line-height: 0; + position: relative; + vertical-align: baseline; +} +sup { + top: -0.5em; +} +sub { + bottom: -0.25em; +} +img { + border: 0; +} +svg:not(:root) { + overflow: hidden; +} +figure { + margin: 1em 40px; +} +hr { + -webkit-box-sizing: content-box; + -moz-box-sizing: content-box; + box-sizing: content-box; + height: 0; +} +pre { + overflow: auto; +} +code, +kbd, +pre, +samp { + font-family: monospace, monospace; + font-size: 1em; +} +button, +input, +optgroup, +select, +textarea { + color: inherit; + font: inherit; + margin: 0; +} +button { + overflow: visible; +} +button, +select { + text-transform: none; +} +button, +html input[type="button"], +input[type="reset"], +input[type="submit"] { + -webkit-appearance: button; + cursor: pointer; +} +button[disabled], +html input[disabled] { + cursor: default; +} +button::-moz-focus-inner, +input::-moz-focus-inner { + border: 0; + padding: 0; +} +input { + line-height: normal; +} +input[type="checkbox"], +input[type="radio"] { + -webkit-box-sizing: border-box; + -moz-box-sizing: border-box; + box-sizing: border-box; + padding: 0; +} +input[type="number"]::-webkit-inner-spin-button, +input[type="number"]::-webkit-outer-spin-button { + height: auto; +} +input[type="search"] { + -webkit-appearance: textfield; + -webkit-box-sizing: content-box; + -moz-box-sizing: content-box; + box-sizing: content-box; +} +input[type="search"]::-webkit-search-cancel-button, +input[type="search"]::-webkit-search-decoration { + -webkit-appearance: none; +} +fieldset { + border: 1px solid #c0c0c0; + margin: 0 2px; + padding: 0.35em 0.625em 0.75em; +} +legend { + border: 0; + padding: 0; +} +textarea { + overflow: auto; +} +optgroup { + font-weight: bold; +} +table { + border-collapse: collapse; + border-spacing: 0; +} +td, +th { + padding: 0; +} +* { + -webkit-box-sizing: border-box; + -moz-box-sizing: border-box; + box-sizing: border-box; +} +*:before, +*:after { + -webkit-box-sizing: border-box; + -moz-box-sizing: border-box; + box-sizing: border-box; +} +html { + font-size: 10px; + -webkit-tap-highlight-color: rgba(0, 0, 0, 0); +} +body { + font-family: "Helvetica Neue", Helvetica, Arial, sans-serif; + font-size: 14px; + line-height: 1.42857143; + color: #333333; + background-color: #ffffff; +} +input, +button, +select, +textarea { + font-family: inherit; + font-size: inherit; + line-height: inherit; +} +a { + color: #337ab7; + text-decoration: none; +} +a:hover, +a:focus { + color: #23527c; + text-decoration: underline; +} +a:focus { + outline: thin dotted; + outline: 5px auto -webkit-focus-ring-color; + outline-offset: -2px; +} +figure { + margin: 0; +} +img { + vertical-align: middle; +} +.img-responsive { + display: block; + max-width: 100%; + height: auto; +} +.img-rounded { + border-radius: 2px; +} +.img-thumbnail { + padding: 4px; + line-height: 1.42857143; + background-color: #ffffff; + border: 1px solid #dddddd; + border-radius: 2px; + -webkit-transition: all 0.2s ease-in-out; + -o-transition: all 0.2s ease-in-out; + transition: all 0.2s ease-in-out; + display: inline-block; + max-width: 100%; + height: auto; +} +.img-circle { + border-radius: 50%; +} +hr { + margin-top: 20px; + margin-bottom: 20px; + border: 0; + border-top: 1px solid #eeeeee; +} +.sr-only { + position: absolute; + width: 1px; + height: 1px; + margin: -1px; + padding: 0; + overflow: hidden; + clip: rect(0, 0, 0, 0); + border: 0; +} +.sr-only-focusable:active, +.sr-only-focusable:focus { + position: static; + width: auto; + height: auto; + margin: 0; + overflow: visible; + clip: auto; +} +[role="button"] { + cursor: pointer; +} +table { + background-color: transparent; +} +caption { + padding-top: 8px; + padding-bottom: 8px; + color: #777777; + text-align: left; +} +th { + text-align: left; +} +.table { + width: 100%; + max-width: 100%; + margin-bottom: 20px; +} +.table > thead > tr > th, +.table > tbody > tr > th, +.table > tfoot > tr > th, +.table > thead > tr > td, +.table > tbody > tr > td, +.table > tfoot > tr > td { + padding: 8px; + line-height: 1.42857143; + vertical-align: top; + border-top: 1px solid #dddddd; +} +.table > thead > tr > th { + vertical-align: bottom; + border-bottom: 2px solid #dddddd; +} +.table > caption + thead > tr:first-child > th, +.table > colgroup + thead > tr:first-child > th, +.table > thead:first-child > tr:first-child > th, +.table > caption + thead > tr:first-child > td, +.table > colgroup + thead > tr:first-child > td, +.table > thead:first-child > tr:first-child > td { + border-top: 0; +} +.table > tbody + tbody { + border-top: 2px solid #dddddd; +} +.table .table { + background-color: #ffffff; +} +.table-condensed > thead > tr > th, +.table-condensed > tbody > tr > th, +.table-condensed > tfoot > tr > th, +.table-condensed > thead > tr > td, +.table-condensed > tbody > tr > td, +.table-condensed > tfoot > tr > td { + padding: 5px; +} +.table-bordered { + border: 1px solid #dddddd; +} +.table-bordered > thead > tr > th, +.table-bordered > tbody > tr > th, +.table-bordered > tfoot > tr > th, +.table-bordered > thead > tr > td, +.table-bordered > tbody > tr > td, +.table-bordered > tfoot > tr > td { + border: 1px solid #dddddd; +} +.table-bordered > thead > tr > th, +.table-bordered > thead > tr > td { + border-bottom-width: 2px; +} +.table-striped > tbody > tr:nth-of-type(odd) { + background-color: #f9f9f9; +} +.table-hover > tbody > tr:hover { + background-color: #f5f5f5; +} +table col[class*="col-"] { + position: static; + float: none; + display: table-column; +} +table td[class*="col-"], +table th[class*="col-"] { + position: static; + float: none; + display: table-cell; +} +.table > thead > tr > td.active, +.table > tbody > tr > td.active, +.table > tfoot > tr > td.active, +.table > thead > tr > th.active, +.table > tbody > tr > th.active, +.table > tfoot > tr > th.active, +.table > thead > tr.active > td, +.table > tbody > tr.active > td, +.table > tfoot > tr.active > td, +.table > thead > tr.active > th, +.table > tbody > tr.active > th, +.table > tfoot > tr.active > th { + background-color: #f5f5f5; +} +.table-hover > tbody > tr > td.active:hover, +.table-hover > tbody > tr > th.active:hover, +.table-hover > tbody > tr.active:hover > td, +.table-hover > tbody > tr:hover > .active, +.table-hover > tbody > tr.active:hover > th { + background-color: #e8e8e8; +} +.table > thead > tr > td.success, +.table > tbody > tr > td.success, +.table > tfoot > tr > td.success, +.table > thead > tr > th.success, +.table > tbody > tr > th.success, +.table > tfoot > tr > th.success, +.table > thead > tr.success > td, +.table > tbody > tr.success > td, +.table > tfoot > tr.success > td, +.table > thead > tr.success > th, +.table > tbody > tr.success > th, +.table > tfoot > tr.success > th { + background-color: #dff0d8; +} +.table-hover > tbody > tr > td.success:hover, +.table-hover > tbody > tr > th.success:hover, +.table-hover > tbody > tr.success:hover > td, +.table-hover > tbody > tr:hover > .success, +.table-hover > tbody > tr.success:hover > th { + background-color: #d0e9c6; +} +.table > thead > tr > td.info, +.table > tbody > tr > td.info, +.table > tfoot > tr > td.info, +.table > thead > tr > th.info, +.table > tbody > tr > th.info, +.table > tfoot > tr > th.info, +.table > thead > tr.info > td, +.table > tbody > tr.info > td, +.table > tfoot > tr.info > td, +.table > thead > tr.info > th, +.table > tbody > tr.info > th, +.table > tfoot > tr.info > th { + background-color: #d9edf7; +} +.table-hover > tbody > tr > td.info:hover, +.table-hover > tbody > tr > th.info:hover, +.table-hover > tbody > tr.info:hover > td, +.table-hover > tbody > tr:hover > .info, +.table-hover > tbody > tr.info:hover > th { + background-color: #c4e3f3; +} +.table > thead > tr > td.warning, +.table > tbody > tr > td.warning, +.table > tfoot > tr > td.warning, +.table > thead > tr > th.warning, +.table > tbody > tr > th.warning, +.table > tfoot > tr > th.warning, +.table > thead > tr.warning > td, +.table > tbody > tr.warning > td, +.table > tfoot > tr.warning > td, +.table > thead > tr.warning > th, +.table > tbody > tr.warning > th, +.table > tfoot > tr.warning > th { + background-color: #fcf8e3; +} +.table-hover > tbody > tr > td.warning:hover, +.table-hover > tbody > tr > th.warning:hover, +.table-hover > tbody > tr.warning:hover > td, +.table-hover > tbody > tr:hover > .warning, +.table-hover > tbody > tr.warning:hover > th { + background-color: #faf2cc; +} +.table > thead > tr > td.danger, +.table > tbody > tr > td.danger, +.table > tfoot > tr > td.danger, +.table > thead > tr > th.danger, +.table > tbody > tr > th.danger, +.table > tfoot > tr > th.danger, +.table > thead > tr.danger > td, +.table > tbody > tr.danger > td, +.table > tfoot > tr.danger > td, +.table > thead > tr.danger > th, +.table > tbody > tr.danger > th, +.table > tfoot > tr.danger > th { + background-color: #f2dede; +} +.table-hover > tbody > tr > td.danger:hover, +.table-hover > tbody > tr > th.danger:hover, +.table-hover > tbody > tr.danger:hover > td, +.table-hover > tbody > tr:hover > .danger, +.table-hover > tbody > tr.danger:hover > th { + background-color: #ebcccc; +} +.table-responsive { + overflow-x: auto; + min-height: 0.01%; +} +@media screen and (max-width: 767px) { + .table-responsive { + width: 100%; + margin-bottom: 15px; + overflow-y: hidden; + -ms-overflow-style: -ms-autohiding-scrollbar; + border: 1px solid #dddddd; + } + .table-responsive > .table { + margin-bottom: 0; + } + .table-responsive > .table > thead > tr > th, + .table-responsive > .table > tbody > tr > th, + .table-responsive > .table > tfoot > tr > th, + .table-responsive > .table > thead > tr > td, + .table-responsive > .table > tbody > tr > td, + .table-responsive > .table > tfoot > tr > td { + white-space: nowrap; + } + .table-responsive > .table-bordered { + border: 0; + } + .table-responsive > .table-bordered > thead > tr > th:first-child, + .table-responsive > .table-bordered > tbody > tr > th:first-child, + .table-responsive > .table-bordered > tfoot > tr > th:first-child, + .table-responsive > .table-bordered > thead > tr > td:first-child, + .table-responsive > .table-bordered > tbody > tr > td:first-child, + .table-responsive > .table-bordered > tfoot > tr > td:first-child { + border-left: 0; + } + .table-responsive > .table-bordered > thead > tr > th:last-child, + .table-responsive > .table-bordered > tbody > tr > th:last-child, + .table-responsive > .table-bordered > tfoot > tr > th:last-child, + .table-responsive > .table-bordered > thead > tr > td:last-child, + .table-responsive > .table-bordered > tbody > tr > td:last-child, + .table-responsive > .table-bordered > tfoot > tr > td:last-child { + border-right: 0; + } + .table-responsive > .table-bordered > tbody > tr:last-child > th, + .table-responsive > .table-bordered > tfoot > tr:last-child > th, + .table-responsive > .table-bordered > tbody > tr:last-child > td, + .table-responsive > .table-bordered > tfoot > tr:last-child > td { + border-bottom: 0; + } +} +.media { + margin-top: 15px; +} +.media:first-child { + margin-top: 0; +} +.media, +.media-body { + zoom: 1; + overflow: hidden; +} +.media-body { + width: 10000px; +} +.media-object { + display: block; +} +.media-object.img-thumbnail { + max-width: none; +} +.media-right, +.media > .pull-right { + padding-left: 10px; +} +.media-left, +.media > .pull-left { + padding-right: 10px; +} +.media-left, +.media-right, +.media-body { + display: table-cell; + vertical-align: top; +} +.media-middle { + vertical-align: middle; +} +.media-bottom { + vertical-align: bottom; +} +.media-heading { + margin-top: 0; + margin-bottom: 5px; +} +.media-list { + padding-left: 0; + list-style: none; +} +.clearfix:before, +.clearfix:after { + content: " "; + display: table; +} +.clearfix:after { + clear: both; +} +.center-block { + display: block; + margin-left: auto; + margin-right: auto; +} +.pull-right { + float: right !important; +} +.pull-left { + float: left !important; +} +.hide { + display: none !important; +} +.show { + display: block !important; +} +.invisible { + visibility: hidden; +} +.text-hide { + font: 0/0 a; + color: transparent; + text-shadow: none; + background-color: transparent; + border: 0; +} +.hidden { + display: none !important; +} +.affix { + position: fixed; +} diff --git a/src/static/bootstrap.min.css b/src/static/bootstrap.min.css new file mode 100755 index 0000000..8a6d85b --- /dev/null +++ b/src/static/bootstrap.min.css @@ -0,0 +1,14 @@ +/*! + * Bootstrap v3.3.5 (http://getbootstrap.com) + * Copyright 2011-2016 Twitter, Inc. + * Licensed under MIT (https://github.com/twbs/bootstrap/blob/master/LICENSE) + */ + +/*! + * Generated using the Bootstrap Customizer (http://getbootstrap.com/customize/?id=2b71cceb85b313486c11) + * Config saved to config.json and https://gist.github.com/2b71cceb85b313486c11 + *//*! + * Bootstrap v3.3.6 (http://getbootstrap.com) + * Copyright 2011-2015 Twitter, Inc. + * Licensed under MIT (https://github.com/twbs/bootstrap/blob/master/LICENSE) + *//*! normalize.css v3.0.3 | MIT License | github.com/necolas/normalize.css */html{font-family:sans-serif;-ms-text-size-adjust:100%;-webkit-text-size-adjust:100%}body{margin:0}article,aside,details,figcaption,figure,footer,header,hgroup,main,menu,nav,section,summary{display:block}audio,canvas,progress,video{display:inline-block;vertical-align:baseline}audio:not([controls]){display:none;height:0}[hidden],template{display:none}a{background-color:transparent}a:active,a:hover{outline:0}abbr[title]{border-bottom:1px dotted}b,strong{font-weight:bold}dfn{font-style:italic}h1{font-size:2em;margin:0.67em 0}mark{background:#ff0;color:#000}small{font-size:80%}sub,sup{font-size:75%;line-height:0;position:relative;vertical-align:baseline}sup{top:-0.5em}sub{bottom:-0.25em}img{border:0}svg:not(:root){overflow:hidden}figure{margin:1em 40px}hr{-webkit-box-sizing:content-box;-moz-box-sizing:content-box;box-sizing:content-box;height:0}pre{overflow:auto}code,kbd,pre,samp{font-family:monospace, monospace;font-size:1em}button,input,optgroup,select,textarea{color:inherit;font:inherit;margin:0}button{overflow:visible}button,select{text-transform:none}button,html input[type="button"],input[type="reset"],input[type="submit"]{-webkit-appearance:button;cursor:pointer}button[disabled],html input[disabled]{cursor:default}button::-moz-focus-inner,input::-moz-focus-inner{border:0;padding:0}input{line-height:normal}input[type="checkbox"],input[type="radio"]{-webkit-box-sizing:border-box;-moz-box-sizing:border-box;box-sizing:border-box;padding:0}input[type="number"]::-webkit-inner-spin-button,input[type="number"]::-webkit-outer-spin-button{height:auto}input[type="search"]{-webkit-appearance:textfield;-webkit-box-sizing:content-box;-moz-box-sizing:content-box;box-sizing:content-box}input[type="search"]::-webkit-search-cancel-button,input[type="search"]::-webkit-search-decoration{-webkit-appearance:none}fieldset{border:1px solid #c0c0c0;margin:0 2px;padding:0.35em 0.625em 0.75em}legend{border:0;padding:0}textarea{overflow:auto}optgroup{font-weight:bold}table{border-collapse:collapse;border-spacing:0}td,th{padding:0}*{-webkit-box-sizing:border-box;-moz-box-sizing:border-box;box-sizing:border-box}*:before,*:after{-webkit-box-sizing:border-box;-moz-box-sizing:border-box;box-sizing:border-box}html{font-size:10px;-webkit-tap-highlight-color:rgba(0,0,0,0)}body{font-family:"Helvetica Neue",Helvetica,Arial,sans-serif;font-size:14px;line-height:1.42857143;color:#333;background-color:#fff}input,button,select,textarea{font-family:inherit;font-size:inherit;line-height:inherit}a{color:#337ab7;text-decoration:none}a:hover,a:focus{color:#23527c;text-decoration:underline}a:focus{outline:thin dotted;outline:5px auto -webkit-focus-ring-color;outline-offset:-2px}figure{margin:0}img{vertical-align:middle}.img-responsive{display:block;max-width:100%;height:auto}.img-rounded{border-radius:2px}.img-thumbnail{padding:4px;line-height:1.42857143;background-color:#fff;border:1px solid #ddd;border-radius:2px;-webkit-transition:all .2s ease-in-out;-o-transition:all .2s ease-in-out;transition:all .2s ease-in-out;display:inline-block;max-width:100%;height:auto}.img-circle{border-radius:50%}hr{margin-top:20px;margin-bottom:20px;border:0;border-top:1px solid #eee}.sr-only{position:absolute;width:1px;height:1px;margin:-1px;padding:0;overflow:hidden;clip:rect(0, 0, 0, 0);border:0}.sr-only-focusable:active,.sr-only-focusable:focus{position:static;width:auto;height:auto;margin:0;overflow:visible;clip:auto}[role="button"]{cursor:pointer}table{background-color:transparent}caption{padding-top:8px;padding-bottom:8px;color:#777;text-align:left}th{text-align:left}.table{width:100%;max-width:100%;margin-bottom:20px}.table>thead>tr>th,.table>tbody>tr>th,.table>tfoot>tr>th,.table>thead>tr>td,.table>tbody>tr>td,.table>tfoot>tr>td{padding:8px;line-height:1.42857143;vertical-align:top;border-top:1px solid #ddd}.table>thead>tr>th{vertical-align:bottom;border-bottom:2px solid #ddd}.table>caption+thead>tr:first-child>th,.table>colgroup+thead>tr:first-child>th,.table>thead:first-child>tr:first-child>th,.table>caption+thead>tr:first-child>td,.table>colgroup+thead>tr:first-child>td,.table>thead:first-child>tr:first-child>td{border-top:0}.table>tbody+tbody{border-top:2px solid #ddd}.table .table{background-color:#fff}.table-condensed>thead>tr>th,.table-condensed>tbody>tr>th,.table-condensed>tfoot>tr>th,.table-condensed>thead>tr>td,.table-condensed>tbody>tr>td,.table-condensed>tfoot>tr>td{padding:5px}.table-bordered{border:1px solid #ddd}.table-bordered>thead>tr>th,.table-bordered>tbody>tr>th,.table-bordered>tfoot>tr>th,.table-bordered>thead>tr>td,.table-bordered>tbody>tr>td,.table-bordered>tfoot>tr>td{border:1px solid #ddd}.table-bordered>thead>tr>th,.table-bordered>thead>tr>td{border-bottom-width:2px}.table-striped>tbody>tr:nth-of-type(odd){background-color:#f9f9f9}.table-hover>tbody>tr:hover{background-color:#f5f5f5}table col[class*="col-"]{position:static;float:none;display:table-column}table td[class*="col-"],table th[class*="col-"]{position:static;float:none;display:table-cell}.table>thead>tr>td.active,.table>tbody>tr>td.active,.table>tfoot>tr>td.active,.table>thead>tr>th.active,.table>tbody>tr>th.active,.table>tfoot>tr>th.active,.table>thead>tr.active>td,.table>tbody>tr.active>td,.table>tfoot>tr.active>td,.table>thead>tr.active>th,.table>tbody>tr.active>th,.table>tfoot>tr.active>th{background-color:#f5f5f5}.table-hover>tbody>tr>td.active:hover,.table-hover>tbody>tr>th.active:hover,.table-hover>tbody>tr.active:hover>td,.table-hover>tbody>tr:hover>.active,.table-hover>tbody>tr.active:hover>th{background-color:#e8e8e8}.table>thead>tr>td.success,.table>tbody>tr>td.success,.table>tfoot>tr>td.success,.table>thead>tr>th.success,.table>tbody>tr>th.success,.table>tfoot>tr>th.success,.table>thead>tr.success>td,.table>tbody>tr.success>td,.table>tfoot>tr.success>td,.table>thead>tr.success>th,.table>tbody>tr.success>th,.table>tfoot>tr.success>th{background-color:#dff0d8}.table-hover>tbody>tr>td.success:hover,.table-hover>tbody>tr>th.success:hover,.table-hover>tbody>tr.success:hover>td,.table-hover>tbody>tr:hover>.success,.table-hover>tbody>tr.success:hover>th{background-color:#d0e9c6}.table>thead>tr>td.info,.table>tbody>tr>td.info,.table>tfoot>tr>td.info,.table>thead>tr>th.info,.table>tbody>tr>th.info,.table>tfoot>tr>th.info,.table>thead>tr.info>td,.table>tbody>tr.info>td,.table>tfoot>tr.info>td,.table>thead>tr.info>th,.table>tbody>tr.info>th,.table>tfoot>tr.info>th{background-color:#d9edf7}.table-hover>tbody>tr>td.info:hover,.table-hover>tbody>tr>th.info:hover,.table-hover>tbody>tr.info:hover>td,.table-hover>tbody>tr:hover>.info,.table-hover>tbody>tr.info:hover>th{background-color:#c4e3f3}.table>thead>tr>td.warning,.table>tbody>tr>td.warning,.table>tfoot>tr>td.warning,.table>thead>tr>th.warning,.table>tbody>tr>th.warning,.table>tfoot>tr>th.warning,.table>thead>tr.warning>td,.table>tbody>tr.warning>td,.table>tfoot>tr.warning>td,.table>thead>tr.warning>th,.table>tbody>tr.warning>th,.table>tfoot>tr.warning>th{background-color:#fcf8e3}.table-hover>tbody>tr>td.warning:hover,.table-hover>tbody>tr>th.warning:hover,.table-hover>tbody>tr.warning:hover>td,.table-hover>tbody>tr:hover>.warning,.table-hover>tbody>tr.warning:hover>th{background-color:#faf2cc}.table>thead>tr>td.danger,.table>tbody>tr>td.danger,.table>tfoot>tr>td.danger,.table>thead>tr>th.danger,.table>tbody>tr>th.danger,.table>tfoot>tr>th.danger,.table>thead>tr.danger>td,.table>tbody>tr.danger>td,.table>tfoot>tr.danger>td,.table>thead>tr.danger>th,.table>tbody>tr.danger>th,.table>tfoot>tr.danger>th{background-color:#f2dede}.table-hover>tbody>tr>td.danger:hover,.table-hover>tbody>tr>th.danger:hover,.table-hover>tbody>tr.danger:hover>td,.table-hover>tbody>tr:hover>.danger,.table-hover>tbody>tr.danger:hover>th{background-color:#ebcccc}.table-responsive{overflow-x:auto;min-height:0.01%}@media screen and (max-width:767px){.table-responsive{width:100%;margin-bottom:15px;overflow-y:hidden;-ms-overflow-style:-ms-autohiding-scrollbar;border:1px solid #ddd}.table-responsive>.table{margin-bottom:0}.table-responsive>.table>thead>tr>th,.table-responsive>.table>tbody>tr>th,.table-responsive>.table>tfoot>tr>th,.table-responsive>.table>thead>tr>td,.table-responsive>.table>tbody>tr>td,.table-responsive>.table>tfoot>tr>td{white-space:nowrap}.table-responsive>.table-bordered{border:0}.table-responsive>.table-bordered>thead>tr>th:first-child,.table-responsive>.table-bordered>tbody>tr>th:first-child,.table-responsive>.table-bordered>tfoot>tr>th:first-child,.table-responsive>.table-bordered>thead>tr>td:first-child,.table-responsive>.table-bordered>tbody>tr>td:first-child,.table-responsive>.table-bordered>tfoot>tr>td:first-child{border-left:0}.table-responsive>.table-bordered>thead>tr>th:last-child,.table-responsive>.table-bordered>tbody>tr>th:last-child,.table-responsive>.table-bordered>tfoot>tr>th:last-child,.table-responsive>.table-bordered>thead>tr>td:last-child,.table-responsive>.table-bordered>tbody>tr>td:last-child,.table-responsive>.table-bordered>tfoot>tr>td:last-child{border-right:0}.table-responsive>.table-bordered>tbody>tr:last-child>th,.table-responsive>.table-bordered>tfoot>tr:last-child>th,.table-responsive>.table-bordered>tbody>tr:last-child>td,.table-responsive>.table-bordered>tfoot>tr:last-child>td{border-bottom:0}}.media{margin-top:15px}.media:first-child{margin-top:0}.media,.media-body{zoom:1;overflow:hidden}.media-body{width:10000px}.media-object{display:block}.media-object.img-thumbnail{max-width:none}.media-right,.media>.pull-right{padding-left:10px}.media-left,.media>.pull-left{padding-right:10px}.media-left,.media-right,.media-body{display:table-cell;vertical-align:top}.media-middle{vertical-align:middle}.media-bottom{vertical-align:bottom}.media-heading{margin-top:0;margin-bottom:5px}.media-list{padding-left:0;list-style:none}.clearfix:before,.clearfix:after{content:" ";display:table}.clearfix:after{clear:both}.center-block{display:block;margin-left:auto;margin-right:auto}.pull-right{float:right !important}.pull-left{float:left !important}.hide{display:none !important}.show{display:block !important}.invisible{visibility:hidden}.text-hide{font:0/0 a;color:transparent;text-shadow:none;background-color:transparent;border:0}.hidden{display:none !important}.affix{position:fixed} \ No newline at end of file diff --git a/src/typings/angular-material/angular-material.d.ts b/src/typings/angular-material/angular-material.d.ts new file mode 100644 index 0000000..6ba6547 --- /dev/null +++ b/src/typings/angular-material/angular-material.d.ts @@ -0,0 +1,253 @@ +// Type definitions for Angular Material 1.0.0-rc5+ (angular.material module) +// Project: https://github.com/angular/material +// Definitions by: Matt Traynham +// Definitions: https://github.com/DefinitelyTyped/DefinitelyTyped + +/// +declare namespace angular.material { + + interface IBottomSheetOptions { + templateUrl?: string; + template?: string; + scope?: angular.IScope; // default: new child scope + preserveScope?: boolean; // default: false + controller?: string|Function; + locals?: {[index: string]: any}; + targetEvent?: MouseEvent; + resolve?: {[index: string]: angular.IPromise} + controllerAs?: string; + bindToController?: boolean; + parent?: string|Element|JQuery; // default: root node + disableParentScroll?: boolean; // default: true + } + + interface IBottomSheetService { + show(options: IBottomSheetOptions): angular.IPromise; + hide(response?: any): void; + cancel(response?: any): void; + } + + interface IPresetDialog { + title(title: string): T; + textContent(textContent: string): T; + htmlContent(htmlContent: string): T; + ok(ok: string): T; + theme(theme: string): T; + templateUrl(templateUrl?: string): T; + template(template?: string): T; + targetEvent(targetEvent?: MouseEvent): T; + scope(scope?: angular.IScope): T; // default: new child scope + preserveScope(preserveScope?: boolean): T; // default: false + disableParentScroll(disableParentScroll?: boolean): T; // default: true + hasBackdrop(hasBackdrop?: boolean): T; // default: true + clickOutsideToClose(clickOutsideToClose?: boolean): T; // default: false + escapeToClose(escapeToClose?: boolean): T; // default: true + focusOnOpen(focusOnOpen?: boolean): T; // default: true + controller(controller?: string|Function): T; + locals(locals?: {[index: string]: any}): T; + bindToController(bindToController?: boolean): T; // default: false + resolve(resolve?: {[index: string]: angular.IPromise}): T; + controllerAs(controllerAs?: string): T; + parent(parent?: string|Element|JQuery): T; // default: root node + onComplete(onComplete?: Function): T; + ariaLabel(ariaLabel: string): T; + } + + interface IAlertDialog extends IPresetDialog { + } + + interface IConfirmDialog extends IPresetDialog { + cancel(cancel: string): IConfirmDialog; + } + + interface IDialogOptions { + templateUrl?: string; + template?: string; + autoWrap?: boolean; // default: true + targetEvent?: MouseEvent; + openFrom?: any; + closeTo?: any; + scope?: angular.IScope; // default: new child scope + preserveScope?: boolean; // default: false + disableParentScroll?: boolean; // default: true + hasBackdrop?: boolean // default: true + clickOutsideToClose?: boolean; // default: false + escapeToClose?: boolean; // default: true + focusOnOpen?: boolean; // default: true + controller?: string|Function; + locals?: {[index: string]: any}; + bindToController?: boolean; // default: false + resolve?: {[index: string]: angular.IPromise} + controllerAs?: string; + parent?: string|Element|JQuery; // default: root node + onShowing?: Function; + onComplete?: Function; + onRemoving?: Function; + fullscreen?: boolean; + } + + interface IDialogService { + show(dialog: IDialogOptions|IAlertDialog|IConfirmDialog): angular.IPromise; + confirm(): IConfirmDialog; + alert(): IAlertDialog; + hide(response?: any): angular.IPromise; + cancel(response?: any): void; + } + + interface IIcon { + (id: string): angular.IPromise; // id is a unique ID or URL + } + + interface IIconProvider { + icon(id: string, url: string, viewBoxSize?: number): IIconProvider; // viewBoxSize default: 24 + iconSet(id: string, url: string, viewBoxSize?: number): IIconProvider; // viewBoxSize default: 24 + defaultIconSet(url: string, viewBoxSize?: number): IIconProvider; // viewBoxSize default: 24 + defaultViewBoxSize(viewBoxSize: number): IIconProvider; // default: 24 + defaultFontSet(name: string): IIconProvider; + } + + interface IMedia { + (media: string): boolean; + } + + interface ISidenavObject { + toggle(): angular.IPromise; + open(): angular.IPromise; + close(): angular.IPromise; + isOpen(): boolean; + isLockedOpen(): boolean; + } + + interface ISidenavService { + (component: string): ISidenavObject; + } + + interface IToastPreset { + textContent(content: string): T; + action(action: string): T; + highlightAction(highlightAction: boolean): T; + highlightClass(highlightClass: string): T; + capsule(capsule: boolean): T; + theme(theme: string): T; + hideDelay(delay: number): T; + position(position: string): T; + parent(parent?: string|Element|JQuery): T; // default: root node + } + + interface ISimpleToastPreset extends IToastPreset { + } + + interface IToastOptions { + templateUrl?: string; + template?: string; + autoWrap?:boolean; + scope?: angular.IScope; // default: new child scope + preserveScope?: boolean; // default: false + hideDelay?: number; // default (ms): 3000 + position?: string; // any combination of 'bottom'/'left'/'top'/'right'/'fit'; default: 'bottom left' + controller?: string|Function; + locals?: {[index: string]: any}; + bindToController?: boolean; // default: false + resolve?: {[index: string]: angular.IPromise} + controllerAs?: string; + parent?: string|Element|JQuery; // default: root node + } + + interface IToastService { + show(optionsOrPreset: IToastOptions|IToastPreset): angular.IPromise; + showSimple(content: string): angular.IPromise; + simple(): ISimpleToastPreset; + build(): IToastPreset; + updateContent(): void; + hide(response?: any): void; + cancel(response?: any): void; + } + + interface IPalette { + 0?: string; + 50?: string; + 100?: string; + 200?: string; + 300?: string; + 400?: string; + 500?: string; + 600?: string; + 700?: string; + 800?: string; + 900?: string; + A100?: string; + A200?: string; + A400?: string; + A700?: string; + contrastDefaultColor?: string; + contrastDarkColors?: string|string[]; + contrastLightColors?: string|string[]; + } + + interface IThemeHues { + default?: string; + 'hue-1'?: string; + 'hue-2'?: string; + 'hue-3'?: string; + } + + interface IThemePalette { + name: string; + hues: IThemeHues; + } + + interface IThemeColors { + accent: IThemePalette; + background: IThemePalette; + primary: IThemePalette; + warn: IThemePalette; + } + + interface IThemeGrayScalePalette { + 1: string; + 2: string; + 3: string; + 4: string; + name: string; + } + + interface ITheme { + name: string; + isDark: boolean; + colors: IThemeColors; + foregroundPalette: IThemeGrayScalePalette; + foregroundShadow: string; + accentPalette(name: string, hues?: IThemeHues): ITheme; + primaryPalette(name: string, hues?: IThemeHues): ITheme; + warnPalette(name: string, hues?: IThemeHues): ITheme; + backgroundPalette(name: string, hues?: IThemeHues): ITheme; + dark(isDark?: boolean): ITheme; + } + + interface IThemingProvider { + theme(name: string, inheritFrom?: string): ITheme; + definePalette(name: string, palette: IPalette): IThemingProvider; + extendPalette(name: string, palette: IPalette): IPalette; + setDefaultTheme(theme: string): void; + alwaysWatchTheme(alwaysWatch: boolean): void; + } + + interface IDateLocaleProvider { + months: string[]; + shortMonths: string[]; + days: string[]; + shortDays: string[]; + dates: string[]; + firstDayOfWeek: number; + parseDate(dateString: string): Date; + formatDate(date: Date): string; + monthHeaderFormatter(date: Date): string; + weekNumberFormatter(weekNumber: number): string; + msgCalendar: string; + msgOpenCalendar: string; + } + + interface IMenuService { + hide(response?: any, options?: any): angular.IPromise; + } +} diff --git a/src/typings/busboy/busboy.d.ts b/src/typings/busboy/busboy.d.ts new file mode 100644 index 0000000..c8c4360 --- /dev/null +++ b/src/typings/busboy/busboy.d.ts @@ -0,0 +1,21 @@ +/// + +declare module busboy { + + interface IBusboyStatic { + /** + * Initialize a new `Busboy`. + * + */ + new (options:any):IBusboy; + } + + interface IBusboy extends NodeJS.EventEmitter, NodeJS.WritableStream { + + } +} + +declare module "busboy" { + let b:busboy.IBusboyStatic; + export = b; +} diff --git a/src/typings/mkdirp/mkdirp.d.ts b/src/typings/mkdirp/mkdirp.d.ts new file mode 100644 index 0000000..c3fca38 --- /dev/null +++ b/src/typings/mkdirp/mkdirp.d.ts @@ -0,0 +1,15 @@ +// Type definitions for mkdirp 0.3.0 +// Project: http://github.com/substack/node-mkdirp +// Definitions by: Bart van der Schoor +// Definitions: https://github.com/borisyankov/DefinitelyTyped + +declare module 'mkdirp' { + + function mkdirp(dir: string, cb: (err: any, made: string) => void): void; + function mkdirp(dir: string, flags: any, cb: (err: any, made: string) => void): void; + + module mkdirp { + function sync(dir: string, flags?: any): string; + } + export = mkdirp; +} diff --git a/src/util/mockLogger.ts b/src/util/mockLogger.ts new file mode 100644 index 0000000..98d7fb8 --- /dev/null +++ b/src/util/mockLogger.ts @@ -0,0 +1,28 @@ +import { + spy, stub +} from 'sinon'; + +import { + ILogger +} from 'ts-rupert'; + +let arrString = (_: any[]) => _.join(' '); +export function getMockLogger(): ILogger { + let methods = [ 'silly', 'data', 'debug', 'verbose', 'http', 'info', 'log', + 'warn', 'error', 'silent', 'query', 'profile', ]; + let logger: any = { + middleware: stub().returns((q: any, s: any, n: Function): void => n()), + print: (): void => { + methods.forEach((method: string) => { + let level: string = console[method] ? method : 'log'; + console[level](`${method}: ${logger[method].args.map(arrString)}`); + }); + } + }; + + methods.forEach((method: string) => { + logger[method] = spy(); + }); + + return logger; +} diff --git a/tslint.json b/tslint.json index 7b566c6..cbec73d 100644 --- a/tslint.json +++ b/tslint.json @@ -16,7 +16,7 @@ "jsdoc-format": true, "label-position": true, "label-undefined": true, - "max-line-length": [true, 80], + "max-line-length": [true, 100], "member-ordering": false, "no-any": false, "no-arg": true, @@ -38,7 +38,6 @@ "no-require-imports": true, "no-string-literal": false, "no-switch-case-fall-through": false, - "no-trailing-comma": false, "no-trailing-whitespace": true, "no-unreachable": true, "no-unused-expression": true, @@ -78,4 +77,4 @@ "check-type" ] } -} \ No newline at end of file +} diff --git a/webpack.config.js b/webpack.config.js new file mode 100644 index 0000000..4f94e3b --- /dev/null +++ b/webpack.config.js @@ -0,0 +1,25 @@ +module.exports = { + output: { + filename: 'bundle.js' + }, + // Turn on sourcemaps + devtool: 'source-map', + resolve: { + extensions: ['', '.webpack.js', '.web.js', '.ts', '.js'] + }, + // Add minification + plugins: [ + //new webpack.optimize.UglifyJsPlugin() + ], + module: { + preLoaders: [ + { + test: /\.js$/, + loader: "source-map-loader" + } + ], + loaders: [ + { test: /\.ts$/, loader: 'ts' } + ] + } +}