From 7d76c0abc01c71204e87c3371f1f6252de48be0f Mon Sep 17 00:00:00 2001 From: Jonathan Porta Date: Tue, 21 Jun 2016 21:56:15 -0600 Subject: [PATCH] Reset to upstream (#1) * Do not merge the same story. * Actually handle replacing, not merging, records. * Try to replace tapes, when possible. * Search UI will handle dates for queries. * Pasing the correct parameters to the service for search. * Search client service, not exporting unnecessary things. * Chore: removing extra whitespace. * Fix database not being read at startup * Some layout improvements, breaking start and end dates into explicit fields that can be manually set by the user. * Add some basic logging on error * Some layout improvements, breaking start and end dates into explicit fields that can be manually set by the user. * Expand first/last dates. * Current record will collapse on background click. * Adding a new record will set it as current for editing. * Fixing collapse when middle record is selected. * Add icon indicators on right side. * Tests for archive.collapse * Remove extension importing mock logger. * Gulpfile makes builds *much* faster. * Gulp for build; run mocha and karma directly. * Save on close, and show toast when done. * Made webpack less verbose in it's output, took out webpack stream. * Updated mocks. * Add multimedia to detail view layout - Adds videos, images, and notes to layout. - Adds videos and images to the data model. * Don't let stubs span columns * Fix some nits. * Add file upload UI and API route * Reused TabletTop uploader. * Uploads save in data/ directory, and load in sidebar. * Updates after call w/ Stan * Ensure persisted database when no db present. * Only show image loader with valid label. * Miscellaneous. * Cleaned up date entry. * Reload data when loading records. * Added missing static mock. * When renaming a record, move the images as well. * Sort by label when find()ing all records. * Test regression and correctly filter duplicate stories. * Add static Bootstrap, and augment some elements with bootstrap styles. * Bump karma version, see https://github.com/karma-runner/karma/issues/1782 * Records now grouped by family * Futzed with styles for archive record list. * Use same markup in pre and post. * no-sticky the subheaders. * Add toggle between view and edit for record top content. * Responsive record view * Initial markup for video association. * This tweaks a bug with firefox's rendering of md-card. Basically, there is a css prop that sets max height to 100%. Firefox interprets this as the height of the visible viewport rather than 'what is needed', or whatever chrome actually is. By setting this value to inherit, the height is automatically inherited. This works in chrome and firefox. * Added videos api, and /api/videos/incoming route. * Tweak class implementation. * Patch the other card. * worked in video player to responsive design and moved buttons * put back real video code * Changes to labels. Dates crunch and family as a super. * Hey tests actually run now. * So, apparently isn't a tag. Changed to and added md-subheader class to family on record view. * displays error when server not running * Tigthen list view and fix icons * Changed icon for notes * Fix tests that had bad form, and add Associate route for adding vidoes. * Displaying incoming videos. * Associate incoming videos. * When creating a new record, start in edit state. * Added documentation on how to set up ./incoming * Do not try to focus a record when it does not have an input. * Added image delete button. * Search bar works and even works right! * Allow force set of a Record id * Correct test to use ID convention forced in Record * Set baseId when id is force set; Update test to check for force set id * Remove tslint option * Fix a bunch of breaking things. * Add a form-level action for searching. * Unify data/ and incoming/ * Update docs. * Try ARCHIVE_DATA_ROOT first for image paths. * And do the same for the list of incoming vidoes. * finish refactoring basePath and dataPath. * Moving in a hurry... * Added debugging for video display. * Use more correct images url for video. * Remove console.log * Changes to adding video * Don't attempt to save when a current request is in flight. * Make Notes part of the Editable toggle. * Add videos safely, and show them immediately. * This makes the style bar look not as totally horendous. * Create missing record folders when associating videos. * When users attempt to move a record into an existing label, don't damage data and tell them why it failed. * Ensure newly added & saved records have correct baseId. * URL encodes search and selected record. BIG BUG: Dates are all mucked up. * Fix date handling. * Handle all path permutations. * Records have a more permissive check for when two stories are equivalent. * Clarified and simplified story adding and editing. * Save record when finished editing stories. * Do not dupe when saving renamed stories. * Only allow single story to be edited at a time. * Clearing a story's slug will change save to delete. * Sets focus on slug when editing story. * Merge with a real record. * Search within stories. * Wait for previous record to close before opening new record. * Delete button on images confirms, then deletes. * Sort by family, then first: date. * Punt on performance, and just hide the FAB when a tape is selected. * Use mdButton for virtual click on Add Image. * Delete dialog and button. * Update media links when renaming records. * Always anchorscroll when selecting records. * Apparently we were not merging dates, because we thought they should be computed at one point. * Consistently enters edit mode when creating a new record. * Assume that returned media data is canonical. * Update location when saving and when collapsing. * Return record data after renaming files, and update in memory record. * Turns out, failing to remove an image you want removed is not an exceptional case. * Delete records, as per discussion. * Actually remove the tape after deleting it. * Remove the empty directory on delete. * Make viewing state for notes * add padding to video player * put record medium selector in line * Disable [Delete] when the form is not pristine. * Do not fail save when data/id dir is not present. * addressed Davids notes * a space * another space --- .editorconfig | 5 + .gitignore | 3 + README.md | 11 +- gulpfile.js | 116 ++++ karma.config.js | 76 +-- package.json | 71 +- src/api/app.ts | 14 +- src/api/record/record.spec.ts | 328 ++++++++- src/api/record/record.ts | 258 ++++++- src/api/videos/video.mock.ts | 1 + src/api/videos/video.spec.ts | 55 ++ src/api/videos/video.ts | 36 + src/client/index.jade | 24 +- src/client/index.ts | 10 +- src/client/mtna/.DS_Store | Bin 6148 -> 0 bytes src/client/mtna/archive-component.spec.ts | 154 +++-- src/client/mtna/archive-component.ts | 193 ++++-- src/client/mtna/archive-style.scss | 9 + src/client/mtna/archive-template.jade | 49 +- .../mtna/elem-click/elem-click-directive.ts | 27 + src/client/mtna/location/location-service.ts | 107 +++ .../record/associate/associate-component.ts | 103 +++ .../record/associate/associate-service.ts | 21 + .../mtna/record/associate/incoming-service.ts | 18 + .../mtna/record/associate/incoming-style.scss | 7 + .../record/associate/incoming-template.jade | 12 + src/client/mtna/record/record-component.ts | 172 ++++- src/client/mtna/record/record-resource.ts | 5 +- src/client/mtna/record/record-style.scss | 76 ++- src/client/mtna/record/record-template.jade | 198 ++++-- .../mtna/searchbar/searchbar-component.ts | 23 +- .../mtna/searchbar/searchbar-service.spec.ts | 38 +- .../mtna/searchbar/searchbar-service.ts | 46 +- .../mtna/searchbar/searchbar-style.scss | 19 + .../mtna/searchbar/searchbar-template.jade | 29 +- src/client/mtna/toast/toast-service.ts | 37 + src/client/util/date.ts | 25 + .../util/fileinput/fileinput-directive.ts | 44 ++ .../util/fileinput/fileinput-service.ts | 66 ++ .../util/setfocus/setfocus-directive.ts | 29 + src/client/util/table.scss | 9 +- src/scripts/glob.js | 13 - src/scripts/glob.js.map | 1 - src/scripts/glob.ts | 12 - src/scripts/templateCache.js | 32 - src/scripts/templateCache.js.map | 1 - src/scripts/templateCache.ts | 48 -- src/scripts/tsconfig.json | 27 - src/shared/record/record.mock.ts | 33 +- src/shared/record/record.spec.ts | 104 ++- src/shared/record/record.ts | 330 +++++++-- src/static/bootstrap-theme.css | 596 +++++++++++++++++ src/static/bootstrap-theme.min.css | 14 + src/static/bootstrap.css | 630 ++++++++++++++++++ src/static/bootstrap.min.css | 14 + .../angular-material/angular-material.d.ts | 253 +++++++ src/typings/busboy/busboy.d.ts | 21 + src/typings/mkdirp/mkdirp.d.ts | 15 + src/util/mockLogger.ts | 28 + tslint.json | 5 +- webpack.config.js | 25 + 61 files changed, 4129 insertions(+), 597 deletions(-) create mode 100644 .editorconfig create mode 100644 gulpfile.js create mode 100644 src/api/videos/video.mock.ts create mode 100644 src/api/videos/video.spec.ts create mode 100644 src/api/videos/video.ts delete mode 100644 src/client/mtna/.DS_Store create mode 100644 src/client/mtna/elem-click/elem-click-directive.ts create mode 100644 src/client/mtna/location/location-service.ts create mode 100644 src/client/mtna/record/associate/associate-component.ts create mode 100644 src/client/mtna/record/associate/associate-service.ts create mode 100644 src/client/mtna/record/associate/incoming-service.ts create mode 100644 src/client/mtna/record/associate/incoming-style.scss create mode 100644 src/client/mtna/record/associate/incoming-template.jade create mode 100644 src/client/mtna/searchbar/searchbar-style.scss create mode 100644 src/client/mtna/toast/toast-service.ts create mode 100644 src/client/util/date.ts create mode 100644 src/client/util/fileinput/fileinput-directive.ts create mode 100644 src/client/util/fileinput/fileinput-service.ts create mode 100644 src/client/util/setfocus/setfocus-directive.ts delete mode 100644 src/scripts/glob.js delete mode 100644 src/scripts/glob.js.map delete mode 100644 src/scripts/glob.ts delete mode 100644 src/scripts/templateCache.js delete mode 100644 src/scripts/templateCache.js.map delete mode 100644 src/scripts/templateCache.ts delete mode 100644 src/scripts/tsconfig.json create mode 100755 src/static/bootstrap-theme.css create mode 100755 src/static/bootstrap-theme.min.css create mode 100755 src/static/bootstrap.css create mode 100755 src/static/bootstrap.min.css create mode 100644 src/typings/angular-material/angular-material.d.ts create mode 100644 src/typings/busboy/busboy.d.ts create mode 100644 src/typings/mkdirp/mkdirp.d.ts create mode 100644 src/util/mockLogger.ts create mode 100644 webpack.config.js 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 5008ddfcf53c02e82d7eee2e57c38e5672ef89f6..0000000000000000000000000000000000000000 GIT binary patch literal 0 HcmV?d00001 literal 6148 zcmeH~Jr2S!425mzP>H1@V-^m;4Wg<&0T*E43hX&L&p$$qDprKhvt+--jT7}7np#A3 zem<@ulZcFPQ@L2!n>{z**++&mCkOWA81W14cNZlEfg7;MkzE(HCqgga^y>{tEnwC%0;vJ&^%eQ zLs35+`xjp>T0{ + 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' } + ] + } +}