kahuna/public/js/main.js (413 lines of code) (raw):

// TODO: Grunt: hash dependencies? or ETag? import '@babel/polyfill'; import angular from 'angular'; import 'angular-ui-router'; import {heal} from 'pandular'; import {cropperApi} from './services/api/media-cropper'; import {editsApi} from './services/api/edits-api'; import {loaderApi} from './services/api/loader'; import {mediaApi} from './services/api/media-api'; import {imageFade} from './directives/gr-image-fade-on-load'; import {crop} from './crop/index'; import {image} from './image/index'; import {upload} from './upload/index'; import {search} from './search/index'; import {edits} from './edits/index'; import {async} from './util/async'; import {digest} from './util/digest'; import {sentry} from './sentry/sentry'; import {userActions} from './common/user-actions'; import {httpErrors} from './errors/http'; import {globalErrors} from './errors/global'; import {notifications} from './notifications/notifications'; import {icon} from './components/gr-icon/gr-icon'; import {tooltip} from './components/gr-tooltip/gr-tooltip'; // TODO: move to an async config to remove deps on play var apiLink = document.querySelector('link[rel="media-api-uri"]'); var reauthLink = document.querySelector('link[rel="reauth-uri"]'); var sentryDsnLink = document.querySelector('link[rel="sentry-dsn"]'); var assetsRootLink = document.querySelector('link[rel="assets"]'); var config = { mediaApiUri: apiLink.getAttribute('href'), sentryDsn: sentryDsnLink && sentryDsnLink.getAttribute('href'), assetsRoot: assetsRootLink && assetsRootLink.getAttribute('href'), // Static config // TODO: use link in 4xx response to avoid having to hardcode in HTML page 'pandular.reAuthUri': reauthLink && reauthLink.getAttribute('href'), // Number of millis before pandular stops trying to reauth // This number is relatively high to cater for AUS 'pandular.reAuthTimeout': 7000, vndMimeTypes: new Map([ ['gridImageData', 'application/vnd.mediaservice.image+json'], ['gridImagesData', 'application/vnd.mediaservice.images+json'], ['gridCropData', 'application/vnd.mediaservice.crops+json'], // this doesn't appear to hold multiple things, so shouldn't be plural, however too many usages outside this project e.g. https://github.com/search?q=org%3Aguardian+application%2Fvnd.mediaservice.crops%2Bjson&type=code ['kahunaUri', 'application/vnd.mediaservice.kahuna.uri'], ['assetHandle', 'application/vnd.asset-handle+json'], // These two are internal hacks to help us identify when we're dragging internal assets // They should definitely not be relied on externally. ['isGridLink', 'application/vnd.mediaservice.kahuna.link'], ['isGridImage' , 'application/vnd.mediaservice.kahuna.image'] ]) }; var kahuna = angular.module('kahuna', [ 'ui.router', heal.name, cropperApi.name, editsApi.name, loaderApi.name, mediaApi.name, async.name, digest.name, sentry.name, crop.name, image.name, upload.name, search.name, edits.name, userActions.name, httpErrors.name, globalErrors.name, notifications.name, // directives used throughout imageFade.name, icon.name, tooltip.name ]); // Inject configuration values into the app angular.forEach(config, function(value, key) { kahuna.constant(key, value); }); kahuna.config(['$qProvider', function($qProvider) { $qProvider.errorOnUnhandledRejections(false); }]); kahuna.config(['$locationProvider', function($locationProvider) { // Use real URLs (with History API) instead of hashbangs $locationProvider.html5Mode({enabled: true, requireBase: false}); }]); kahuna.config(['$urlRouterProvider', function($urlRouterProvider) { $urlRouterProvider.otherwise('/search'); }]); // https://code.angularjs.org/1.5.5/docs/guide/production kahuna.config(['$compileProvider', function ($compileProvider) { $compileProvider.debugInfoEnabled(false); }]); kahuna.run(['$log', '$rootScope', 'mediaApi', function($log, $rootScope, mediaApi) { // TODO: don't mix these two concerns. This is done here to avoid // doing redundant API calls to the same endpoint. Could be // abstracted into a service that unifies parallel calls to the root. mediaApi.root.get() // Emit configuration .then(index => { if (index.data) { $rootScope.$emit('events:config-loaded', index.data.configuration); } }) // Ping API at init time to ensure we're logged in .catch(error => { // If missing a session, send for auth if (error && error.status === 401 || error.status === 419) { $log.info('No session, send for auth'); if (reauthLink) { const authLink = new URL(reauthLink.getAttribute('href')); const authParams = new URLSearchParams(authLink.search); authParams.set("redirectUri", window.location.href); authLink.search = authParams.toString(); // Full page redirect to the login URI window.location.href = authLink.toString(); } else { // Couldn't extract a login URI, die noisily throw new Error('Failed to redirect to auth, no login URI found'); } } }); }]); kahuna.run(['$rootScope', 'mediaApi', ($rootScope, mediaApi) => { mediaApi.getSession().then(session => { $rootScope.$emit('events:user-loaded', session.user); }); }]); // Intercept 401s and emit an event kahuna.config(['$httpProvider', function($httpProvider) { $httpProvider.interceptors.push('httpErrorInterceptor'); }]); kahuna.factory('httpErrorInterceptor', ['$q', '$rootScope', 'httpErrors', function($q, $rootScope, httpErrors) { return { responseError: function(response) { switch (response.status) { case 0: { /* Status is 0 when the headers of the response does not include the correct cors. This happens when the request fails and we don't explicitly return an error code. */ $rootScope.$emit('events:error:unknown'); break; } case httpErrors.unauthorised.errorCode: { $rootScope.$emit('events:error:unauthorised'); break; } case httpErrors.internalServerError.errorCode: { $rootScope.$emit('events:error:server'); break; } case httpErrors.internalServerError.serviceUnavailableError: { $rootScope.$emit('events:error:server'); break; } default: { break; } } return $q.reject(response); } }; }]); // global errors UI kahuna.run(['$rootScope', 'globalErrors', function($rootScope, globalErrors) { $rootScope.$on('events:error:unauthorised', () => globalErrors.trigger('unauthorised')); $rootScope.$on('pandular:re-establishment:fail', () => globalErrors.trigger('authFailed')); $rootScope.$on('events:error:server', () => globalErrors.trigger('server')); $rootScope.$on('events:error:unknown', () => globalErrors.trigger('unknown')); }]); // tracking errors kahuna.run(['$rootScope', 'httpErrors', function($rootScope, httpErrors) { $rootScope.$on('events:error:unauthorised', () => $rootScope.$emit( 'track:event', 'Authentication', null, 'Error', null, { 'Error code': httpErrors.unauthorised.errorCode } )); $rootScope.$on('pandular:re-establishment:fail', () => $rootScope.$emit( 'track:event', 'Authentication', null, 'Error', null, { 'Error code': httpErrors.authFailed.errorCode } )); }]); /** * Takes a resources and returns a promise of the entity data (uri, * data) as a plain JavaScript object. */ kahuna.factory('getEntity', ['$q', function($q) { function getEntity(resource) { return $q.all([resource.uri, resource.getData()]).then(([uri, data]) => { return {uri, data}; }); } return getEntity; }]); /** * Intercept global events and broadcast them on the parent window. * Used by the parent page when the app is embedded as an iframe. */ kahuna.run(['$rootScope', '$window', '$q', 'getEntity', function($rootScope, $window, $q, getEntity) { // Note: we target all domains because we don't know who may be embedding us. // Wrap message in `angular.toJson` to remove internal fields with a `$$` prefix. // See https://docs.angularjs.org/api/ng/function/angular.toJson const postMessage = message => $window.parent.postMessage(JSON.parse(angular.toJson(message)), '*'); const cropMessage = function(image, crop) {return { image, crop };}; // These interfaces are used when the app is embedded as an iframe $rootScope.$on('events:crop-selected', (_, params) => { getEntity(params.image).then(imageEntity => { // FIXME: `crop.data` is set as the cropper API doesn't return // resources and this is the structure composer expects var message = cropMessage(imageEntity, { data: params.crop }); postMessage(message); }); }); $rootScope.$on('events:crop-created', (_, params) => { var syncImage = getEntity(params.image); var syncCrop = getEntity(params.crop); $q.all([syncImage, syncCrop]).then(([imageEntity, cropEntity]) => { var message = cropMessage(imageEntity, cropEntity); postMessage(message); }); }); // used for batched crops $rootScope.$on('events:crops-created', (_, params) => { const specsPromise = params.map(({ image, crop }) => $q.all([ getEntity(image), getEntity(crop) ]).then(([image, crop]) => ({ image, crop }))); $q.all(specsPromise).then(images => postMessage({ images })); }); }]); kahuna.controller('SessionCtrl', ['$scope', '$state', '$stateParams', 'mediaApi', function($scope, $state, $stateParams, mediaApi) { mediaApi.getSession().then(session => { $scope.user = session.user; }); }]); kahuna.filter("embeddableUrl", ['$state', function($state) { return function(imageId, maybeCropId) { return $state.href('image', {imageId: imageId, crop: maybeCropId}, { absolute: true }); } }]); kahuna.filter('getExtremeAssets', function() { return function(image) { var orderedAssets = image.assets.sort((a, b) => { return (a.dimensions.width * a.dimensions.height) - (b.dimensions.width * b.dimensions.height); }); return { smallest: orderedAssets[0], largest: orderedAssets.slice(-1)[0] }; }; }); const getAssetHandleDragData = ($filter, image, maybeCrop) => ({ source: "grid", sourceType: maybeCrop ? "crop" : "original", thumbnail: $filter('assetFile')( maybeCrop ? $filter('getExtremeAssets')(maybeCrop).smallest : image.data.thumbnail ), embeddableUrl: $filter('embeddableUrl')(image.data.id, maybeCrop && maybeCrop.id) }); // Take an image and return a drag data map of mime-type -> value. // Note: the serialisation is expensive so make sure you only evaluate // this filter when necessary. kahuna.filter('asImageDragData', ['vndMimeTypes', '$filter', function(vndMimeTypes, $filter) { // Annoyingly cannot use Resource#getLink because it returns a // Promise and Angular filters are synchronous :-( function syncGetLinkUri(resource, rel) { const links = resource && resource.links || []; const link = links.filter(link => link.rel === rel)[0]; return link && link.href; } return function(image) { var uri = image && image.uri; if (uri) { const kahunaUri = syncGetLinkUri(image, 'ui:image'); // Resources don't serialise well yet.. const imageObj = { data: image.data, uri }; return { [vndMimeTypes.get('gridImageData')]: JSON.stringify(imageObj), [vndMimeTypes.get('kahunaUri')]: kahunaUri, [vndMimeTypes.get('assetHandle')]: JSON.stringify(getAssetHandleDragData($filter, image)), 'text/plain': uri, 'text/uri-list': uri }; } }; }]); // Take an image and return a drag data map of mime-type -> value. // Note: the serialisation is expensive so make sure you only evaluate // this filter when necessary. kahuna.filter('asCropDragData', ['vndMimeTypes', '$filter', function(vndMimeTypes, $filter) { return function(image, crop) { return { [vndMimeTypes.get('gridCropData')]: JSON.stringify(crop), [vndMimeTypes.get('assetHandle')]: JSON.stringify(getAssetHandleDragData($filter, image, crop)), }; }; }]); // Take an image and return a drag data map of mime-type -> value. // Note: the serialisation is expensive so make sure you only evaluate // this filter when necessary. kahuna.filter('asImageAndCropDragData', ['$filter', function($filter) { var extend = angular.extend; return function(image, crop) { return extend( $filter('asImageDragData')(image), $filter('asCropDragData')(image, crop)); }; }]); kahuna.filter('asFileSize', function() { return bytes => { if (!bytes) { return '0 Byte'; } const k = 1000; const sizes = ['Bytes', 'KB', 'MB', 'GB']; const i = Math.floor(Math.log(bytes) / Math.log(k)); return parseFloat((bytes / Math.pow(k, i)).toFixed(2)) + ' ' + sizes[i]; }; }); kahuna.filter('toLocaleString', function() { return number => number.toLocaleString(); }); kahuna.filter('assetFile', function() { // Prefer SSL asset, but default to HTTP URI if missing // (e.g. non-PROD env) return asset => asset.secureUrl || asset.file; }); kahuna.filter('stripEmailDomain', function() { return str => str.replace(/@.+/, ''); }); kahuna.filter('getInitials', function() { return str => str && str.replace(/@.+/, '') .split('.') .map(e => e.charAt(0).toUpperCase()) .join(''); }); kahuna.filter('spaceWords', function() { return str => str.charAt(0).toUpperCase() + str.replace( /([A-Z]+)/g, " $1").slice(1) }); kahuna.directive('uiDragData', function() { return { restrict: 'A', link: function(scope, element, attrs) { element.on('dragstart', function(e) { // Evaluate the attribute value to retrieve a JS object // (done lazily to avoid unnecessary serialisation work) var dataMap = scope.$eval(attrs.uiDragData); Object.keys(dataMap).forEach(function(mimeType) { e.dataTransfer.setData(mimeType, dataMap[mimeType]); }); }); } }; }); kahuna.directive('uiDragImage', function() { return { restrict: 'A', link: function(scope, element, attrs) { element.on('dragstart', function(e) { // Evaluate the attribute value to retrieve a JS object // (done lazily to avoid unnecessary serialisation work) const src = scope.$eval(attrs.uiDragImage); var img = document.createElement('img'); img.src = src; e.dataTransfer.setDragImage(img, -10, -10); }); } }; }); kahuna.directive('uiTitle', ['$rootScope', function($rootScope) { return { restrict: 'A', link: function(scope, element, attrs) { $rootScope.$on('$stateChangeStart', function(event, toState, toParams) { var titleFunc = toState.data && toState.data.title; var title = (titleFunc ? titleFunc(toParams) : toState.name) + ' | ' + attrs.uiTitleSuffix; attrs.uiTitle = title; element.text(title); }); $rootScope.$on('events:new-images', function(event, data) { var title = (data.count ? `(${data.count} new) ${attrs.uiTitle}` : attrs.uiTitle); element.text(title); }); } }; }]); /** * using uiLocalStoreVal to set a key to the same value will remove that key from localStorage * this allows toggling values on/off * we force localstore attr to be and object * TODO: Support deep objects i.e * { "search": * { "lastSeen": * { "dogs": "1849-09-26T00:00:00Z" }}}` * * TODO: Think about what to do if a value * exists for a key that isn't JSON * * TODO: Make a service for data retrieval? */ kahuna.directive('uiLocalstore', ['$window', function($window) { return { restrict: 'A', scope: { key: '@uiLocalstore', value: '&uiLocalstoreVal' }, link: function(scope, element) { element.on('click', function() { var k = scope.key; var currentMap = JSON.parse($window.localStorage.getItem(k) || '{}'); var mapUpdate = scope.value(); // Update map by removing keys set to the same value, or merging if not Object.keys(mapUpdate).forEach(key => { if (currentMap[key] === mapUpdate[key]) { delete currentMap[key]; } else { currentMap[key] = mapUpdate[key]; } }); $window.localStorage.setItem(k, JSON.stringify(currentMap)); scope.$apply(); }); } }; }]); // These two are internal hacks to help us identify when we're dragging internal assets // They should definitely not be relied on externally. kahuna.directive('img', ['vndMimeTypes', function(vndMimeTypes) { return { restrict: 'E', link: function(scope, element) { element.on('dragstart', e => { e.dataTransfer.setData(vndMimeTypes.get('isGridLink'), 'true'); }); } }; }]); kahuna.directive('a', ['vndMimeTypes', function(vndMimeTypes) { return { restrict: 'E', link: function(scope, element) { element.on('dragstart', e => { e.dataTransfer.setData(vndMimeTypes.get('isGridImage'), 'true'); }); } }; }]); kahuna.directive('uiWindowResized', ['$window', function ($window) { return { restrict: 'A', link: function (scope, element, attrs) { angular.element($window).on('resize', function() { scope.$eval(attrs.uiWindowResized); }); } }; }]); angular.bootstrap(document, ['kahuna']);