diff --git a/Gruntfile.js b/Gruntfile.js index 87ab9a8be..cd3c452c8 100644 --- a/Gruntfile.js +++ b/Gruntfile.js @@ -70,7 +70,7 @@ module.exports = function(grunt) { }, app: { src: [ - 'app/assets/javascripts/app/services/helpers/*.js', + 'app/assets/javascripts/app/services/encryption/*.js', 'app/assets/javascripts/app/*.js', 'app/assets/javascripts/app/frontend/*.js', 'app/assets/javascripts/app/frontend/controllers/*.js', @@ -142,7 +142,6 @@ module.exports = function(grunt) { dest: 'vendor/assets/javascripts/compiled.min.js' } }, - }); grunt.loadNpmTasks('grunt-newer'); diff --git a/README.md b/README.md index 6712c93d3..63d5c7d0a 100644 --- a/README.md +++ b/README.md @@ -24,7 +24,7 @@ For more information on Standard Notes, see https://standardnotes.org. Open your browser to http://localhost:3000 -In the bottom left, click on "Sign in or Register" or "Account" (if signed in), and make sure you're using the correct server. You can use a production server here as usual (like https://n3.standardnotes.org). +In the bottom left, click on "Sign in or Register" or "Account" (if signed in), and make sure you're using the correct server. You can use a production server here as usual (like https://sync.standardnotes.org). ## License diff --git a/app/assets/javascripts/app/app.frontend.js b/app/assets/javascripts/app/app.frontend.js index 4511bf882..80a607ee3 100644 --- a/app/assets/javascripts/app/app.frontend.js +++ b/app/assets/javascripts/app/app.frontend.js @@ -14,3 +14,25 @@ if(!IEOrEdge && (window.crypto && window.crypto.subtle)) { } angular.module('app.frontend', []) + +function getParameterByName(name, url) { + name = name.replace(/[\[\]]/g, "\\$&"); + var regex = new RegExp("[?&]" + name + "(=([^]*)|&|#|$)"), + results = regex.exec(url); + if (!results) return null; + if (!results[2]) return ''; + return decodeURIComponent(results[2].replace(/\+/g, " ")); +} + +function parametersFromURL(url) { + url = url.split("?").slice(-1)[0]; + var obj = {}; + url.replace(/([^=&]+)=([^&]*)/g, function(m, key, value) { + obj[decodeURIComponent(key)] = decodeURIComponent(value); + }); + return obj; +} + +function isDesktopApplication() { + return window && window.process && window.process.type && window.process.versions["electron"]; +} diff --git a/app/assets/javascripts/app/frontend/controllers/_base.js b/app/assets/javascripts/app/frontend/controllers/_base.js deleted file mode 100644 index 920ae9358..000000000 --- a/app/assets/javascripts/app/frontend/controllers/_base.js +++ /dev/null @@ -1,34 +0,0 @@ -class BaseCtrl { - constructor($rootScope, $scope, syncManager, dbManager, analyticsManager, componentManager) { - dbManager.openDatabase(null, function(){ - // new database, delete syncToken so that items can be refetched entirely from server - syncManager.clearSyncToken(); - syncManager.sync(); - }) - - $scope.onUpdateAvailable = function(version) { - $rootScope.$broadcast('new-update-available', version); - } - } -} - - -function getParameterByName(name, url) { - name = name.replace(/[\[\]]/g, "\\$&"); - var regex = new RegExp("[?&]" + name + "(=([^]*)|&|#|$)"), - results = regex.exec(url); - if (!results) return null; - if (!results[2]) return ''; - return decodeURIComponent(results[2].replace(/\+/g, " ")); -} - -function parametersFromURL(url) { - url = url.split("?").slice(-1)[0]; - var obj = {}; - url.replace(/([^=&]+)=([^&]*)/g, function(m, key, value) { - obj[decodeURIComponent(key)] = decodeURIComponent(value); - }); - return obj; -} - -angular.module('app.frontend').controller('BaseCtrl', BaseCtrl); diff --git a/app/assets/javascripts/app/frontend/controllers/editor.js b/app/assets/javascripts/app/frontend/controllers/editor.js index 0b2d02b9a..d822e089d 100644 --- a/app/assets/javascripts/app/frontend/controllers/editor.js +++ b/app/assets/javascripts/app/frontend/controllers/editor.js @@ -37,7 +37,7 @@ angular.module('app.frontend') } } }) - .controller('EditorCtrl', function ($sce, $timeout, authManager, $rootScope, extensionManager, syncManager, modelManager, editorManager, themeManager, componentManager) { + .controller('EditorCtrl', function ($sce, $timeout, authManager, $rootScope, extensionManager, syncManager, modelManager, editorManager, themeManager, componentManager, storageManager) { this.componentManager = componentManager; this.componentStack = []; @@ -50,6 +50,10 @@ angular.module('app.frontend') this.syncTakingTooLong = true; }.bind(this)); + $rootScope.$on("sync:completed", function(){ + this.syncTakingTooLong = false; + }.bind(this)); + $rootScope.$on("tag-changed", function(){ this.loadTagsString(); }.bind(this)); @@ -291,7 +295,7 @@ angular.module('app.frontend') if(success) { if(statusTimeout) $timeout.cancel(statusTimeout); statusTimeout = $timeout(function(){ - var status = "All changes saved" + var status = "All changes saved"; if(authManager.offline()) { status += " (offline)"; } @@ -368,12 +372,34 @@ angular.module('app.frontend') } } + this.togglePin = function() { + this.note.setAppDataItem("pinned", !this.note.pinned); + this.note.setDirty(true); + this.changesMade(); + } + + this.toggleArchiveNote = function() { + this.note.setAppDataItem("archived", !this.note.archived); + this.note.setDirty(true); + this.changesMade(); + $rootScope.$broadcast("noteArchived"); + } + this.clickedEditNote = function() { this.editorMode = 'edit'; this.focusEditor(100); } - /* Tags */ + + + + + + + + /* + Tags + */ this.loadTagsString = function() { var string = ""; @@ -419,16 +445,23 @@ angular.module('app.frontend') this.updateTags()(this.note, tags); } - /* Components */ + + + + + + /* + Components + */ let alertKey = "displayed-component-disable-alert"; this.disableComponent = function(component) { componentManager.disableComponentForItem(component, this.note); componentManager.setEventFlowForComponent(component, false); - if(!localStorage.getItem(alertKey)) { + if(!storageManager.getItem(alertKey)) { alert("This component will be disabled for this note. You can re-enable this component in the 'Menu' of the editor pane."); - localStorage.setItem(alertKey, true); + storageManager.setItem(alertKey, true); } } @@ -455,6 +488,19 @@ angular.module('app.frontend') } } + + + + + + + + + + /* + Editor Customization + */ + this.onSystemEditorLoad = function() { if(this.loadedTabListener) { return; diff --git a/app/assets/javascripts/app/frontend/controllers/footer.js b/app/assets/javascripts/app/frontend/controllers/footer.js index 1fc3fad8a..e59321187 100644 --- a/app/assets/javascripts/app/frontend/controllers/footer.js +++ b/app/assets/javascripts/app/frontend/controllers/footer.js @@ -22,7 +22,7 @@ angular.module('app.frontend') } } }) - .controller('FooterCtrl', function ($rootScope, authManager, modelManager, $timeout, dbManager, syncManager) { + .controller('FooterCtrl', function ($rootScope, authManager, modelManager, $timeout, dbManager, syncManager, storageManager, passcodeManager) { this.user = authManager.user; @@ -31,7 +31,7 @@ angular.module('app.frontend') } this.updateOfflineStatus(); - if(this.offline) { + if(this.offline && !passcodeManager.hasPasscode()) { this.showAccountMenu = true; } @@ -40,6 +40,10 @@ angular.module('app.frontend') } this.findErrors(); + this.onAuthSuccess = function() { + this.showAccountMenu = false; + }.bind(this) + this.accountMenuPressed = function() { this.serverData = {}; this.showAccountMenu = !this.showAccountMenu; @@ -61,6 +65,14 @@ angular.module('app.frontend') this.showAccountMenu = false; } + this.hasPasscode = function() { + return passcodeManager.hasPasscode(); + } + + this.lockApp = function() { + $rootScope.lockApplication(); + } + this.refreshData = function() { this.isRefreshing = true; syncManager.sync(function(response){ diff --git a/app/assets/javascripts/app/frontend/controllers/home.js b/app/assets/javascripts/app/frontend/controllers/home.js index 7ea67b38e..5692bbc2f 100644 --- a/app/assets/javascripts/app/frontend/controllers/home.js +++ b/app/assets/javascripts/app/frontend/controllers/home.js @@ -1,56 +1,86 @@ angular.module('app.frontend') -.controller('HomeCtrl', function ($scope, $location, $rootScope, $timeout, modelManager, syncManager, authManager, themeManager) { +.controller('HomeCtrl', function ($scope, $location, $rootScope, $timeout, modelManager, + dbManager, syncManager, authManager, themeManager, passcodeManager, storageManager) { - function urlParam(key) { - return $location.search()[key]; + storageManager.initialize(passcodeManager.hasPasscode(), authManager.isEphemeralSession()); + + $scope.onUpdateAvailable = function(version) { + $rootScope.$broadcast('new-update-available', version); } - function autoSignInFromParams() { - var server = urlParam("server"); - var email = urlParam("email"); - var pw = urlParam("pw"); - - if(!authManager.offline()) { - // check if current account - if(syncManager.serverURL === server && authManager.user.email === email) { - // already signed in, return - return; - } else { - // sign out - syncManager.destroyLocalData(function(){ - window.location.reload(); - }) - } - } else { - authManager.login(server, email, pw, function(response){ - window.location.reload(); - }) - } + $rootScope.lockApplication = function() { + // Render first to show lock screen immediately, then refresh + $scope.needsUnlock = true; + // Reloading wipes current objects from memory + setTimeout(function () { + window.location.reload(); + }, 100); } - if(urlParam("server")) { - autoSignInFromParams(); + function load() { + // pass keys to storageManager to decrypt storage + storageManager.setKeys(passcodeManager.keys()); + + openDatabase(); + // Retrieve local data and begin sycing timer + initiateSync(); + // Configure "All" psuedo-tag + loadAllTag(); + // Configure "Archived" psuedo-tag + loadArchivedTag(); } - syncManager.loadLocalItems(function(items) { - $scope.allTag.didLoad = true; - themeManager.activateInitialTheme(); - $scope.$apply(); + if(passcodeManager.isLocked()) { + $scope.needsUnlock = true; + } else { + load(); + } + + $scope.onSuccessfulUnlock = function() { + $timeout(() => { + $scope.needsUnlock = false; + load(); + }) + } + + function openDatabase() { + dbManager.setLocked(false); + dbManager.openDatabase(null, function() { + // new database, delete syncToken so that items can be refetched entirely from server + syncManager.clearSyncToken(); + syncManager.sync(); + }) + } + + function initiateSync() { + authManager.loadInitialData(); + syncManager.loadLocalItems(function(items) { + $scope.allTag.didLoad = true; + themeManager.activateInitialTheme(); + $scope.$apply(); - syncManager.sync(null); - // refresh every 30s - setInterval(function () { syncManager.sync(null); - }, 30000); - }); + // refresh every 30s + setInterval(function () { + syncManager.sync(null); + }, 30000); + }); + } - var allTag = new Tag({all: true}); - allTag.needsLoad = true; - $scope.allTag = allTag; - $scope.allTag.title = "All"; - $scope.tags = modelManager.tags; - $scope.allTag.notes = modelManager.notes; + function loadAllTag() { + var allTag = new Tag({all: true, title: "All"}); + allTag.needsLoad = true; + $scope.allTag = allTag; + $scope.tags = modelManager.tags; + $scope.allTag.notes = modelManager.notes; + } + + function loadArchivedTag() { + var archiveTag = new Tag({archiveTag: true, title: "Archived"}); + $scope.archiveTag = archiveTag; + $scope.archiveTag.notes = modelManager.notes; + } /* Editor Callbacks @@ -116,6 +146,7 @@ angular.module('app.frontend') tag.setDirty(true); syncManager.sync(callback); $rootScope.$broadcast("tag-changed"); + modelManager.resortTag(tag); } /* @@ -140,7 +171,7 @@ angular.module('app.frontend') $scope.notesAddNew = function(note) { modelManager.addItem(note); - if(!$scope.selectedTag.all) { + if(!$scope.selectedTag.all && !$scope.selectedTag.archiveTag) { modelManager.createRelationshipBetweenItems($scope.selectedTag, note); } } @@ -210,4 +241,39 @@ angular.module('app.frontend') } }); } + + + + // Handle Auto Sign In From URL + + function urlParam(key) { + return $location.search()[key]; + } + + function autoSignInFromParams() { + var server = urlParam("server"); + var email = urlParam("email"); + var pw = urlParam("pw"); + + if(!authManager.offline()) { + // check if current account + if(syncManager.serverURL === server && authManager.user.email === email) { + // already signed in, return + return; + } else { + // sign out + syncManager.destroyLocalData(function(){ + window.location.reload(); + }) + } + } else { + authManager.login(server, email, pw, false, function(response){ + window.location.reload(); + }) + } + } + + if(urlParam("server")) { + autoSignInFromParams(); + } }); diff --git a/app/assets/javascripts/app/frontend/controllers/lockScreen.js b/app/assets/javascripts/app/frontend/controllers/lockScreen.js new file mode 100644 index 000000000..409344fb6 --- /dev/null +++ b/app/assets/javascripts/app/frontend/controllers/lockScreen.js @@ -0,0 +1,30 @@ +class LockScreen { + + constructor() { + this.restrict = "E"; + this.templateUrl = "frontend/lock-screen.html"; + this.scope = { + onSuccess: "&", + }; + } + + controller($scope, passcodeManager) { + 'ngInject'; + + $scope.formData = {}; + + $scope.submitPasscodeForm = function() { + passcodeManager.unlock($scope.formData.passcode, (success) => { + if(!success) { + alert("Invalid passcode. Please try again."); + return; + } + + $scope.onSuccess()(); + }) + } + } + +} + +angular.module('app.frontend').directive('lockScreen', () => new LockScreen); diff --git a/app/assets/javascripts/app/frontend/controllers/notes.js b/app/assets/javascripts/app/frontend/controllers/notes.js index 75cdd3a73..a7225a764 100644 --- a/app/assets/javascripts/app/frontend/controllers/notes.js +++ b/app/assets/javascripts/app/frontend/controllers/notes.js @@ -31,9 +31,9 @@ angular.module('app.frontend') } } }) - .controller('NotesCtrl', function (authManager, $timeout, $rootScope, modelManager) { + .controller('NotesCtrl', function (authManager, $timeout, $rootScope, modelManager, storageManager) { - this.sortBy = localStorage.getItem("sortBy") || "created_at"; + this.sortBy = storageManager.getItem("sortBy") || "created_at"; this.sortDescending = this.sortBy != "title"; $rootScope.$on("editorFocused", function(){ @@ -44,11 +44,26 @@ angular.module('app.frontend') this.selectFirstNote(false); }.bind(this)) + $rootScope.$on("noteArchived", function() { + this.selectFirstNote(false); + }.bind(this)) + this.notesToDisplay = 20; this.paginate = function() { this.notesToDisplay += 20 } + this.sortByTitle = function() { + var base = "Sort |"; + if(this.sortBy == "created_at") { + return base + " Date added"; + } else if(this.sortBy == "updated_at") { + return base + " Date modifed"; + } else if(this.sortBy == "title") { + return base + " Title"; + } + } + this.tagDidChange = function(tag, oldTag) { this.showMenu = false; @@ -98,6 +113,14 @@ angular.module('app.frontend') this.noteFilter = {text : ''}; this.filterNotes = function(note) { + if(this.tag.archiveTag) { + return note.archived; + } + + if(note.archived) { + return false; + } + var filterText = this.noteFilter.text.toLowerCase(); if(filterText.length == 0) { note.visible = true; @@ -139,7 +162,7 @@ angular.module('app.frontend') this.setSortBy = function(type) { this.sortBy = type; - localStorage.setItem("sortBy", type); + storageManager.setItem("sortBy", type); } }); diff --git a/app/assets/javascripts/app/frontend/controllers/tags.js b/app/assets/javascripts/app/frontend/controllers/tags.js index 1cf690773..41e34cc14 100644 --- a/app/assets/javascripts/app/frontend/controllers/tags.js +++ b/app/assets/javascripts/app/frontend/controllers/tags.js @@ -9,6 +9,7 @@ angular.module('app.frontend') save: "&", tags: "=", allTag: "=", + archiveTag: "=", updateNoteTag: "&", removeTag: "&" }, diff --git a/app/assets/javascripts/app/frontend/models/api/item.js b/app/assets/javascripts/app/frontend/models/api/item.js index 2b333748b..b153ac92b 100644 --- a/app/assets/javascripts/app/frontend/models/api/item.js +++ b/app/assets/javascripts/app/frontend/models/api/item.js @@ -1,9 +1,11 @@ +let AppDomain = "org.standardnotes.sn"; +var dateFormatter; + class Item { - constructor(json_obj) { - + constructor(json_obj = {}) { + this.appData = {}; this.updateFromJSON(json_obj); - this.observers = []; if(!this.uuid) { @@ -30,7 +32,7 @@ class Item { try { return JSON.parse(this.content); } catch (e) { - console.log("Error parsing json", e); + console.log("Error parsing json", e, this); return {}; } } @@ -81,7 +83,10 @@ class Item { } mapContentToLocalProperties(contentObj) { - + this.appData = contentObj.appData; + if(!this.appData) { + this.appData = {}; + } } createContentJSONFromProperties() { @@ -93,7 +98,10 @@ class Item { } structureParams() { - return {references: this.referenceParams()} + return { + references: this.referenceParams(), + appData: this.appData + } } addItemAsRelationship(item) { @@ -137,4 +145,71 @@ class Item { doNotEncrypt() { return false; } + + /* + App Data + */ + + setAppDataItem(key, value) { + var data = this.appData[AppDomain]; + if(!data) { + data = {} + } + data[key] = value; + this.appData[AppDomain] = data; + } + + getAppDataItem(key) { + var data = this.appData[AppDomain]; + if(data) { + return data[key]; + } else { + return null; + } + } + + get pinned() { + return this.getAppDataItem("pinned"); + } + + get archived() { + return this.getAppDataItem("archived"); + } + + + + /* + Dates + */ + + createdAtString() { + return this.dateToLocalizedString(this.created_at); + } + + updatedAtString() { + return this.dateToLocalizedString(this.updated_at); + } + + dateToLocalizedString(date) { + if (typeof Intl !== 'undefined' && Intl.DateTimeFormat) { + if (!dateFormatter) { + var locale = (navigator.languages && navigator.languages.length) ? navigator.languages[0] : navigator.language; + dateFormatter = new Intl.DateTimeFormat(locale, { + year: 'numeric', + month: 'short', + day: '2-digit', + weekday: 'long', + hour: '2-digit', + minute: '2-digit', + }); + } + return dateFormatter.format(date); + } else { + // IE < 11, Safari <= 9.0. + // In English, this generates the string most similar to + // the toLocaleDateString() result above. + return date.toDateString() + ' ' + date.toLocaleTimeString(); + } + } + } diff --git a/app/assets/javascripts/app/frontend/models/api/syncAdapter.js b/app/assets/javascripts/app/frontend/models/api/syncAdapter.js index 4c3ce2a3a..6e74b1441 100644 --- a/app/assets/javascripts/app/frontend/models/api/syncAdapter.js +++ b/app/assets/javascripts/app/frontend/models/api/syncAdapter.js @@ -10,6 +10,15 @@ class SyncAdapter extends Item { } structureParams() { + // There was a bug with the way Base64 content was parsed in previous releases related to this item. + // The bug would not parse the JSON behind the base64 string and thus saved data in an invalid format. + // This is the line: https://github.com/standardnotes/web/commit/1ad0bf73d8e995b7588854f1b1e4e4a02303a42f#diff-15753bac364782a3a5876032bcdbf99aR76 + // We'll remedy this for affected users by trying to parse the content string + if(typeof this.content !== 'object') { + try { + this.content = JSON.parse(this.content); + } catch (e) {} + } var params = this.content || {}; _.merge(params, super.structureParams()); return params; diff --git a/app/assets/javascripts/app/frontend/models/app/note.js b/app/assets/javascripts/app/frontend/models/app/note.js index 8c125587c..7ea5d10ea 100644 --- a/app/assets/javascripts/app/frontend/models/app/note.js +++ b/app/assets/javascripts/app/frontend/models/app/note.js @@ -106,4 +106,8 @@ class Note extends Item { get content_type() { return "Note"; } + + tagsString() { + return Tag.arrayToDisplayString(this.tags); + } } diff --git a/app/assets/javascripts/app/frontend/models/app/tag.js b/app/assets/javascripts/app/frontend/models/app/tag.js index b3552edb1..1976f6f3b 100644 --- a/app/assets/javascripts/app/frontend/models/app/tag.js +++ b/app/assets/javascripts/app/frontend/models/app/tag.js @@ -86,4 +86,14 @@ class Tag extends Item { allReferencedObjects() { return this.notes; } + + static arrayToDisplayString(tags, includeComma) { + return tags.map(function(tag, i){ + var text = "#" + tag.title; + if(i != tags.length - 1) { + text += includeComma ? ", " : " "; + } + return text; + }).join(" "); + } } diff --git a/app/assets/javascripts/app/frontend/models/local/encryptedStorage.js b/app/assets/javascripts/app/frontend/models/local/encryptedStorage.js new file mode 100644 index 000000000..412c669ca --- /dev/null +++ b/app/assets/javascripts/app/frontend/models/local/encryptedStorage.js @@ -0,0 +1,28 @@ +class EncryptedStorage extends Item { + + constructor(json_obj) { + super(json_obj); + } + + mapContentToLocalProperties(contentObject) { + super.mapContentToLocalProperties(contentObject) + this.storage = contentObject.storage; + } + + structureParams() { + var params = { + storage: this.storage, + }; + + _.merge(params, super.structureParams()); + return params; + } + + toJSON() { + return {uuid: this.uuid} + } + + get content_type() { + return "SN|EncryptedStorage"; + } +} diff --git a/app/assets/javascripts/app/frontend/models/local/itemParams.js b/app/assets/javascripts/app/frontend/models/local/itemParams.js index a07c898f4..7cb25214c 100644 --- a/app/assets/javascripts/app/frontend/models/local/itemParams.js +++ b/app/assets/javascripts/app/frontend/models/local/itemParams.js @@ -3,7 +3,7 @@ class ItemParams { constructor(item, keys, version) { this.item = item; this.keys = keys; - this.version = version; + this.version = version || "002"; } paramsForExportFile() { diff --git a/app/assets/javascripts/app/frontend/routes.js b/app/assets/javascripts/app/frontend/routes.js index f02c9bbfd..5409347db 100644 --- a/app/assets/javascripts/app/frontend/routes.js +++ b/app/assets/javascripts/app/frontend/routes.js @@ -1,8 +1,7 @@ angular.module('app.frontend') .config(function ($locationProvider) { - var runningInElectron = window && window.process && window.process.type && window.process.versions["electron"]; - if(!runningInElectron) { + if(!isDesktopApplication()) { if (window.history && window.history.pushState) { $locationProvider.html5Mode({ enabled: true, diff --git a/app/assets/javascripts/app/services/analyticsManager.js b/app/assets/javascripts/app/services/analyticsManager.js deleted file mode 100644 index bbc80a409..000000000 --- a/app/assets/javascripts/app/services/analyticsManager.js +++ /dev/null @@ -1,51 +0,0 @@ -class AnalyticsManager { - - constructor(authManager) { - this.authManager = authManager; - - var status = localStorage.getItem("analyticsEnabled"); - if(status === null) { - this.enabled = false; - } else { - this.enabled = JSON.parse(status); - } - - if(this.enabled === true) { - this.initialize(); - } - } - - setStatus(enabled) { - this.enabled = enabled; - localStorage.setItem("analyticsEnabled", JSON.stringify(enabled)); - - window.location.reload(); - } - - toggleStatus() { - this.setStatus(!this.enabled); - } - - initialize() { - // load analytics - window._paq = window._paq || []; - - (function() { - var u="https://piwik.standardnotes.org/"; - window._paq.push(['setTrackerUrl', u+'piwik.php']); - window._paq.push(['setSiteId', '2']); - var d=document, g=d.createElement('script'), s=d.getElementsByTagName('script')[0]; - g.type='text/javascript'; g.id="piwik", g.async=true; g.defer=true; g.src=u+'piwik.js'; s.parentNode.insertBefore(g,s); - })(); - - var analyticsId = this.authManager.getUserAnalyticsId(); - if(analyticsId) { - window._paq.push(['setUserId', analyticsId]); - } - window._paq.push(['trackPageView', "AppInterface"]); - window._paq.push(['enableLinkTracking']); - } - -} - -angular.module('app.frontend').service('analyticsManager', AnalyticsManager); diff --git a/app/assets/javascripts/app/services/authManager.js b/app/assets/javascripts/app/services/authManager.js index 67f5dd3fb..26ddd6ec8 100644 --- a/app/assets/javascripts/app/services/authManager.js +++ b/app/assets/javascripts/app/services/authManager.js @@ -7,49 +7,62 @@ angular.module('app.frontend') return domain; } - this.$get = function($rootScope, $timeout, httpManager, modelManager, dbManager) { - return new AuthManager($rootScope, $timeout, httpManager, modelManager, dbManager); + this.$get = function($rootScope, $timeout, httpManager, modelManager, dbManager, storageManager) { + return new AuthManager($rootScope, $timeout, httpManager, modelManager, dbManager, storageManager); } - function AuthManager($rootScope, $timeout, httpManager, modelManager, dbManager) { + function AuthManager($rootScope, $timeout, httpManager, modelManager, dbManager, storageManager) { - var userData = localStorage.getItem("user"); - if(userData) { - this.user = JSON.parse(userData); - } else { - // legacy, check for uuid - var idData = localStorage.getItem("uuid"); - if(idData) { - this.user = {uuid: idData}; + this.loadInitialData = function() { + var userData = storageManager.getItem("user"); + if(userData) { + this.user = JSON.parse(userData); + } else { + // legacy, check for uuid + var idData = storageManager.getItem("uuid"); + if(idData) { + this.user = {uuid: idData}; + } } } - this.getUserAnalyticsId = function() { - if(!this.user || !this.user.uuid) { - return null; - } - // anonymize user id irreversably - return Neeto.crypto.hmac256(this.user.uuid, Neeto.crypto.sha256(localStorage.getItem("pw"))); - } - this.offline = function() { return !this.user; } + this.isEphemeralSession = function() { + if(this.ephemeral == null || this.ephemeral == undefined) { + this.ephemeral = JSON.parse(storageManager.getItem("ephemeral", StorageManager.Fixed)); + } + return this.ephemeral; + } + + this.setEphemeral = function(ephemeral) { + this.ephemeral = ephemeral; + if(ephemeral) { + storageManager.setModelStorageMode(StorageManager.Ephemeral); + storageManager.setItemsMode(storageManager.hasPasscode() ? StorageManager.FixedEncrypted : StorageManager.Ephemeral); + } else { + storageManager.setItem("ephemeral", JSON.stringify(false), StorageManager.Fixed); + } + } + this.getAuthParams = function() { if(!this._authParams) { - this._authParams = JSON.parse(localStorage.getItem("auth_params")); + this._authParams = JSON.parse(storageManager.getItem("auth_params")); } return this._authParams; } this.keys = function() { - var mk = localStorage.getItem("mk"); - if(!mk) { - return null; + if(!this._keys) { + var mk = storageManager.getItem("mk"); + if(!mk) { + return null; + } + this._keys = {mk: mk, ak: storageManager.getItem("ak")}; } - var keys = {mk: mk, ak: localStorage.getItem("ak")}; - return keys; + return this._keys; } this.protocolVersion = function() { @@ -99,7 +112,7 @@ angular.module('app.frontend') } } - this.login = function(url, email, password, callback) { + this.login = function(url, email, password, ephemeral, callback) { this.getAuthParamsForEmail(url, email, function(authParams){ if(!authParams || !authParams.pw_cost) { @@ -134,7 +147,11 @@ angular.module('app.frontend') var requestUrl = url + "/auth/sign_in"; var params = {password: keys.pw, email: email}; httpManager.postAbsolute(requestUrl, params, function(response){ + this.setEphemeral(ephemeral); + this.handleAuthResponse(response, email, url, authParams, keys); + storageManager.setModelStorageMode(ephemeral ? StorageManager.Ephemeral : StorageManager.Fixed); + callback(response); }.bind(this), function(response){ console.error("Error logging in", response); @@ -148,11 +165,16 @@ angular.module('app.frontend') this.handleAuthResponse = function(response, email, url, authParams, keys) { try { if(url) { - localStorage.setItem("server", url); + storageManager.setItem("server", url); } - localStorage.setItem("user", JSON.stringify(response.user)); - localStorage.setItem("auth_params", JSON.stringify(authParams)); - localStorage.setItem("jwt", response.token); + + this.user = response.user; + storageManager.setItem("user", JSON.stringify(response.user)); + + this._authParams = authParams; + storageManager.setItem("auth_params", JSON.stringify(authParams)); + + storageManager.setItem("jwt", response.token); this.saveKeys(keys); } catch(e) { dbManager.displayOfflineAlert(); @@ -160,18 +182,24 @@ angular.module('app.frontend') } this.saveKeys = function(keys) { - localStorage.setItem("pw", keys.pw); - localStorage.setItem("mk", keys.mk); - localStorage.setItem("ak", keys.ak); + this._keys = keys; + storageManager.setItem("pw", keys.pw); + storageManager.setItem("mk", keys.mk); + storageManager.setItem("ak", keys.ak); } - this.register = function(url, email, password, callback) { + this.register = function(url, email, password, ephemeral, callback) { Neeto.crypto.generateInitialEncryptionKeysForUser({password: password, email: email}, function(keys, authParams){ var requestUrl = url + "/auth"; var params = _.merge({password: keys.pw, email: email}, authParams); httpManager.postAbsolute(requestUrl, params, function(response){ + this.setEphemeral(ephemeral); + this.handleAuthResponse(response, email, url, authParams, keys); + + storageManager.setModelStorageMode(ephemeral ? StorageManager.Ephemeral : StorageManager.Fixed); + callback(response); }.bind(this), function(response){ console.error("Registration error", response); @@ -182,7 +210,7 @@ angular.module('app.frontend') this.changePassword = function(email, new_password, callback) { Neeto.crypto.generateInitialEncryptionKeysForUser({password: new_password, email: email}, function(keys, authParams){ - var requestUrl = localStorage.getItem("server") + "/auth/change_pw"; + var requestUrl = storageManager.getItem("server") + "/auth/change_pw"; var params = _.merge({new_password: keys.pw}, authParams); httpManager.postAbsolute(requestUrl, params, function(response) { @@ -200,10 +228,10 @@ angular.module('app.frontend') } this.updateAuthParams = function(authParams, callback) { - var requestUrl = localStorage.getItem("server") + "/auth/update"; + var requestUrl = storageManager.getItem("server") + "/auth/update"; var params = authParams; httpManager.postAbsolute(requestUrl, params, function(response) { - localStorage.setItem("auth_params", JSON.stringify(authParams)); + storageManager.setItem("auth_params", JSON.stringify(authParams)); if(callback) { callback(response); } @@ -246,6 +274,8 @@ angular.module('app.frontend') } this.signOut = function() { + this._keys = null; + this.user = null; this._authParams = null; } diff --git a/app/assets/javascripts/app/services/dbManager.js b/app/assets/javascripts/app/services/dbManager.js index e642ff186..4c5d0341e 100644 --- a/app/assets/javascripts/app/services/dbManager.js +++ b/app/assets/javascripts/app/services/dbManager.js @@ -1,5 +1,9 @@ class DBManager { + constructor() { + this.locked = true; + } + displayOfflineAlert() { var message = "There was an issue loading your offline database. This could happen for two reasons:"; message += "\n\n1. You're in a private window in your browser. We can't save your data without access to the local database. Please use a non-private window."; @@ -7,7 +11,15 @@ class DBManager { alert(message); } + setLocked(locked) { + this.locked = locked; + } + openDatabase(callback, onUgradeNeeded) { + if(this.locked) { + return; + } + var request = window.indexedDB.open("standardnotes", 1); request.onerror = function(event) { @@ -57,7 +69,7 @@ class DBManager { }; } - getAllItems(callback) { + getAllModels(callback) { this.openDatabase((db) => { var objectStore = db.transaction("items").objectStore("items"); var items = []; @@ -74,11 +86,11 @@ class DBManager { }, null) } - saveItem(item) { - this.saveItems([item]); + saveModel(item) { + this.saveModels([item]); } - saveItems(items, callback) { + saveModels(items, callback) { if(items.length == 0) { if(callback) { @@ -115,7 +127,7 @@ class DBManager { }, null) } - deleteItem(item, callback) { + deleteModel(item, callback) { this.openDatabase((db) => { var request = db.transaction("items", "readwrite").objectStore("items").delete(item.uuid); request.onsuccess = function(event) { @@ -126,26 +138,17 @@ class DBManager { }, null) } - getItemByUUID(uuid, callback) { - this.openDatabase((db) => { - var request = db.transaction("items", "readonly").objectStore("items").get(uuid); - request.onsuccess = function(event) { - callback(event.result); - }; - }, null); - } - - clearAllItems(callback) { + clearAllModels(callback) { var deleteRequest = window.indexedDB.deleteDatabase("standardnotes"); deleteRequest.onerror = function(event) { console.log("Error deleting database."); - callback(); + callback && callback(); }; deleteRequest.onsuccess = function(event) { console.log("Database deleted successfully"); - callback(); + callback && callback(); }; deleteRequest.onblocked = function(event) { diff --git a/app/assets/javascripts/app/services/directives/views/accountMenu.js b/app/assets/javascripts/app/services/directives/views/accountMenu.js index 1b4e2eb8b..96a7a70ff 100644 --- a/app/assets/javascripts/app/services/directives/views/accountMenu.js +++ b/app/assets/javascripts/app/services/directives/views/accountMenu.js @@ -3,18 +3,19 @@ class AccountMenu { constructor() { this.restrict = "E"; this.templateUrl = "frontend/directives/account-menu.html"; - this.scope = {}; + this.scope = { + "onSuccessfulAuth" : "&" + }; } - controller($scope, authManager, modelManager, syncManager, dbManager, analyticsManager, $timeout) { + controller($scope, authManager, modelManager, syncManager, dbManager, passcodeManager, $timeout, storageManager) { 'ngInject'; - $scope.formData = {mergeLocal: true, url: syncManager.serverURL}; + $scope.formData = {mergeLocal: true, url: syncManager.serverURL, ephemeral: false}; $scope.user = authManager.user; $scope.server = syncManager.serverURL; $scope.syncStatus = syncManager.syncStatus; - $scope.analyticsManager = analyticsManager; $scope.encryptionKey = function() { return authManager.keys().mk; @@ -29,7 +30,7 @@ class AccountMenu { } $scope.dashboardURL = function() { - return `${$scope.server}/dashboard/?server=${$scope.server}&id=${encodeURIComponent($scope.user.email)}&pw=${$scope.serverPassword()}`; + return `${$scope.server}/dashboard/#server=${$scope.server}&id=${encodeURIComponent($scope.user.email)}&pw=${$scope.serverPassword()}`; } $scope.newPasswordData = {}; @@ -95,7 +96,7 @@ class AccountMenu { $scope.login = function() { $scope.formData.status = "Generating Login Keys..."; $timeout(function(){ - authManager.login($scope.formData.url, $scope.formData.email, $scope.formData.user_password, function(response){ + authManager.login($scope.formData.url, $scope.formData.email, $scope.formData.user_password, $scope.formData.ephemeral, function(response){ if(!response || response.error) { $scope.formData.status = null; var error = response ? response.error : {message: "An unknown error occured."} @@ -120,7 +121,7 @@ class AccountMenu { $scope.formData.status = "Generating Account Keys..."; $timeout(function(){ - authManager.register($scope.formData.url, $scope.formData.email, $scope.formData.user_password, function(response){ + authManager.register($scope.formData.url, $scope.formData.email, $scope.formData.user_password, $scope.formData.ephemeral ,function(response){ if(!response || response.error) { $scope.formData.status = null; var error = response ? response.error : {message: "An unknown error occured."} @@ -132,10 +133,6 @@ class AccountMenu { }) } - $scope.localNotesCount = function() { - return modelManager.filteredNotes.length; - } - $scope.mergeLocalChanged = function() { if(!$scope.formData.mergeLocal) { if(!confirm("Unchecking this option means any of the notes you have written while you were signed out will be deleted. Are you sure you want to discard these notes?")) { @@ -146,18 +143,20 @@ class AccountMenu { $scope.onAuthSuccess = function() { var block = function() { - window.location.reload(); + $timeout(function(){ + $scope.onSuccessfulAuth()(); + syncManager.sync(); + }) } if($scope.formData.mergeLocal) { syncManager.markAllItemsDirtyAndSaveOffline(function(){ block(); - }) + }, true) } else { - dbManager.clearAllItems(function(){ - $timeout(function(){ - block(); - }) + modelManager.resetLocalMemory(); + storageManager.clearAllModels(function(){ + block(); }) } } @@ -206,26 +205,25 @@ class AccountMenu { var file = files[0]; var reader = new FileReader(); reader.onload = function(e) { - var data = JSON.parse(e.target.result); - $timeout(function(){ - if(data.auth_params) { - // request password - $scope.importData.requestPassword = true; - $scope.importData.data = data; - } else { - $scope.performImport(data, null); - } - }) + try { + var data = JSON.parse(e.target.result); + $timeout(function(){ + if(data.auth_params) { + // request password + $scope.importData.requestPassword = true; + $scope.importData.data = data; + } else { + $scope.performImport(data, null); + } + }) + } catch (e) { + alert("Unable to open file. Ensure it is a proper JSON file and try again."); + } } reader.readAsText(file); } - $scope.encryptionStatusForNotes = function() { - var items = modelManager.allItemsMatchingTypes(["Note", "Tag"]); - return items.length + "/" + items.length + " notes and tags encrypted"; - } - $scope.importJSONData = function(data, password, callback) { var onDataReady = function() { var items = modelManager.mapResponseItemsToLocalModels(data.items); @@ -424,6 +422,103 @@ class AccountMenu { }); } + + /* + Encryption Status + */ + + $scope.notesAndTagsCount = function() { + var items = modelManager.allItemsMatchingTypes(["Note", "Tag"]); + return items.length; + } + + $scope.encryptionStatusForNotes = function() { + var length = $scope.notesAndTagsCount(); + return length + "/" + length + " notes and tags encrypted"; + } + + $scope.encryptionEnabled = function() { + return passcodeManager.hasPasscode() || !authManager.offline(); + } + + $scope.encryptionSource = function() { + if(!authManager.offline()) { + return "Account keys"; + } else if(passcodeManager.hasPasscode()) { + return "Local Passcode"; + } else { + return null; + } + } + + $scope.encryptionStatusString = function() { + if(!authManager.offline()) { + return "End-to-end encryption is enabled. Your data is encrypted before being synced to your private account."; + } else if(passcodeManager.hasPasscode()) { + return "Encryption is enabled. Your data is encrypted using your passcode before being stored on disk."; + } else { + return "Encryption is not enabled. Sign in, register, or add a passcode lock to enable encryption."; + } + } + + /* + Passcode Lock + */ + + $scope.passcodeOptionAvailable = function() { + // If you're signed in with an ephemeral session, passcode lock is unavailable + return authManager.offline() || !authManager.isEphemeralSession(); + } + + $scope.hasPasscode = function() { + return passcodeManager.hasPasscode(); + } + + $scope.addPasscodeClicked = function() { + $scope.formData.showPasscodeForm = true; + } + + $scope.submitPasscodeForm = function() { + var passcode = $scope.formData.passcode; + if(passcode !== $scope.formData.confirmPasscode) { + alert("The two passcodes you entered do not match. Please try again."); + return; + } + + passcodeManager.setPasscode(passcode, () => { + $timeout(function(){ + $scope.formData.showPasscodeForm = false; + var offline = authManager.offline(); + + var message = "You've succesfully set an app passcode."; + if(offline) { message += " Your items will now be encrypted using this passcode."; } + alert(message); + + if(offline) { + syncManager.markAllItemsDirtyAndSaveOffline(); + } + }) + }) + } + + $scope.removePasscodePressed = function() { + var signedIn = !authManager.offline(); + var message = "Are you sure you want to remove your local passcode?"; + if(!signedIn) { + message += " This will remove encryption from your local data."; + } + if(confirm(message)) { + passcodeManager.clearPasscode(); + if(authManager.offline()) { + syncManager.markAllItemsDirtyAndSaveOffline(); + } + } + } + + $scope.isDesktopApplication = function() { + return isDesktopApplication(); + } + } } diff --git a/app/assets/javascripts/app/services/directives/views/globalExtensionsMenu.js b/app/assets/javascripts/app/services/directives/views/globalExtensionsMenu.js index 56aa9077b..e93c069eb 100644 --- a/app/assets/javascripts/app/services/directives/views/globalExtensionsMenu.js +++ b/app/assets/javascripts/app/services/directives/views/globalExtensionsMenu.js @@ -17,6 +17,8 @@ class GlobalExtensionsMenu { $scope.editorManager = editorManager; $scope.componentManager = componentManager; + $scope.serverExtensions = modelManager.itemsForContentType("SF|Extension"); + $scope.selectedAction = function(action, extension) { extensionManager.executeAction(action, extension, null, function(response){ if(response && response.error) { @@ -53,6 +55,38 @@ class GlobalExtensionsMenu { } } + // Server extensions + + $scope.deleteServerExt = function(ext) { + if(confirm("Are you sure you want to delete and disable this extension?")) { + _.remove($scope.serverExtensions, {uuid: ext.uuid}); + modelManager.setItemToBeDeleted(ext); + syncManager.sync(); + } + } + + $scope.nameForServerExtension = function(ext) { + var url = ext.url; + if(!url) { + return "Invalid Extension"; + } + if(url.includes("gdrive")) { + return "Google Drive Sync"; + } else if(url.includes("file_attacher")) { + return "File Attacher"; + } else if(url.includes("onedrive")) { + return "OneDrive Sync"; + } else if(url.includes("backup.email_archive")) { + return "Daily Email Backups"; + } else if(url.includes("dropbox")) { + return "Dropbox Sync"; + } else if(url.includes("revisions")) { + return "Revision History"; + } else { + return null; + } + } + // Editors @@ -127,6 +161,7 @@ class GlobalExtensionsMenu { modelManager.addItem(ext); syncManager.sync(); + $scope.serverExtensions.push(ext); completion(); } diff --git a/app/assets/javascripts/app/services/helpers/crypto.js b/app/assets/javascripts/app/services/encryption/crypto.js similarity index 96% rename from app/assets/javascripts/app/services/helpers/crypto.js rename to app/assets/javascripts/app/services/encryption/crypto.js index 3c673cea5..460bba70d 100644 --- a/app/assets/javascripts/app/services/helpers/crypto.js +++ b/app/assets/javascripts/app/services/encryption/crypto.js @@ -99,7 +99,7 @@ class SNCrypto { var pw_cost = this.defaultPasswordGenerationCost(); var pw_nonce = this.generateRandomKey(512); var pw_salt = this.sha256([email, pw_nonce].join(":")); - this.generateSymmetricKeyPair({email: email, password: password, pw_salt: pw_salt, pw_cost: pw_cost}, function(keys){ + this.generateSymmetricKeyPair({password: password, pw_salt: pw_salt, pw_cost: pw_cost}, function(keys){ callback({pw: keys[0], mk: keys[1], ak: keys[2]}, {pw_salt: pw_salt, pw_cost: pw_cost, version: "002"}); }.bind(this)); } diff --git a/app/assets/javascripts/app/services/helpers/cryptojs.js b/app/assets/javascripts/app/services/encryption/cryptojs.js similarity index 100% rename from app/assets/javascripts/app/services/helpers/cryptojs.js rename to app/assets/javascripts/app/services/encryption/cryptojs.js diff --git a/app/assets/javascripts/app/services/helpers/encryptionHelper.js b/app/assets/javascripts/app/services/encryption/encryptionHelper.js similarity index 95% rename from app/assets/javascripts/app/services/helpers/encryptionHelper.js rename to app/assets/javascripts/app/services/encryption/encryptionHelper.js index 3b97a66e7..740a728be 100644 --- a/app/assets/javascripts/app/services/helpers/encryptionHelper.js +++ b/app/assets/javascripts/app/services/encryption/encryptionHelper.js @@ -16,7 +16,7 @@ class EncryptionHelper { return fullCiphertext; } - static encryptItem(item, keys, version) { + static encryptItem(item, keys, version = "002") { var params = {}; // encrypt item key var item_key = Neeto.crypto.generateRandomEncryptionKey(); @@ -73,7 +73,10 @@ class EncryptionHelper { // is encrypted, continue to below } else { // is base64 encoded - item.content = Neeto.crypto.base64Decode(item.content.substring(3, item.content.length)) + try { + item.content = JSON.parse(Neeto.crypto.base64Decode(item.content.substring(3, item.content.length))); + } catch (e) {} + return; } diff --git a/app/assets/javascripts/app/services/helpers/webcrypto.js b/app/assets/javascripts/app/services/encryption/webcrypto.js similarity index 100% rename from app/assets/javascripts/app/services/helpers/webcrypto.js rename to app/assets/javascripts/app/services/encryption/webcrypto.js diff --git a/app/assets/javascripts/app/services/extensionManager.js b/app/assets/javascripts/app/services/extensionManager.js index 83dabe76d..e53e8733a 100644 --- a/app/assets/javascripts/app/services/extensionManager.js +++ b/app/assets/javascripts/app/services/extensionManager.js @@ -1,12 +1,13 @@ class ExtensionManager { - constructor(httpManager, modelManager, authManager, syncManager) { + constructor(httpManager, modelManager, authManager, syncManager, storageManager) { this.httpManager = httpManager; this.modelManager = modelManager; this.authManager = authManager; - this.enabledRepeatActionUrls = JSON.parse(localStorage.getItem("enabledRepeatActionUrls")) || []; - this.decryptedExtensions = JSON.parse(localStorage.getItem("decryptedExtensions")) || []; + this.enabledRepeatActionUrls = JSON.parse(storageManager.getItem("enabledRepeatActionUrls")) || []; + this.decryptedExtensions = JSON.parse(storageManager.getItem("decryptedExtensions")) || []; this.syncManager = syncManager; + this.storageManager = storageManager; modelManager.addItemSyncObserver("extensionManager", "Extension", function(items){ for (var ext of items) { @@ -49,7 +50,7 @@ class ExtensionManager { this.decryptedExtensions.push(extension.url); } - localStorage.setItem("decryptedExtensions", JSON.stringify(this.decryptedExtensions)) + this.storageManager.setItem("decryptedExtensions", JSON.stringify(this.decryptedExtensions)) extension.encrypted = this.extensionUsesEncryptedData(extension); } @@ -240,7 +241,7 @@ class ExtensionManager { disableRepeatAction(action, extension) { _.pull(this.enabledRepeatActionUrls, action.url); - localStorage.setItem("enabledRepeatActionUrls", JSON.stringify(this.enabledRepeatActionUrls)); + this.storageManager.setItem("enabledRepeatActionUrls", JSON.stringify(this.enabledRepeatActionUrls)); this.modelManager.removeItemChangeObserver(action.url); console.assert(this.isRepeatActionEnabled(action) == false); @@ -249,7 +250,7 @@ class ExtensionManager { enableRepeatAction(action, extension) { if(!_.find(this.enabledRepeatActionUrls, action.url)) { this.enabledRepeatActionUrls.push(action.url); - localStorage.setItem("enabledRepeatActionUrls", JSON.stringify(this.enabledRepeatActionUrls)); + this.storageManager.setItem("enabledRepeatActionUrls", JSON.stringify(this.enabledRepeatActionUrls)); } if(action.repeat_mode) { diff --git a/app/assets/javascripts/app/services/filters/sortBy.js b/app/assets/javascripts/app/services/filters/sortBy.js new file mode 100644 index 000000000..c3902604b --- /dev/null +++ b/app/assets/javascripts/app/services/filters/sortBy.js @@ -0,0 +1,42 @@ +angular.module('app.frontend') + .filter('sortBy', function ($filter) { + return function(items, sortBy) { + let sortValueFn = (a, b, pinCheck = false) => { + if(!pinCheck) { + if(a.pinned && b.pinned) { + return sortValueFn(a, b, true); + } + if(a.pinned) { return -1; } + if(b.pinned) { return 1; } + } + + var aValue = a[sortBy] || ""; + var bValue = b[sortBy] || ""; + + let vector = 1; + if(sortBy == "title") { + aValue = aValue.toLowerCase(); + bValue = bValue.toLowerCase(); + + if(aValue.length == 0 && bValue.length == 0) { + return 0; + } else if(aValue.length == 0 && bValue.length != 0) { + return 1; + } else if(aValue.length != 0 && bValue.length == 0) { + return -1; + } else { + vector = -1; + } + } + + if(aValue > bValue) { return -1 * vector;} + else if(aValue < bValue) { return 1 * vector;} + return 0; + } + + items = items || []; + return items.sort(function(a, b){ + return sortValueFn(a, b); + }) + }; + }); diff --git a/app/assets/javascripts/app/services/httpManager.js b/app/assets/javascripts/app/services/httpManager.js index b0c06ff2a..a5d157d57 100644 --- a/app/assets/javascripts/app/services/httpManager.js +++ b/app/assets/javascripts/app/services/httpManager.js @@ -1,14 +1,15 @@ class HttpManager { - constructor($timeout) { + constructor($timeout, storageManager) { // calling callbacks in a $timeout allows angular UI to update this.$timeout = $timeout; + this.storageManager = storageManager; } setAuthHeadersForRequest(request) { - var token = localStorage.getItem("jwt"); + var token = this.storageManager.getItem("jwt"); if(token) { - request.setRequestHeader('Authorization', 'Bearer ' + localStorage.getItem("jwt")); + request.setRequestHeader('Authorization', 'Bearer ' + token); } } diff --git a/app/assets/javascripts/app/services/modelManager.js b/app/assets/javascripts/app/services/modelManager.js index a5b5ff682..4586275ef 100644 --- a/app/assets/javascripts/app/services/modelManager.js +++ b/app/assets/javascripts/app/services/modelManager.js @@ -1,14 +1,21 @@ class ModelManager { - constructor(dbManager) { - this.dbManager = dbManager; + constructor(storageManager) { + this.storageManager = storageManager; this.notes = []; this.tags = []; this.itemSyncObservers = []; this.itemChangeObservers = []; this.items = []; this._extensions = []; - this.acceptableContentTypes = ["Note", "Tag", "Extension", "SN|Editor", "SN|Theme", "SN|Component"]; + this.acceptableContentTypes = ["Note", "Tag", "Extension", "SN|Editor", "SN|Theme", "SN|Component", "SF|Extension"]; + } + + resetLocalMemory() { + this.notes.length = 0; + this.tags.length = 0; + this.items.length = 0; + this._extensions.length = 0; } get allItems() { @@ -26,9 +33,14 @@ class ModelManager { alternateUUIDForItem(item, callback) { // we need to clone this item and give it a new uuid, then delete item with old uuid from db (you can't mofidy uuid's in our indexeddb setup) var newItem = this.createItem(item); + newItem.uuid = Neeto.crypto.generateUUID(); + + // Update uuids of relationships newItem.informReferencesOfUUIDChange(item.uuid, newItem.uuid); + this.informModelsOfUUIDChangeForItem(newItem, item.uuid, newItem.uuid); + this.removeItemLocally(item, function(){ this.addItem(newItem); newItem.setDirty(true); @@ -53,6 +65,12 @@ class ModelManager { }) } + itemsForContentType(contentType) { + return this.items.filter(function(item){ + return item.content_type == contentType; + }); + } + findItem(itemId) { return _.find(this.items, {uuid: itemId}); } @@ -151,6 +169,8 @@ class ModelManager { item = new Theme(json_obj); } else if(json_obj.content_type == "SN|Component") { item = new Component(json_obj); + } else if(json_obj.content_type == "SF|Extension") { + item = new SyncAdapter(json_obj); } else { @@ -189,14 +209,16 @@ class ModelManager { }.bind(this)); } - addItem(item) { - this.addItems([item]); + resortTag(tag) { + _.pull(this.tags, tag); + this.tags.splice(_.sortedIndexBy(this.tags, tag, function(tag){ + if (tag.title) return tag.title.toLowerCase(); + else return '' + }), 0, tag); } - itemsForContentType(contentType) { - return this.items.filter(function(item){ - return item.content_type == contentType; - }); + addItem(item) { + this.addItems([item]); } resolveReferencesForItem(item) { @@ -288,7 +310,7 @@ class ModelManager { _.pull(this._extensions, item); } - this.dbManager.deleteItem(item, callback); + this.storageManager.deleteModel(item, callback); } /* diff --git a/app/assets/javascripts/app/services/passcodeManager.js b/app/assets/javascripts/app/services/passcodeManager.js new file mode 100644 index 000000000..2124b6363 --- /dev/null +++ b/app/assets/javascripts/app/services/passcodeManager.js @@ -0,0 +1,77 @@ +angular.module('app.frontend') + .provider('passcodeManager', function () { + + this.$get = function($rootScope, $timeout, modelManager, dbManager, authManager, storageManager) { + return new PasscodeManager($rootScope, $timeout, modelManager, dbManager, authManager, storageManager); + } + + function PasscodeManager($rootScope, $timeout, modelManager, dbManager, authManager, storageManager) { + + this._hasPasscode = storageManager.getItem("offlineParams", StorageManager.Fixed) != null; + this._locked = this._hasPasscode; + + this.isLocked = function() { + return this._locked; + } + + this.hasPasscode = function() { + return this._hasPasscode; + } + + this.keys = function() { + return this._keys; + } + + this.unlock = function(passcode, callback) { + var params = JSON.parse(storageManager.getItem("offlineParams", StorageManager.Fixed)); + Neeto.crypto.computeEncryptionKeysForUser(_.merge({password: passcode}, params), function(keys){ + if(keys.pw !== params.hash) { + callback(false); + return; + } + + this._keys = keys; + this.decryptLocalStorage(keys); + this._locked = false; + callback(true); + }.bind(this)); + } + + this.setPasscode = function(passcode, callback) { + var cost = Neeto.crypto.defaultPasswordGenerationCost(); + var salt = Neeto.crypto.generateRandomKey(512); + var defaultParams = {pw_cost: cost, pw_salt: salt}; + + Neeto.crypto.computeEncryptionKeysForUser(_.merge({password: passcode}, defaultParams), function(keys) { + defaultParams.hash = keys.pw; + this._keys = keys; + this._hasPasscode = true; + + // Encrypting will initially clear localStorage + this.encryptLocalStorage(keys); + + // After it's cleared, it's safe to write to it + storageManager.setItem("offlineParams", JSON.stringify(defaultParams), StorageManager.Fixed); + callback(true); + }.bind(this)); + } + + this.clearPasscode = function() { + storageManager.setItemsMode(authManager.isEphemeralSession() ? StorageManager.Ephemeral : StorageManager.Fixed); // Transfer from Ephemeral + storageManager.removeItem("offlineParams", StorageManager.Fixed); + this._keys = null; + this._hasPasscode = false; + } + + this.encryptLocalStorage = function(keys) { + storageManager.setKeys(keys); + // Switch to Ephemeral storage, wiping Fixed storage + storageManager.setItemsMode(authManager.isEphemeralSession() ? StorageManager.Ephemeral : StorageManager.FixedEncrypted); + } + + this.decryptLocalStorage = function(keys) { + storageManager.setKeys(keys); + storageManager.decryptStorage(); + } + } +}); diff --git a/app/assets/javascripts/app/services/storageManager.js b/app/assets/javascripts/app/services/storageManager.js new file mode 100644 index 000000000..6540470e3 --- /dev/null +++ b/app/assets/javascripts/app/services/storageManager.js @@ -0,0 +1,221 @@ +class MemoryStorage { + constructor() { + this.memory = {}; + } + + getItem(key) { + return this.memory[key] || null; + } + + get length() { + return Object.keys(this.memory).length; + } + + setItem(key, value) { + this.memory[key] = value; + } + + removeItem(key) { + delete this.memory[key]; + } + + clear() { + this.memory = {}; + } + + keys() { + return Object.keys(this.memory); + } + + key(index) { + return Object.keys(this.memory)[index]; + } +} + +class StorageManager { + + constructor(dbManager) { + this.dbManager = dbManager; + } + + initialize(hasPasscode, ephemeral) { + if(hasPasscode) { + // We don't want to save anything in fixed storage except for actual item data (in IndexedDB) + this.storage = this.memoryStorage; + } else if(ephemeral) { + // We don't want to save anything in fixed storage as well as IndexedDB + this.storage = this.memoryStorage; + } else { + this.storage = localStorage; + } + + this.modelStorageMode = ephemeral ? StorageManager.Ephemeral : StorageManager.Fixed; + } + + get memoryStorage() { + if(!this._memoryStorage) { + this._memoryStorage = new MemoryStorage(); + } + return this._memoryStorage; + } + + setItemsMode(mode) { + var newStorage = this.getVault(mode); + if(newStorage !== this.storage) { + // transfer storages + var length = this.storage.length; + for(var i = 0; i < length; i++) { + var key = this.storage.key(i); + newStorage.setItem(key, this.storage.getItem(key)); + } + + this.storage.clear(); + this.storage = newStorage; + + if(mode == StorageManager.FixedEncrypted) { + this.writeEncryptedStorageToDisk(); + } else if(mode == StorageManager.Fixed) { + // Remove encrypted storage + this.removeItem("encryptedStorage", StorageManager.Fixed); + } + } + } + + getVault(vaultKey) { + if(vaultKey) { + return this.storageForVault(vaultKey); + } else { + return this.storage; + } + } + + storageForVault(vault) { + if(vault == StorageManager.Ephemeral || vault == StorageManager.FixedEncrypted) { + return this.memoryStorage; + } else { + return localStorage; + } + } + + setItem(key, value, vault) { + var storage = this.getVault(vault); + storage.setItem(key, value); + + if(vault === StorageManager.FixedEncrypted) { + this.writeEncryptedStorageToDisk(); + } + } + + getItem(key, vault) { + var storage = this.getVault(vault); + return storage.getItem(key); + } + + removeItem(key, vault) { + var storage = this.getVault(vault); + storage.removeItem(key); + } + + clear() { + this.memoryStorage.clear(); + localStorage.clear(); + } + + storageAsHash() { + var hash = {}; + var length = this.storage.length; + for(var i = 0; i < length; i++) { + var key = this.storage.key(i); + hash[key] = this.storage.getItem(key) + } + return hash; + } + + setKeys(keys) { + this.encryptedStorageKeys = keys; + } + + writeEncryptedStorageToDisk() { + var encryptedStorage = new EncryptedStorage(); + // Copy over totality of current storage + encryptedStorage.storage = this.storageAsHash(); + // Save new encrypted storage in Fixed storage + var params = new ItemParams(encryptedStorage, this.encryptedStorageKeys); + this.setItem("encryptedStorage", JSON.stringify(params.paramsForSync()), StorageManager.Fixed); + } + + decryptStorage() { + var stored = JSON.parse(this.getItem("encryptedStorage", StorageManager.Fixed)); + EncryptionHelper.decryptItem(stored, this.encryptedStorageKeys); + var encryptedStorage = new EncryptedStorage(stored); + + for(var key of Object.keys(encryptedStorage.storage)) { + this.setItem(key, encryptedStorage.storage[key]); + } + } + + hasPasscode() { + return this.getItem("encryptedStorage", StorageManager.Fixed) !== null; + } + + + /* + Model Storage + + If using ephemeral storage, we don't need to write it to anything as references will be held already by controllers + and the global modelManager service. + */ + + setModelStorageMode(mode) { + if(mode == this.modelStorageMode) { + return; + } + + if(mode == StorageManager.Ephemeral) { + // Clear IndexedDB + this.dbManager.clearAllModels(null); + } else { + // Fixed + } + + this.modelStorageMode = mode; + } + + getAllModels(callback) { + if(this.modelStorageMode == StorageManager.Fixed) { + this.dbManager.getAllModels(callback); + } else { + callback && callback(); + } + } + + saveModel(item) { + this.saveModels([item]); + } + + saveModels(items, callback) { + if(this.modelStorageMode == StorageManager.Fixed) { + this.dbManager.saveModels(items, callback); + } else { + callback && callback(); + } + } + + deleteModel(item, callback) { + if(this.modelStorageMode == StorageManager.Fixed) { + this.dbManager.deleteModel(item, callback); + } else { + callback && callback(); + } + } + + clearAllModels(callback) { + this.dbManager.clearAllModels(callback); + } +} + +StorageManager.FixedEncrypted = "FixedEncrypted"; // encrypted memoryStorage + localStorage persistence +StorageManager.Ephemeral = "Ephemeral"; // memoryStorage +StorageManager.Fixed = "Fixed"; // localStorage + +angular.module('app.frontend').service('storageManager', StorageManager); diff --git a/app/assets/javascripts/app/services/syncManager.js b/app/assets/javascripts/app/services/syncManager.js index 38970ac9a..7a998758e 100644 --- a/app/assets/javascripts/app/services/syncManager.js +++ b/app/assets/javascripts/app/services/syncManager.js @@ -1,6 +1,6 @@ class SyncManager { - constructor($rootScope, modelManager, authManager, dbManager, httpManager, $interval, $timeout) { + constructor($rootScope, modelManager, authManager, dbManager, httpManager, $interval, $timeout, storageManager, passcodeManager) { this.$rootScope = $rootScope; this.httpManager = httpManager; this.modelManager = modelManager; @@ -8,25 +8,33 @@ class SyncManager { this.dbManager = dbManager; this.$interval = $interval; this.$timeout = $timeout; + this.storageManager = storageManager; + this.passcodeManager = passcodeManager; this.syncStatus = {}; } get serverURL() { - return localStorage.getItem("server") || window._default_sf_server; + return this.storageManager.getItem("server") || window._default_sf_server; } get masterKey() { - return localStorage.getItem("mk"); + return this.storageManager.getItem("mk"); } get serverPassword() { - return localStorage.getItem("pw"); + return this.storageManager.getItem("pw"); } writeItemsToLocalStorage(items, offlineOnly, callback) { - var version = this.authManager.protocolVersion(); + if(items.length == 0) { + callback && callback(); + return; + } + // Use null to use the latest protocol version if offline + var version = this.authManager.offline() ? null : this.authManager.protocolVersion(); + var keys = this.authManager.offline() ? this.passcodeManager.keys() : this.authManager.keys(); var params = items.map(function(item) { - var itemParams = new ItemParams(item, null, version); + var itemParams = new ItemParams(item, keys, version); itemParams = itemParams.paramsForLocalStorage(); if(offlineOnly) { delete itemParams.dirty; @@ -34,12 +42,12 @@ class SyncManager { return itemParams; }.bind(this)); - this.dbManager.saveItems(params, callback); + this.storageManager.saveModels(params, callback); } loadLocalItems(callback) { - var params = this.dbManager.getAllItems(function(items){ - var items = this.handleItemsResponse(items, null, null); + var params = this.storageManager.getAllModels(function(items){ + var items = this.handleItemsResponse(items, null); Item.sortItemsByDate(items); callback(items); }.bind(this)) @@ -61,12 +69,40 @@ class SyncManager { } - markAllItemsDirtyAndSaveOffline(callback) { - var items = this.modelManager.allItems; - for(var item of items) { - item.setDirty(true); + /* + In the case of signing in and merging local data, we alternative UUIDs + to avoid overwriting data a user may retrieve that has the same UUID. + Alternating here forces us to to create duplicates of the items instead. + */ + markAllItemsDirtyAndSaveOffline(callback, alternateUUIDs) { + var originalItems = this.modelManager.allItems; + + var block = (items) => { + for(var item of items) { + item.setDirty(true); + } + this.writeItemsToLocalStorage(items, false, callback); + } + + if(alternateUUIDs) { + var index = 0; + + let alternateNextItem = () => { + if(index >= originalItems.length) { + // We don't use originalItems as altnerating UUID will have deleted them. + block(this.modelManager.allItems); + return; + } + + var item = originalItems[index]; + this.modelManager.alternateUUIDForItem(item, alternateNextItem); + ++index; + } + + alternateNextItem(); + } else { + block(originalItems); } - this.writeItemsToLocalStorage(items, false, callback); } get syncURL() { @@ -75,12 +111,12 @@ class SyncManager { set syncToken(token) { this._syncToken = token; - localStorage.setItem("syncToken", token); + this.storageManager.setItem("syncToken", token); } get syncToken() { if(!this._syncToken) { - this._syncToken = localStorage.getItem("syncToken"); + this._syncToken = this.storageManager.getItem("syncToken"); } return this._syncToken; } @@ -88,15 +124,15 @@ class SyncManager { set cursorToken(token) { this._cursorToken = token; if(token) { - localStorage.setItem("cursorToken", token); + this.storageManager.setItem("cursorToken", token); } else { - localStorage.removeItem("cursorToken"); + this.storageManager.removeItem("cursorToken"); } } get cursorToken() { if(!this._cursorToken) { - this._cursorToken = localStorage.getItem("cursorToken"); + this._cursorToken = this.storageManager.getItem("cursorToken"); } return this._cursorToken; } @@ -129,7 +165,7 @@ class SyncManager { this.syncStatus.checker = this.$interval(function(){ // check to see if the ongoing sync is taking too long, alert the user var secondsPassed = (new Date() - this.syncStatus.syncStart) / 1000; - var warningThreshold = 5; // seconds + var warningThreshold = 5.0; // seconds if(secondsPassed > warningThreshold) { this.$rootScope.$broadcast("sync:taking-too-long"); this.stopCheckingIfSyncIsTakingTooLong(); @@ -159,7 +195,6 @@ class SyncManager { return; } - // we want to write all dirty items to disk only if the user is offline, or if the sync op fails // if the sync op succeeds, these items will be written to disk by handling the "saved_items" response from the server if(this.authManager.offline()) { @@ -288,7 +323,8 @@ class SyncManager { } handleItemsResponse(responseItems, omitFields) { - EncryptionHelper.decryptMultipleItems(responseItems, this.authManager.keys()); + var keys = this.authManager.keys() || this.passcodeManager.keys(); + EncryptionHelper.decryptMultipleItems(responseItems, keys); var items = this.modelManager.mapResponseItemsToLocalModelsOmittingFields(responseItems, omitFields); return items; } @@ -302,45 +338,64 @@ class SyncManager { var i = 0; var handleNext = function() { - if (i < unsaved.length) { - var mapping = unsaved[i]; - var itemResponse = mapping.item; - EncryptionHelper.decryptMultipleItems([itemResponse], this.authManager.keys()); - var item = this.modelManager.findItem(itemResponse.uuid); - if(!item) { - // could be deleted - return; - } - var error = mapping.error; - if(error.tag == "uuid_conflict") { - // uuid conflicts can occur if a user attempts to import an old data archive with uuids from the old account into a new account - this.modelManager.alternateUUIDForItem(item, handleNext); - } else if(error.tag === "sync_conflict") { - // create a new item with the same contents of this item if the contents differ - itemResponse.uuid = null; // we want a new uuid for the new item - var dup = this.modelManager.createItem(itemResponse); - if(!itemResponse.deleted && JSON.stringify(item.structureParams()) !== JSON.stringify(dup.structureParams())) { - this.modelManager.addItem(dup); - dup.conflict_of = item.uuid; - dup.setDirty(true); - } - } - ++i; - } else { + if(i >= unsaved.length) { + // Handled all items this.sync(null, {additionalFields: ["created_at", "updated_at"]}); + return; } + + var handled = false; + var mapping = unsaved[i]; + var itemResponse = mapping.item; + EncryptionHelper.decryptMultipleItems([itemResponse], this.authManager.keys()); + var item = this.modelManager.findItem(itemResponse.uuid); + + if(!item) { + // Could be deleted + return; + } + + var error = mapping.error; + + if(error.tag === "uuid_conflict") { + // UUID conflicts can occur if a user attempts to + // import an old data archive with uuids from the old account into a new account + handled = true; + this.modelManager.alternateUUIDForItem(item, handleNext); + } + + else if(error.tag === "sync_conflict") { + // Create a new item with the same contents of this item if the contents differ + + // We want a new uuid for the new item. Note that this won't neccessarily adjust references. + itemResponse.uuid = null; + + var dup = this.modelManager.createItem(itemResponse); + if(!itemResponse.deleted && JSON.stringify(item.structureParams()) !== JSON.stringify(dup.structureParams())) { + this.modelManager.addItem(dup); + dup.conflict_of = item.uuid; + dup.setDirty(true); + } + } + + ++i; + + if(!handled) { + handleNext(); + } + }.bind(this); handleNext(); } clearSyncToken() { - localStorage.removeItem("syncToken"); + this.storageManager.removeItem("syncToken"); } destroyLocalData(callback) { - localStorage.clear(); - this.dbManager.clearAllItems(function(){ + this.storageManager.clear(); + this.storageManager.clearAllModels(function(){ if(callback) { this.$timeout(function(){ callback(); diff --git a/app/assets/javascripts/app/services/themeManager.js b/app/assets/javascripts/app/services/themeManager.js index 0bf779033..0e799ba08 100644 --- a/app/assets/javascripts/app/services/themeManager.js +++ b/app/assets/javascripts/app/services/themeManager.js @@ -1,9 +1,10 @@ class ThemeManager { - constructor(modelManager, syncManager, $rootScope) { + constructor(modelManager, syncManager, $rootScope, storageManager) { this.syncManager = syncManager; this.modelManager = modelManager; this.$rootScope = $rootScope; + this.storageManager = storageManager; } get themes() { @@ -16,7 +17,7 @@ class ThemeManager { */ get activeTheme() { - var activeThemeId = localStorage.getItem("activeTheme"); + var activeThemeId = this.storageManager.getItem("activeTheme"); if(!activeThemeId) { return null; } @@ -53,14 +54,14 @@ class ThemeManager { link.media = "screen,print"; link.id = theme.uuid; document.getElementsByTagName("head")[0].appendChild(link); - localStorage.setItem("activeTheme", theme.uuid); + this.storageManager.setItem("activeTheme", theme.uuid); this.currentTheme = theme; this.$rootScope.$broadcast("theme-changed"); } deactivateTheme(theme) { - localStorage.removeItem("activeTheme"); + this.storageManager.removeItem("activeTheme"); var element = document.getElementById(theme.uuid); if(element) { element.disabled = true; @@ -72,7 +73,7 @@ class ThemeManager { } isThemeActive(theme) { - return localStorage.getItem("activeTheme") === theme.uuid; + return this.storageManager.getItem("activeTheme") === theme.uuid; } fileNameFromPath(filePath) { diff --git a/app/assets/stylesheets/_ionicons.scss b/app/assets/stylesheets/_ionicons.scss new file mode 100644 index 000000000..47fd1b15c --- /dev/null +++ b/app/assets/stylesheets/_ionicons.scss @@ -0,0 +1,2966 @@ +@charset "UTF-8"; +/*! + Ionicons, v2.0.0 + Created by Ben Sperry for the Ionic Framework, http://ionicons.com/ + https://twitter.com/benjsperry https://twitter.com/ionicframework + MIT License: https://github.com/driftyco/ionicons + + Android-style icons originally built by Google’s + Material Design Icons: https://github.com/google/material-design-icons + used under CC BY http://creativecommons.org/licenses/by/4.0/ + Modified icons to fit ionicon’s grid from original. +*/ +@font-face { + font-family: "Ionicons"; + src: url("../assets/ionicons.eot?v=2.0.0"); + src: url("../assets/ionicons.eot?v=2.0.0#iefix") format("embedded-opentype"), url("../assets/ionicons.ttf?v=2.0.0") format("truetype"), url("../assets/ionicons.woff?v=2.0.0") format("woff"), url("../assets/ionicons.svg?v=2.0.0#Ionicons") format("svg"); + font-weight: normal; + font-style: normal; +} +.ion, .ionicons, .ion-alert:before, .ion-alert-circled:before, .ion-android-add:before, .ion-android-add-circle:before, .ion-android-alarm-clock:before, .ion-android-alert:before, .ion-android-apps:before, .ion-android-archive:before, .ion-android-arrow-back:before, .ion-android-arrow-down:before, .ion-android-arrow-dropdown:before, .ion-android-arrow-dropdown-circle:before, .ion-android-arrow-dropleft:before, .ion-android-arrow-dropleft-circle:before, .ion-android-arrow-dropright:before, .ion-android-arrow-dropright-circle:before, .ion-android-arrow-dropup:before, .ion-android-arrow-dropup-circle:before, .ion-android-arrow-forward:before, .ion-android-arrow-up:before, .ion-android-attach:before, .ion-android-bar:before, .ion-android-bicycle:before, .ion-android-boat:before, .ion-android-bookmark:before, .ion-android-bulb:before, .ion-android-bus:before, .ion-android-calendar:before, .ion-android-call:before, .ion-android-camera:before, .ion-android-cancel:before, .ion-android-car:before, .ion-android-cart:before, .ion-android-chat:before, .ion-android-checkbox:before, .ion-android-checkbox-blank:before, .ion-android-checkbox-outline:before, .ion-android-checkbox-outline-blank:before, .ion-android-checkmark-circle:before, .ion-android-clipboard:before, .ion-android-close:before, .ion-android-cloud:before, .ion-android-cloud-circle:before, .ion-android-cloud-done:before, .ion-android-cloud-outline:before, .ion-android-color-palette:before, .ion-android-compass:before, .ion-android-contact:before, .ion-android-contacts:before, .ion-android-contract:before, .ion-android-create:before, .ion-android-delete:before, .ion-android-desktop:before, .ion-android-document:before, .ion-android-done:before, .ion-android-done-all:before, .ion-android-download:before, .ion-android-drafts:before, .ion-android-exit:before, .ion-android-expand:before, .ion-android-favorite:before, .ion-android-favorite-outline:before, .ion-android-film:before, .ion-android-folder:before, .ion-android-folder-open:before, .ion-android-funnel:before, .ion-android-globe:before, .ion-android-hand:before, .ion-android-hangout:before, .ion-android-happy:before, .ion-android-home:before, .ion-android-image:before, .ion-android-laptop:before, .ion-android-list:before, .ion-android-locate:before, .ion-android-lock:before, .ion-android-mail:before, .ion-android-map:before, .ion-android-menu:before, .ion-android-microphone:before, .ion-android-microphone-off:before, .ion-android-more-horizontal:before, .ion-android-more-vertical:before, .ion-android-navigate:before, .ion-android-notifications:before, .ion-android-notifications-none:before, .ion-android-notifications-off:before, .ion-android-open:before, .ion-android-options:before, .ion-android-people:before, .ion-android-person:before, .ion-android-person-add:before, .ion-android-phone-landscape:before, .ion-android-phone-portrait:before, .ion-android-pin:before, .ion-android-plane:before, .ion-android-playstore:before, .ion-android-print:before, .ion-android-radio-button-off:before, .ion-android-radio-button-on:before, .ion-android-refresh:before, .ion-android-remove:before, .ion-android-remove-circle:before, .ion-android-restaurant:before, .ion-android-sad:before, .ion-android-search:before, .ion-android-send:before, .ion-android-settings:before, .ion-android-share:before, .ion-android-share-alt:before, .ion-android-star:before, .ion-android-star-half:before, .ion-android-star-outline:before, .ion-android-stopwatch:before, .ion-android-subway:before, .ion-android-sunny:before, .ion-android-sync:before, .ion-android-textsms:before, .ion-android-time:before, .ion-android-train:before, .ion-android-unlock:before, .ion-android-upload:before, .ion-android-volume-down:before, .ion-android-volume-mute:before, .ion-android-volume-off:before, .ion-android-volume-up:before, .ion-android-walk:before, .ion-android-warning:before, .ion-android-watch:before, .ion-android-wifi:before, .ion-aperture:before, .ion-archive:before, .ion-arrow-down-a:before, .ion-arrow-down-b:before, .ion-arrow-down-c:before, .ion-arrow-expand:before, .ion-arrow-graph-down-left:before, .ion-arrow-graph-down-right:before, .ion-arrow-graph-up-left:before, .ion-arrow-graph-up-right:before, .ion-arrow-left-a:before, .ion-arrow-left-b:before, .ion-arrow-left-c:before, .ion-arrow-move:before, .ion-arrow-resize:before, .ion-arrow-return-left:before, .ion-arrow-return-right:before, .ion-arrow-right-a:before, .ion-arrow-right-b:before, .ion-arrow-right-c:before, .ion-arrow-shrink:before, .ion-arrow-swap:before, .ion-arrow-up-a:before, .ion-arrow-up-b:before, .ion-arrow-up-c:before, .ion-asterisk:before, .ion-at:before, .ion-backspace:before, .ion-backspace-outline:before, .ion-bag:before, .ion-battery-charging:before, .ion-battery-empty:before, .ion-battery-full:before, .ion-battery-half:before, .ion-battery-low:before, .ion-beaker:before, .ion-beer:before, .ion-bluetooth:before, .ion-bonfire:before, .ion-bookmark:before, .ion-bowtie:before, .ion-briefcase:before, .ion-bug:before, .ion-calculator:before, .ion-calendar:before, .ion-camera:before, .ion-card:before, .ion-cash:before, .ion-chatbox:before, .ion-chatbox-working:before, .ion-chatboxes:before, .ion-chatbubble:before, .ion-chatbubble-working:before, .ion-chatbubbles:before, .ion-checkmark:before, .ion-checkmark-circled:before, .ion-checkmark-round:before, .ion-chevron-down:before, .ion-chevron-left:before, .ion-chevron-right:before, .ion-chevron-up:before, .ion-clipboard:before, .ion-clock:before, .ion-close:before, .ion-close-circled:before, .ion-close-round:before, .ion-closed-captioning:before, .ion-cloud:before, .ion-code:before, .ion-code-download:before, .ion-code-working:before, .ion-coffee:before, .ion-compass:before, .ion-compose:before, .ion-connection-bars:before, .ion-contrast:before, .ion-crop:before, .ion-cube:before, .ion-disc:before, .ion-document:before, .ion-document-text:before, .ion-drag:before, .ion-earth:before, .ion-easel:before, .ion-edit:before, .ion-egg:before, .ion-eject:before, .ion-email:before, .ion-email-unread:before, .ion-erlenmeyer-flask:before, .ion-erlenmeyer-flask-bubbles:before, .ion-eye:before, .ion-eye-disabled:before, .ion-female:before, .ion-filing:before, .ion-film-marker:before, .ion-fireball:before, .ion-flag:before, .ion-flame:before, .ion-flash:before, .ion-flash-off:before, .ion-folder:before, .ion-fork:before, .ion-fork-repo:before, .ion-forward:before, .ion-funnel:before, .ion-gear-a:before, .ion-gear-b:before, .ion-grid:before, .ion-hammer:before, .ion-happy:before, .ion-happy-outline:before, .ion-headphone:before, .ion-heart:before, .ion-heart-broken:before, .ion-help:before, .ion-help-buoy:before, .ion-help-circled:before, .ion-home:before, .ion-icecream:before, .ion-image:before, .ion-images:before, .ion-information:before, .ion-information-circled:before, .ion-ionic:before, .ion-ios-alarm:before, .ion-ios-alarm-outline:before, .ion-ios-albums:before, .ion-ios-albums-outline:before, .ion-ios-americanfootball:before, .ion-ios-americanfootball-outline:before, .ion-ios-analytics:before, .ion-ios-analytics-outline:before, .ion-ios-arrow-back:before, .ion-ios-arrow-down:before, .ion-ios-arrow-forward:before, .ion-ios-arrow-left:before, .ion-ios-arrow-right:before, .ion-ios-arrow-thin-down:before, .ion-ios-arrow-thin-left:before, .ion-ios-arrow-thin-right:before, .ion-ios-arrow-thin-up:before, .ion-ios-arrow-up:before, .ion-ios-at:before, .ion-ios-at-outline:before, .ion-ios-barcode:before, .ion-ios-barcode-outline:before, .ion-ios-baseball:before, .ion-ios-baseball-outline:before, .ion-ios-basketball:before, .ion-ios-basketball-outline:before, .ion-ios-bell:before, .ion-ios-bell-outline:before, .ion-ios-body:before, .ion-ios-body-outline:before, .ion-ios-bolt:before, .ion-ios-bolt-outline:before, .ion-ios-book:before, .ion-ios-book-outline:before, .ion-ios-bookmarks:before, .ion-ios-bookmarks-outline:before, .ion-ios-box:before, .ion-ios-box-outline:before, .ion-ios-briefcase:before, .ion-ios-briefcase-outline:before, .ion-ios-browsers:before, .ion-ios-browsers-outline:before, .ion-ios-calculator:before, .ion-ios-calculator-outline:before, .ion-ios-calendar:before, .ion-ios-calendar-outline:before, .ion-ios-camera:before, .ion-ios-camera-outline:before, .ion-ios-cart:before, .ion-ios-cart-outline:before, .ion-ios-chatboxes:before, .ion-ios-chatboxes-outline:before, .ion-ios-chatbubble:before, .ion-ios-chatbubble-outline:before, .ion-ios-checkmark:before, .ion-ios-checkmark-empty:before, .ion-ios-checkmark-outline:before, .ion-ios-circle-filled:before, .ion-ios-circle-outline:before, .ion-ios-clock:before, .ion-ios-clock-outline:before, .ion-ios-close:before, .ion-ios-close-empty:before, .ion-ios-close-outline:before, .ion-ios-cloud:before, .ion-ios-cloud-download:before, .ion-ios-cloud-download-outline:before, .ion-ios-cloud-outline:before, .ion-ios-cloud-upload:before, .ion-ios-cloud-upload-outline:before, .ion-ios-cloudy:before, .ion-ios-cloudy-night:before, .ion-ios-cloudy-night-outline:before, .ion-ios-cloudy-outline:before, .ion-ios-cog:before, .ion-ios-cog-outline:before, .ion-ios-color-filter:before, .ion-ios-color-filter-outline:before, .ion-ios-color-wand:before, .ion-ios-color-wand-outline:before, .ion-ios-compose:before, .ion-ios-compose-outline:before, .ion-ios-contact:before, .ion-ios-contact-outline:before, .ion-ios-copy:before, .ion-ios-copy-outline:before, .ion-ios-crop:before, .ion-ios-crop-strong:before, .ion-ios-download:before, .ion-ios-download-outline:before, .ion-ios-drag:before, .ion-ios-email:before, .ion-ios-email-outline:before, .ion-ios-eye:before, .ion-ios-eye-outline:before, .ion-ios-fastforward:before, .ion-ios-fastforward-outline:before, .ion-ios-filing:before, .ion-ios-filing-outline:before, .ion-ios-film:before, .ion-ios-film-outline:before, .ion-ios-flag:before, .ion-ios-flag-outline:before, .ion-ios-flame:before, .ion-ios-flame-outline:before, .ion-ios-flask:before, .ion-ios-flask-outline:before, .ion-ios-flower:before, .ion-ios-flower-outline:before, .ion-ios-folder:before, .ion-ios-folder-outline:before, .ion-ios-football:before, .ion-ios-football-outline:before, .ion-ios-game-controller-a:before, .ion-ios-game-controller-a-outline:before, .ion-ios-game-controller-b:before, .ion-ios-game-controller-b-outline:before, .ion-ios-gear:before, .ion-ios-gear-outline:before, .ion-ios-glasses:before, .ion-ios-glasses-outline:before, .ion-ios-grid-view:before, .ion-ios-grid-view-outline:before, .ion-ios-heart:before, .ion-ios-heart-outline:before, .ion-ios-help:before, .ion-ios-help-empty:before, .ion-ios-help-outline:before, .ion-ios-home:before, .ion-ios-home-outline:before, .ion-ios-infinite:before, .ion-ios-infinite-outline:before, .ion-ios-information:before, .ion-ios-information-empty:before, .ion-ios-information-outline:before, .ion-ios-ionic-outline:before, .ion-ios-keypad:before, .ion-ios-keypad-outline:before, .ion-ios-lightbulb:before, .ion-ios-lightbulb-outline:before, .ion-ios-list:before, .ion-ios-list-outline:before, .ion-ios-location:before, .ion-ios-location-outline:before, .ion-ios-locked:before, .ion-ios-locked-outline:before, .ion-ios-loop:before, .ion-ios-loop-strong:before, .ion-ios-medical:before, .ion-ios-medical-outline:before, .ion-ios-medkit:before, .ion-ios-medkit-outline:before, .ion-ios-mic:before, .ion-ios-mic-off:before, .ion-ios-mic-outline:before, .ion-ios-minus:before, .ion-ios-minus-empty:before, .ion-ios-minus-outline:before, .ion-ios-monitor:before, .ion-ios-monitor-outline:before, .ion-ios-moon:before, .ion-ios-moon-outline:before, .ion-ios-more:before, .ion-ios-more-outline:before, .ion-ios-musical-note:before, .ion-ios-musical-notes:before, .ion-ios-navigate:before, .ion-ios-navigate-outline:before, .ion-ios-nutrition:before, .ion-ios-nutrition-outline:before, .ion-ios-paper:before, .ion-ios-paper-outline:before, .ion-ios-paperplane:before, .ion-ios-paperplane-outline:before, .ion-ios-partlysunny:before, .ion-ios-partlysunny-outline:before, .ion-ios-pause:before, .ion-ios-pause-outline:before, .ion-ios-paw:before, .ion-ios-paw-outline:before, .ion-ios-people:before, .ion-ios-people-outline:before, .ion-ios-person:before, .ion-ios-person-outline:before, .ion-ios-personadd:before, .ion-ios-personadd-outline:before, .ion-ios-photos:before, .ion-ios-photos-outline:before, .ion-ios-pie:before, .ion-ios-pie-outline:before, .ion-ios-pint:before, .ion-ios-pint-outline:before, .ion-ios-play:before, .ion-ios-play-outline:before, .ion-ios-plus:before, .ion-ios-plus-empty:before, .ion-ios-plus-outline:before, .ion-ios-pricetag:before, .ion-ios-pricetag-outline:before, .ion-ios-pricetags:before, .ion-ios-pricetags-outline:before, .ion-ios-printer:before, .ion-ios-printer-outline:before, .ion-ios-pulse:before, .ion-ios-pulse-strong:before, .ion-ios-rainy:before, .ion-ios-rainy-outline:before, .ion-ios-recording:before, .ion-ios-recording-outline:before, .ion-ios-redo:before, .ion-ios-redo-outline:before, .ion-ios-refresh:before, .ion-ios-refresh-empty:before, .ion-ios-refresh-outline:before, .ion-ios-reload:before, .ion-ios-reverse-camera:before, .ion-ios-reverse-camera-outline:before, .ion-ios-rewind:before, .ion-ios-rewind-outline:before, .ion-ios-rose:before, .ion-ios-rose-outline:before, .ion-ios-search:before, .ion-ios-search-strong:before, .ion-ios-settings:before, .ion-ios-settings-strong:before, .ion-ios-shuffle:before, .ion-ios-shuffle-strong:before, .ion-ios-skipbackward:before, .ion-ios-skipbackward-outline:before, .ion-ios-skipforward:before, .ion-ios-skipforward-outline:before, .ion-ios-snowy:before, .ion-ios-speedometer:before, .ion-ios-speedometer-outline:before, .ion-ios-star:before, .ion-ios-star-half:before, .ion-ios-star-outline:before, .ion-ios-stopwatch:before, .ion-ios-stopwatch-outline:before, .ion-ios-sunny:before, .ion-ios-sunny-outline:before, .ion-ios-telephone:before, .ion-ios-telephone-outline:before, .ion-ios-tennisball:before, .ion-ios-tennisball-outline:before, .ion-ios-thunderstorm:before, .ion-ios-thunderstorm-outline:before, .ion-ios-time:before, .ion-ios-time-outline:before, .ion-ios-timer:before, .ion-ios-timer-outline:before, .ion-ios-toggle:before, .ion-ios-toggle-outline:before, .ion-ios-trash:before, .ion-ios-trash-outline:before, .ion-ios-undo:before, .ion-ios-undo-outline:before, .ion-ios-unlocked:before, .ion-ios-unlocked-outline:before, .ion-ios-upload:before, .ion-ios-upload-outline:before, .ion-ios-videocam:before, .ion-ios-videocam-outline:before, .ion-ios-volume-high:before, .ion-ios-volume-low:before, .ion-ios-wineglass:before, .ion-ios-wineglass-outline:before, .ion-ios-world:before, .ion-ios-world-outline:before, .ion-ipad:before, .ion-iphone:before, .ion-ipod:before, .ion-jet:before, .ion-key:before, .ion-knife:before, .ion-laptop:before, .ion-leaf:before, .ion-levels:before, .ion-lightbulb:before, .ion-link:before, .ion-load-a:before, .ion-load-b:before, .ion-load-c:before, .ion-load-d:before, .ion-location:before, .ion-lock-combination:before, .ion-locked:before, .ion-log-in:before, .ion-log-out:before, .ion-loop:before, .ion-magnet:before, .ion-male:before, .ion-man:before, .ion-map:before, .ion-medkit:before, .ion-merge:before, .ion-mic-a:before, .ion-mic-b:before, .ion-mic-c:before, .ion-minus:before, .ion-minus-circled:before, .ion-minus-round:before, .ion-model-s:before, .ion-monitor:before, .ion-more:before, .ion-mouse:before, .ion-music-note:before, .ion-navicon:before, .ion-navicon-round:before, .ion-navigate:before, .ion-network:before, .ion-no-smoking:before, .ion-nuclear:before, .ion-outlet:before, .ion-paintbrush:before, .ion-paintbucket:before, .ion-paper-airplane:before, .ion-paperclip:before, .ion-pause:before, .ion-person:before, .ion-person-add:before, .ion-person-stalker:before, .ion-pie-graph:before, .ion-pin:before, .ion-pinpoint:before, .ion-pizza:before, .ion-plane:before, .ion-planet:before, .ion-play:before, .ion-playstation:before, .ion-plus:before, .ion-plus-circled:before, .ion-plus-round:before, .ion-podium:before, .ion-pound:before, .ion-power:before, .ion-pricetag:before, .ion-pricetags:before, .ion-printer:before, .ion-pull-request:before, .ion-qr-scanner:before, .ion-quote:before, .ion-radio-waves:before, .ion-record:before, .ion-refresh:before, .ion-reply:before, .ion-reply-all:before, .ion-ribbon-a:before, .ion-ribbon-b:before, .ion-sad:before, .ion-sad-outline:before, .ion-scissors:before, .ion-search:before, .ion-settings:before, .ion-share:before, .ion-shuffle:before, .ion-skip-backward:before, .ion-skip-forward:before, .ion-social-android:before, .ion-social-android-outline:before, .ion-social-angular:before, .ion-social-angular-outline:before, .ion-social-apple:before, .ion-social-apple-outline:before, .ion-social-bitcoin:before, .ion-social-bitcoin-outline:before, .ion-social-buffer:before, .ion-social-buffer-outline:before, .ion-social-chrome:before, .ion-social-chrome-outline:before, .ion-social-codepen:before, .ion-social-codepen-outline:before, .ion-social-css3:before, .ion-social-css3-outline:before, .ion-social-designernews:before, .ion-social-designernews-outline:before, .ion-social-dribbble:before, .ion-social-dribbble-outline:before, .ion-social-dropbox:before, .ion-social-dropbox-outline:before, .ion-social-euro:before, .ion-social-euro-outline:before, .ion-social-facebook:before, .ion-social-facebook-outline:before, .ion-social-foursquare:before, .ion-social-foursquare-outline:before, .ion-social-freebsd-devil:before, .ion-social-github:before, .ion-social-github-outline:before, .ion-social-google:before, .ion-social-google-outline:before, .ion-social-googleplus:before, .ion-social-googleplus-outline:before, .ion-social-hackernews:before, .ion-social-hackernews-outline:before, .ion-social-html5:before, .ion-social-html5-outline:before, .ion-social-instagram:before, .ion-social-instagram-outline:before, .ion-social-javascript:before, .ion-social-javascript-outline:before, .ion-social-linkedin:before, .ion-social-linkedin-outline:before, .ion-social-markdown:before, .ion-social-nodejs:before, .ion-social-octocat:before, .ion-social-pinterest:before, .ion-social-pinterest-outline:before, .ion-social-python:before, .ion-social-reddit:before, .ion-social-reddit-outline:before, .ion-social-rss:before, .ion-social-rss-outline:before, .ion-social-sass:before, .ion-social-skype:before, .ion-social-skype-outline:before, .ion-social-snapchat:before, .ion-social-snapchat-outline:before, .ion-social-tumblr:before, .ion-social-tumblr-outline:before, .ion-social-tux:before, .ion-social-twitch:before, .ion-social-twitch-outline:before, .ion-social-twitter:before, .ion-social-twitter-outline:before, .ion-social-usd:before, .ion-social-usd-outline:before, .ion-social-vimeo:before, .ion-social-vimeo-outline:before, .ion-social-whatsapp:before, .ion-social-whatsapp-outline:before, .ion-social-windows:before, .ion-social-windows-outline:before, .ion-social-wordpress:before, .ion-social-wordpress-outline:before, .ion-social-yahoo:before, .ion-social-yahoo-outline:before, .ion-social-yen:before, .ion-social-yen-outline:before, .ion-social-youtube:before, .ion-social-youtube-outline:before, .ion-soup-can:before, .ion-soup-can-outline:before, .ion-speakerphone:before, .ion-speedometer:before, .ion-spoon:before, .ion-star:before, .ion-stats-bars:before, .ion-steam:before, .ion-stop:before, .ion-thermometer:before, .ion-thumbsdown:before, .ion-thumbsup:before, .ion-toggle:before, .ion-toggle-filled:before, .ion-transgender:before, .ion-trash-a:before, .ion-trash-b:before, .ion-trophy:before, .ion-tshirt:before, .ion-tshirt-outline:before, .ion-umbrella:before, .ion-university:before, .ion-unlocked:before, .ion-upload:before, .ion-usb:before, .ion-videocamera:before, .ion-volume-high:before, .ion-volume-low:before, .ion-volume-medium:before, .ion-volume-mute:before, .ion-wand:before, .ion-waterdrop:before, .ion-wifi:before, .ion-wineglass:before, .ion-woman:before, .ion-wrench:before, .ion-xbox:before { + display: inline-block; + font-family: "Ionicons"; + speak: none; + font-style: normal; + font-weight: normal; + font-variant: normal; + text-transform: none; + text-rendering: auto; + line-height: 1; + -webkit-font-smoothing: antialiased; + -moz-osx-font-smoothing: grayscale; +} + +.ion-alert:before { + content: "\f101"; +} + +.ion-alert-circled:before { + content: "\f100"; +} + +.ion-android-add:before { + content: "\f2c7"; +} + +.ion-android-add-circle:before { + content: "\f359"; +} + +.ion-android-alarm-clock:before { + content: "\f35a"; +} + +.ion-android-alert:before { + content: "\f35b"; +} + +.ion-android-apps:before { + content: "\f35c"; +} + +.ion-android-archive:before { + content: "\f2c9"; +} + +.ion-android-arrow-back:before { + content: "\f2ca"; +} + +.ion-android-arrow-down:before { + content: "\f35d"; +} + +.ion-android-arrow-dropdown:before { + content: "\f35f"; +} + +.ion-android-arrow-dropdown-circle:before { + content: "\f35e"; +} + +.ion-android-arrow-dropleft:before { + content: "\f361"; +} + +.ion-android-arrow-dropleft-circle:before { + content: "\f360"; +} + +.ion-android-arrow-dropright:before { + content: "\f363"; +} + +.ion-android-arrow-dropright-circle:before { + content: "\f362"; +} + +.ion-android-arrow-dropup:before { + content: "\f365"; +} + +.ion-android-arrow-dropup-circle:before { + content: "\f364"; +} + +.ion-android-arrow-forward:before { + content: "\f30f"; +} + +.ion-android-arrow-up:before { + content: "\f366"; +} + +.ion-android-attach:before { + content: "\f367"; +} + +.ion-android-bar:before { + content: "\f368"; +} + +.ion-android-bicycle:before { + content: "\f369"; +} + +.ion-android-boat:before { + content: "\f36a"; +} + +.ion-android-bookmark:before { + content: "\f36b"; +} + +.ion-android-bulb:before { + content: "\f36c"; +} + +.ion-android-bus:before { + content: "\f36d"; +} + +.ion-android-calendar:before { + content: "\f2d1"; +} + +.ion-android-call:before { + content: "\f2d2"; +} + +.ion-android-camera:before { + content: "\f2d3"; +} + +.ion-android-cancel:before { + content: "\f36e"; +} + +.ion-android-car:before { + content: "\f36f"; +} + +.ion-android-cart:before { + content: "\f370"; +} + +.ion-android-chat:before { + content: "\f2d4"; +} + +.ion-android-checkbox:before { + content: "\f374"; +} + +.ion-android-checkbox-blank:before { + content: "\f371"; +} + +.ion-android-checkbox-outline:before { + content: "\f373"; +} + +.ion-android-checkbox-outline-blank:before { + content: "\f372"; +} + +.ion-android-checkmark-circle:before { + content: "\f375"; +} + +.ion-android-clipboard:before { + content: "\f376"; +} + +.ion-android-close:before { + content: "\f2d7"; +} + +.ion-android-cloud:before { + content: "\f37a"; +} + +.ion-android-cloud-circle:before { + content: "\f377"; +} + +.ion-android-cloud-done:before { + content: "\f378"; +} + +.ion-android-cloud-outline:before { + content: "\f379"; +} + +.ion-android-color-palette:before { + content: "\f37b"; +} + +.ion-android-compass:before { + content: "\f37c"; +} + +.ion-android-contact:before { + content: "\f2d8"; +} + +.ion-android-contacts:before { + content: "\f2d9"; +} + +.ion-android-contract:before { + content: "\f37d"; +} + +.ion-android-create:before { + content: "\f37e"; +} + +.ion-android-delete:before { + content: "\f37f"; +} + +.ion-android-desktop:before { + content: "\f380"; +} + +.ion-android-document:before { + content: "\f381"; +} + +.ion-android-done:before { + content: "\f383"; +} + +.ion-android-done-all:before { + content: "\f382"; +} + +.ion-android-download:before { + content: "\f2dd"; +} + +.ion-android-drafts:before { + content: "\f384"; +} + +.ion-android-exit:before { + content: "\f385"; +} + +.ion-android-expand:before { + content: "\f386"; +} + +.ion-android-favorite:before { + content: "\f388"; +} + +.ion-android-favorite-outline:before { + content: "\f387"; +} + +.ion-android-film:before { + content: "\f389"; +} + +.ion-android-folder:before { + content: "\f2e0"; +} + +.ion-android-folder-open:before { + content: "\f38a"; +} + +.ion-android-funnel:before { + content: "\f38b"; +} + +.ion-android-globe:before { + content: "\f38c"; +} + +.ion-android-hand:before { + content: "\f2e3"; +} + +.ion-android-hangout:before { + content: "\f38d"; +} + +.ion-android-happy:before { + content: "\f38e"; +} + +.ion-android-home:before { + content: "\f38f"; +} + +.ion-android-image:before { + content: "\f2e4"; +} + +.ion-android-laptop:before { + content: "\f390"; +} + +.ion-android-list:before { + content: "\f391"; +} + +.ion-android-locate:before { + content: "\f2e9"; +} + +.ion-android-lock:before { + content: "\f392"; +} + +.ion-android-mail:before { + content: "\f2eb"; +} + +.ion-android-map:before { + content: "\f393"; +} + +.ion-android-menu:before { + content: "\f394"; +} + +.ion-android-microphone:before { + content: "\f2ec"; +} + +.ion-android-microphone-off:before { + content: "\f395"; +} + +.ion-android-more-horizontal:before { + content: "\f396"; +} + +.ion-android-more-vertical:before { + content: "\f397"; +} + +.ion-android-navigate:before { + content: "\f398"; +} + +.ion-android-notifications:before { + content: "\f39b"; +} + +.ion-android-notifications-none:before { + content: "\f399"; +} + +.ion-android-notifications-off:before { + content: "\f39a"; +} + +.ion-android-open:before { + content: "\f39c"; +} + +.ion-android-options:before { + content: "\f39d"; +} + +.ion-android-people:before { + content: "\f39e"; +} + +.ion-android-person:before { + content: "\f3a0"; +} + +.ion-android-person-add:before { + content: "\f39f"; +} + +.ion-android-phone-landscape:before { + content: "\f3a1"; +} + +.ion-android-phone-portrait:before { + content: "\f3a2"; +} + +.ion-android-pin:before { + content: "\f3a3"; +} + +.ion-android-plane:before { + content: "\f3a4"; +} + +.ion-android-playstore:before { + content: "\f2f0"; +} + +.ion-android-print:before { + content: "\f3a5"; +} + +.ion-android-radio-button-off:before { + content: "\f3a6"; +} + +.ion-android-radio-button-on:before { + content: "\f3a7"; +} + +.ion-android-refresh:before { + content: "\f3a8"; +} + +.ion-android-remove:before { + content: "\f2f4"; +} + +.ion-android-remove-circle:before { + content: "\f3a9"; +} + +.ion-android-restaurant:before { + content: "\f3aa"; +} + +.ion-android-sad:before { + content: "\f3ab"; +} + +.ion-android-search:before { + content: "\f2f5"; +} + +.ion-android-send:before { + content: "\f2f6"; +} + +.ion-android-settings:before { + content: "\f2f7"; +} + +.ion-android-share:before { + content: "\f2f8"; +} + +.ion-android-share-alt:before { + content: "\f3ac"; +} + +.ion-android-star:before { + content: "\f2fc"; +} + +.ion-android-star-half:before { + content: "\f3ad"; +} + +.ion-android-star-outline:before { + content: "\f3ae"; +} + +.ion-android-stopwatch:before { + content: "\f2fd"; +} + +.ion-android-subway:before { + content: "\f3af"; +} + +.ion-android-sunny:before { + content: "\f3b0"; +} + +.ion-android-sync:before { + content: "\f3b1"; +} + +.ion-android-textsms:before { + content: "\f3b2"; +} + +.ion-android-time:before { + content: "\f3b3"; +} + +.ion-android-train:before { + content: "\f3b4"; +} + +.ion-android-unlock:before { + content: "\f3b5"; +} + +.ion-android-upload:before { + content: "\f3b6"; +} + +.ion-android-volume-down:before { + content: "\f3b7"; +} + +.ion-android-volume-mute:before { + content: "\f3b8"; +} + +.ion-android-volume-off:before { + content: "\f3b9"; +} + +.ion-android-volume-up:before { + content: "\f3ba"; +} + +.ion-android-walk:before { + content: "\f3bb"; +} + +.ion-android-warning:before { + content: "\f3bc"; +} + +.ion-android-watch:before { + content: "\f3bd"; +} + +.ion-android-wifi:before { + content: "\f305"; +} + +.ion-aperture:before { + content: "\f313"; +} + +.ion-archive:before { + content: "\f102"; +} + +.ion-arrow-down-a:before { + content: "\f103"; +} + +.ion-arrow-down-b:before { + content: "\f104"; +} + +.ion-arrow-down-c:before { + content: "\f105"; +} + +.ion-arrow-expand:before { + content: "\f25e"; +} + +.ion-arrow-graph-down-left:before { + content: "\f25f"; +} + +.ion-arrow-graph-down-right:before { + content: "\f260"; +} + +.ion-arrow-graph-up-left:before { + content: "\f261"; +} + +.ion-arrow-graph-up-right:before { + content: "\f262"; +} + +.ion-arrow-left-a:before { + content: "\f106"; +} + +.ion-arrow-left-b:before { + content: "\f107"; +} + +.ion-arrow-left-c:before { + content: "\f108"; +} + +.ion-arrow-move:before { + content: "\f263"; +} + +.ion-arrow-resize:before { + content: "\f264"; +} + +.ion-arrow-return-left:before { + content: "\f265"; +} + +.ion-arrow-return-right:before { + content: "\f266"; +} + +.ion-arrow-right-a:before { + content: "\f109"; +} + +.ion-arrow-right-b:before { + content: "\f10a"; +} + +.ion-arrow-right-c:before { + content: "\f10b"; +} + +.ion-arrow-shrink:before { + content: "\f267"; +} + +.ion-arrow-swap:before { + content: "\f268"; +} + +.ion-arrow-up-a:before { + content: "\f10c"; +} + +.ion-arrow-up-b:before { + content: "\f10d"; +} + +.ion-arrow-up-c:before { + content: "\f10e"; +} + +.ion-asterisk:before { + content: "\f314"; +} + +.ion-at:before { + content: "\f10f"; +} + +.ion-backspace:before { + content: "\f3bf"; +} + +.ion-backspace-outline:before { + content: "\f3be"; +} + +.ion-bag:before { + content: "\f110"; +} + +.ion-battery-charging:before { + content: "\f111"; +} + +.ion-battery-empty:before { + content: "\f112"; +} + +.ion-battery-full:before { + content: "\f113"; +} + +.ion-battery-half:before { + content: "\f114"; +} + +.ion-battery-low:before { + content: "\f115"; +} + +.ion-beaker:before { + content: "\f269"; +} + +.ion-beer:before { + content: "\f26a"; +} + +.ion-bluetooth:before { + content: "\f116"; +} + +.ion-bonfire:before { + content: "\f315"; +} + +.ion-bookmark:before { + content: "\f26b"; +} + +.ion-bowtie:before { + content: "\f3c0"; +} + +.ion-briefcase:before { + content: "\f26c"; +} + +.ion-bug:before { + content: "\f2be"; +} + +.ion-calculator:before { + content: "\f26d"; +} + +.ion-calendar:before { + content: "\f117"; +} + +.ion-camera:before { + content: "\f118"; +} + +.ion-card:before { + content: "\f119"; +} + +.ion-cash:before { + content: "\f316"; +} + +.ion-chatbox:before { + content: "\f11b"; +} + +.ion-chatbox-working:before { + content: "\f11a"; +} + +.ion-chatboxes:before { + content: "\f11c"; +} + +.ion-chatbubble:before { + content: "\f11e"; +} + +.ion-chatbubble-working:before { + content: "\f11d"; +} + +.ion-chatbubbles:before { + content: "\f11f"; +} + +.ion-checkmark:before { + content: "\f122"; +} + +.ion-checkmark-circled:before { + content: "\f120"; +} + +.ion-checkmark-round:before { + content: "\f121"; +} + +.ion-chevron-down:before { + content: "\f123"; +} + +.ion-chevron-left:before { + content: "\f124"; +} + +.ion-chevron-right:before { + content: "\f125"; +} + +.ion-chevron-up:before { + content: "\f126"; +} + +.ion-clipboard:before { + content: "\f127"; +} + +.ion-clock:before { + content: "\f26e"; +} + +.ion-close:before { + content: "\f12a"; +} + +.ion-close-circled:before { + content: "\f128"; +} + +.ion-close-round:before { + content: "\f129"; +} + +.ion-closed-captioning:before { + content: "\f317"; +} + +.ion-cloud:before { + content: "\f12b"; +} + +.ion-code:before { + content: "\f271"; +} + +.ion-code-download:before { + content: "\f26f"; +} + +.ion-code-working:before { + content: "\f270"; +} + +.ion-coffee:before { + content: "\f272"; +} + +.ion-compass:before { + content: "\f273"; +} + +.ion-compose:before { + content: "\f12c"; +} + +.ion-connection-bars:before { + content: "\f274"; +} + +.ion-contrast:before { + content: "\f275"; +} + +.ion-crop:before { + content: "\f3c1"; +} + +.ion-cube:before { + content: "\f318"; +} + +.ion-disc:before { + content: "\f12d"; +} + +.ion-document:before { + content: "\f12f"; +} + +.ion-document-text:before { + content: "\f12e"; +} + +.ion-drag:before { + content: "\f130"; +} + +.ion-earth:before { + content: "\f276"; +} + +.ion-easel:before { + content: "\f3c2"; +} + +.ion-edit:before { + content: "\f2bf"; +} + +.ion-egg:before { + content: "\f277"; +} + +.ion-eject:before { + content: "\f131"; +} + +.ion-email:before { + content: "\f132"; +} + +.ion-email-unread:before { + content: "\f3c3"; +} + +.ion-erlenmeyer-flask:before { + content: "\f3c5"; +} + +.ion-erlenmeyer-flask-bubbles:before { + content: "\f3c4"; +} + +.ion-eye:before { + content: "\f133"; +} + +.ion-eye-disabled:before { + content: "\f306"; +} + +.ion-female:before { + content: "\f278"; +} + +.ion-filing:before { + content: "\f134"; +} + +.ion-film-marker:before { + content: "\f135"; +} + +.ion-fireball:before { + content: "\f319"; +} + +.ion-flag:before { + content: "\f279"; +} + +.ion-flame:before { + content: "\f31a"; +} + +.ion-flash:before { + content: "\f137"; +} + +.ion-flash-off:before { + content: "\f136"; +} + +.ion-folder:before { + content: "\f139"; +} + +.ion-fork:before { + content: "\f27a"; +} + +.ion-fork-repo:before { + content: "\f2c0"; +} + +.ion-forward:before { + content: "\f13a"; +} + +.ion-funnel:before { + content: "\f31b"; +} + +.ion-gear-a:before { + content: "\f13d"; +} + +.ion-gear-b:before { + content: "\f13e"; +} + +.ion-grid:before { + content: "\f13f"; +} + +.ion-hammer:before { + content: "\f27b"; +} + +.ion-happy:before { + content: "\f31c"; +} + +.ion-happy-outline:before { + content: "\f3c6"; +} + +.ion-headphone:before { + content: "\f140"; +} + +.ion-heart:before { + content: "\f141"; +} + +.ion-heart-broken:before { + content: "\f31d"; +} + +.ion-help:before { + content: "\f143"; +} + +.ion-help-buoy:before { + content: "\f27c"; +} + +.ion-help-circled:before { + content: "\f142"; +} + +.ion-home:before { + content: "\f144"; +} + +.ion-icecream:before { + content: "\f27d"; +} + +.ion-image:before { + content: "\f147"; +} + +.ion-images:before { + content: "\f148"; +} + +.ion-information:before { + content: "\f14a"; +} + +.ion-information-circled:before { + content: "\f149"; +} + +.ion-ionic:before { + content: "\f14b"; +} + +.ion-ios-alarm:before { + content: "\f3c8"; +} + +.ion-ios-alarm-outline:before { + content: "\f3c7"; +} + +.ion-ios-albums:before { + content: "\f3ca"; +} + +.ion-ios-albums-outline:before { + content: "\f3c9"; +} + +.ion-ios-americanfootball:before { + content: "\f3cc"; +} + +.ion-ios-americanfootball-outline:before { + content: "\f3cb"; +} + +.ion-ios-analytics:before { + content: "\f3ce"; +} + +.ion-ios-analytics-outline:before { + content: "\f3cd"; +} + +.ion-ios-arrow-back:before { + content: "\f3cf"; +} + +.ion-ios-arrow-down:before { + content: "\f3d0"; +} + +.ion-ios-arrow-forward:before { + content: "\f3d1"; +} + +.ion-ios-arrow-left:before { + content: "\f3d2"; +} + +.ion-ios-arrow-right:before { + content: "\f3d3"; +} + +.ion-ios-arrow-thin-down:before { + content: "\f3d4"; +} + +.ion-ios-arrow-thin-left:before { + content: "\f3d5"; +} + +.ion-ios-arrow-thin-right:before { + content: "\f3d6"; +} + +.ion-ios-arrow-thin-up:before { + content: "\f3d7"; +} + +.ion-ios-arrow-up:before { + content: "\f3d8"; +} + +.ion-ios-at:before { + content: "\f3da"; +} + +.ion-ios-at-outline:before { + content: "\f3d9"; +} + +.ion-ios-barcode:before { + content: "\f3dc"; +} + +.ion-ios-barcode-outline:before { + content: "\f3db"; +} + +.ion-ios-baseball:before { + content: "\f3de"; +} + +.ion-ios-baseball-outline:before { + content: "\f3dd"; +} + +.ion-ios-basketball:before { + content: "\f3e0"; +} + +.ion-ios-basketball-outline:before { + content: "\f3df"; +} + +.ion-ios-bell:before { + content: "\f3e2"; +} + +.ion-ios-bell-outline:before { + content: "\f3e1"; +} + +.ion-ios-body:before { + content: "\f3e4"; +} + +.ion-ios-body-outline:before { + content: "\f3e3"; +} + +.ion-ios-bolt:before { + content: "\f3e6"; +} + +.ion-ios-bolt-outline:before { + content: "\f3e5"; +} + +.ion-ios-book:before { + content: "\f3e8"; +} + +.ion-ios-book-outline:before { + content: "\f3e7"; +} + +.ion-ios-bookmarks:before { + content: "\f3ea"; +} + +.ion-ios-bookmarks-outline:before { + content: "\f3e9"; +} + +.ion-ios-box:before { + content: "\f3ec"; +} + +.ion-ios-box-outline:before { + content: "\f3eb"; +} + +.ion-ios-briefcase:before { + content: "\f3ee"; +} + +.ion-ios-briefcase-outline:before { + content: "\f3ed"; +} + +.ion-ios-browsers:before { + content: "\f3f0"; +} + +.ion-ios-browsers-outline:before { + content: "\f3ef"; +} + +.ion-ios-calculator:before { + content: "\f3f2"; +} + +.ion-ios-calculator-outline:before { + content: "\f3f1"; +} + +.ion-ios-calendar:before { + content: "\f3f4"; +} + +.ion-ios-calendar-outline:before { + content: "\f3f3"; +} + +.ion-ios-camera:before { + content: "\f3f6"; +} + +.ion-ios-camera-outline:before { + content: "\f3f5"; +} + +.ion-ios-cart:before { + content: "\f3f8"; +} + +.ion-ios-cart-outline:before { + content: "\f3f7"; +} + +.ion-ios-chatboxes:before { + content: "\f3fa"; +} + +.ion-ios-chatboxes-outline:before { + content: "\f3f9"; +} + +.ion-ios-chatbubble:before { + content: "\f3fc"; +} + +.ion-ios-chatbubble-outline:before { + content: "\f3fb"; +} + +.ion-ios-checkmark:before { + content: "\f3ff"; +} + +.ion-ios-checkmark-empty:before { + content: "\f3fd"; +} + +.ion-ios-checkmark-outline:before { + content: "\f3fe"; +} + +.ion-ios-circle-filled:before { + content: "\f400"; +} + +.ion-ios-circle-outline:before { + content: "\f401"; +} + +.ion-ios-clock:before { + content: "\f403"; +} + +.ion-ios-clock-outline:before { + content: "\f402"; +} + +.ion-ios-close:before { + content: "\f406"; +} + +.ion-ios-close-empty:before { + content: "\f404"; +} + +.ion-ios-close-outline:before { + content: "\f405"; +} + +.ion-ios-cloud:before { + content: "\f40c"; +} + +.ion-ios-cloud-download:before { + content: "\f408"; +} + +.ion-ios-cloud-download-outline:before { + content: "\f407"; +} + +.ion-ios-cloud-outline:before { + content: "\f409"; +} + +.ion-ios-cloud-upload:before { + content: "\f40b"; +} + +.ion-ios-cloud-upload-outline:before { + content: "\f40a"; +} + +.ion-ios-cloudy:before { + content: "\f410"; +} + +.ion-ios-cloudy-night:before { + content: "\f40e"; +} + +.ion-ios-cloudy-night-outline:before { + content: "\f40d"; +} + +.ion-ios-cloudy-outline:before { + content: "\f40f"; +} + +.ion-ios-cog:before { + content: "\f412"; +} + +.ion-ios-cog-outline:before { + content: "\f411"; +} + +.ion-ios-color-filter:before { + content: "\f414"; +} + +.ion-ios-color-filter-outline:before { + content: "\f413"; +} + +.ion-ios-color-wand:before { + content: "\f416"; +} + +.ion-ios-color-wand-outline:before { + content: "\f415"; +} + +.ion-ios-compose:before { + content: "\f418"; +} + +.ion-ios-compose-outline:before { + content: "\f417"; +} + +.ion-ios-contact:before { + content: "\f41a"; +} + +.ion-ios-contact-outline:before { + content: "\f419"; +} + +.ion-ios-copy:before { + content: "\f41c"; +} + +.ion-ios-copy-outline:before { + content: "\f41b"; +} + +.ion-ios-crop:before { + content: "\f41e"; +} + +.ion-ios-crop-strong:before { + content: "\f41d"; +} + +.ion-ios-download:before { + content: "\f420"; +} + +.ion-ios-download-outline:before { + content: "\f41f"; +} + +.ion-ios-drag:before { + content: "\f421"; +} + +.ion-ios-email:before { + content: "\f423"; +} + +.ion-ios-email-outline:before { + content: "\f422"; +} + +.ion-ios-eye:before { + content: "\f425"; +} + +.ion-ios-eye-outline:before { + content: "\f424"; +} + +.ion-ios-fastforward:before { + content: "\f427"; +} + +.ion-ios-fastforward-outline:before { + content: "\f426"; +} + +.ion-ios-filing:before { + content: "\f429"; +} + +.ion-ios-filing-outline:before { + content: "\f428"; +} + +.ion-ios-film:before { + content: "\f42b"; +} + +.ion-ios-film-outline:before { + content: "\f42a"; +} + +.ion-ios-flag:before { + content: "\f42d"; +} + +.ion-ios-flag-outline:before { + content: "\f42c"; +} + +.ion-ios-flame:before { + content: "\f42f"; +} + +.ion-ios-flame-outline:before { + content: "\f42e"; +} + +.ion-ios-flask:before { + content: "\f431"; +} + +.ion-ios-flask-outline:before { + content: "\f430"; +} + +.ion-ios-flower:before { + content: "\f433"; +} + +.ion-ios-flower-outline:before { + content: "\f432"; +} + +.ion-ios-folder:before { + content: "\f435"; +} + +.ion-ios-folder-outline:before { + content: "\f434"; +} + +.ion-ios-football:before { + content: "\f437"; +} + +.ion-ios-football-outline:before { + content: "\f436"; +} + +.ion-ios-game-controller-a:before { + content: "\f439"; +} + +.ion-ios-game-controller-a-outline:before { + content: "\f438"; +} + +.ion-ios-game-controller-b:before { + content: "\f43b"; +} + +.ion-ios-game-controller-b-outline:before { + content: "\f43a"; +} + +.ion-ios-gear:before { + content: "\f43d"; +} + +.ion-ios-gear-outline:before { + content: "\f43c"; +} + +.ion-ios-glasses:before { + content: "\f43f"; +} + +.ion-ios-glasses-outline:before { + content: "\f43e"; +} + +.ion-ios-grid-view:before { + content: "\f441"; +} + +.ion-ios-grid-view-outline:before { + content: "\f440"; +} + +.ion-ios-heart:before { + content: "\f443"; +} + +.ion-ios-heart-outline:before { + content: "\f442"; +} + +.ion-ios-help:before { + content: "\f446"; +} + +.ion-ios-help-empty:before { + content: "\f444"; +} + +.ion-ios-help-outline:before { + content: "\f445"; +} + +.ion-ios-home:before { + content: "\f448"; +} + +.ion-ios-home-outline:before { + content: "\f447"; +} + +.ion-ios-infinite:before { + content: "\f44a"; +} + +.ion-ios-infinite-outline:before { + content: "\f449"; +} + +.ion-ios-information:before { + content: "\f44d"; +} + +.ion-ios-information-empty:before { + content: "\f44b"; +} + +.ion-ios-information-outline:before { + content: "\f44c"; +} + +.ion-ios-ionic-outline:before { + content: "\f44e"; +} + +.ion-ios-keypad:before { + content: "\f450"; +} + +.ion-ios-keypad-outline:before { + content: "\f44f"; +} + +.ion-ios-lightbulb:before { + content: "\f452"; +} + +.ion-ios-lightbulb-outline:before { + content: "\f451"; +} + +.ion-ios-list:before { + content: "\f454"; +} + +.ion-ios-list-outline:before { + content: "\f453"; +} + +.ion-ios-location:before { + content: "\f456"; +} + +.ion-ios-location-outline:before { + content: "\f455"; +} + +.ion-ios-locked:before { + content: "\f458"; +} + +.ion-ios-locked-outline:before { + content: "\f457"; +} + +.ion-ios-loop:before { + content: "\f45a"; +} + +.ion-ios-loop-strong:before { + content: "\f459"; +} + +.ion-ios-medical:before { + content: "\f45c"; +} + +.ion-ios-medical-outline:before { + content: "\f45b"; +} + +.ion-ios-medkit:before { + content: "\f45e"; +} + +.ion-ios-medkit-outline:before { + content: "\f45d"; +} + +.ion-ios-mic:before { + content: "\f461"; +} + +.ion-ios-mic-off:before { + content: "\f45f"; +} + +.ion-ios-mic-outline:before { + content: "\f460"; +} + +.ion-ios-minus:before { + content: "\f464"; +} + +.ion-ios-minus-empty:before { + content: "\f462"; +} + +.ion-ios-minus-outline:before { + content: "\f463"; +} + +.ion-ios-monitor:before { + content: "\f466"; +} + +.ion-ios-monitor-outline:before { + content: "\f465"; +} + +.ion-ios-moon:before { + content: "\f468"; +} + +.ion-ios-moon-outline:before { + content: "\f467"; +} + +.ion-ios-more:before { + content: "\f46a"; +} + +.ion-ios-more-outline:before { + content: "\f469"; +} + +.ion-ios-musical-note:before { + content: "\f46b"; +} + +.ion-ios-musical-notes:before { + content: "\f46c"; +} + +.ion-ios-navigate:before { + content: "\f46e"; +} + +.ion-ios-navigate-outline:before { + content: "\f46d"; +} + +.ion-ios-nutrition:before { + content: "\f470"; +} + +.ion-ios-nutrition-outline:before { + content: "\f46f"; +} + +.ion-ios-paper:before { + content: "\f472"; +} + +.ion-ios-paper-outline:before { + content: "\f471"; +} + +.ion-ios-paperplane:before { + content: "\f474"; +} + +.ion-ios-paperplane-outline:before { + content: "\f473"; +} + +.ion-ios-partlysunny:before { + content: "\f476"; +} + +.ion-ios-partlysunny-outline:before { + content: "\f475"; +} + +.ion-ios-pause:before { + content: "\f478"; +} + +.ion-ios-pause-outline:before { + content: "\f477"; +} + +.ion-ios-paw:before { + content: "\f47a"; +} + +.ion-ios-paw-outline:before { + content: "\f479"; +} + +.ion-ios-people:before { + content: "\f47c"; +} + +.ion-ios-people-outline:before { + content: "\f47b"; +} + +.ion-ios-person:before { + content: "\f47e"; +} + +.ion-ios-person-outline:before { + content: "\f47d"; +} + +.ion-ios-personadd:before { + content: "\f480"; +} + +.ion-ios-personadd-outline:before { + content: "\f47f"; +} + +.ion-ios-photos:before { + content: "\f482"; +} + +.ion-ios-photos-outline:before { + content: "\f481"; +} + +.ion-ios-pie:before { + content: "\f484"; +} + +.ion-ios-pie-outline:before { + content: "\f483"; +} + +.ion-ios-pint:before { + content: "\f486"; +} + +.ion-ios-pint-outline:before { + content: "\f485"; +} + +.ion-ios-play:before { + content: "\f488"; +} + +.ion-ios-play-outline:before { + content: "\f487"; +} + +.ion-ios-plus:before { + content: "\f48b"; +} + +.ion-ios-plus-empty:before { + content: "\f489"; +} + +.ion-ios-plus-outline:before { + content: "\f48a"; +} + +.ion-ios-pricetag:before { + content: "\f48d"; +} + +.ion-ios-pricetag-outline:before { + content: "\f48c"; +} + +.ion-ios-pricetags:before { + content: "\f48f"; +} + +.ion-ios-pricetags-outline:before { + content: "\f48e"; +} + +.ion-ios-printer:before { + content: "\f491"; +} + +.ion-ios-printer-outline:before { + content: "\f490"; +} + +.ion-ios-pulse:before { + content: "\f493"; +} + +.ion-ios-pulse-strong:before { + content: "\f492"; +} + +.ion-ios-rainy:before { + content: "\f495"; +} + +.ion-ios-rainy-outline:before { + content: "\f494"; +} + +.ion-ios-recording:before { + content: "\f497"; +} + +.ion-ios-recording-outline:before { + content: "\f496"; +} + +.ion-ios-redo:before { + content: "\f499"; +} + +.ion-ios-redo-outline:before { + content: "\f498"; +} + +.ion-ios-refresh:before { + content: "\f49c"; +} + +.ion-ios-refresh-empty:before { + content: "\f49a"; +} + +.ion-ios-refresh-outline:before { + content: "\f49b"; +} + +.ion-ios-reload:before { + content: "\f49d"; +} + +.ion-ios-reverse-camera:before { + content: "\f49f"; +} + +.ion-ios-reverse-camera-outline:before { + content: "\f49e"; +} + +.ion-ios-rewind:before { + content: "\f4a1"; +} + +.ion-ios-rewind-outline:before { + content: "\f4a0"; +} + +.ion-ios-rose:before { + content: "\f4a3"; +} + +.ion-ios-rose-outline:before { + content: "\f4a2"; +} + +.ion-ios-search:before { + content: "\f4a5"; +} + +.ion-ios-search-strong:before { + content: "\f4a4"; +} + +.ion-ios-settings:before { + content: "\f4a7"; +} + +.ion-ios-settings-strong:before { + content: "\f4a6"; +} + +.ion-ios-shuffle:before { + content: "\f4a9"; +} + +.ion-ios-shuffle-strong:before { + content: "\f4a8"; +} + +.ion-ios-skipbackward:before { + content: "\f4ab"; +} + +.ion-ios-skipbackward-outline:before { + content: "\f4aa"; +} + +.ion-ios-skipforward:before { + content: "\f4ad"; +} + +.ion-ios-skipforward-outline:before { + content: "\f4ac"; +} + +.ion-ios-snowy:before { + content: "\f4ae"; +} + +.ion-ios-speedometer:before { + content: "\f4b0"; +} + +.ion-ios-speedometer-outline:before { + content: "\f4af"; +} + +.ion-ios-star:before { + content: "\f4b3"; +} + +.ion-ios-star-half:before { + content: "\f4b1"; +} + +.ion-ios-star-outline:before { + content: "\f4b2"; +} + +.ion-ios-stopwatch:before { + content: "\f4b5"; +} + +.ion-ios-stopwatch-outline:before { + content: "\f4b4"; +} + +.ion-ios-sunny:before { + content: "\f4b7"; +} + +.ion-ios-sunny-outline:before { + content: "\f4b6"; +} + +.ion-ios-telephone:before { + content: "\f4b9"; +} + +.ion-ios-telephone-outline:before { + content: "\f4b8"; +} + +.ion-ios-tennisball:before { + content: "\f4bb"; +} + +.ion-ios-tennisball-outline:before { + content: "\f4ba"; +} + +.ion-ios-thunderstorm:before { + content: "\f4bd"; +} + +.ion-ios-thunderstorm-outline:before { + content: "\f4bc"; +} + +.ion-ios-time:before { + content: "\f4bf"; +} + +.ion-ios-time-outline:before { + content: "\f4be"; +} + +.ion-ios-timer:before { + content: "\f4c1"; +} + +.ion-ios-timer-outline:before { + content: "\f4c0"; +} + +.ion-ios-toggle:before { + content: "\f4c3"; +} + +.ion-ios-toggle-outline:before { + content: "\f4c2"; +} + +.ion-ios-trash:before { + content: "\f4c5"; +} + +.ion-ios-trash-outline:before { + content: "\f4c4"; +} + +.ion-ios-undo:before { + content: "\f4c7"; +} + +.ion-ios-undo-outline:before { + content: "\f4c6"; +} + +.ion-ios-unlocked:before { + content: "\f4c9"; +} + +.ion-ios-unlocked-outline:before { + content: "\f4c8"; +} + +.ion-ios-upload:before { + content: "\f4cb"; +} + +.ion-ios-upload-outline:before { + content: "\f4ca"; +} + +.ion-ios-videocam:before { + content: "\f4cd"; +} + +.ion-ios-videocam-outline:before { + content: "\f4cc"; +} + +.ion-ios-volume-high:before { + content: "\f4ce"; +} + +.ion-ios-volume-low:before { + content: "\f4cf"; +} + +.ion-ios-wineglass:before { + content: "\f4d1"; +} + +.ion-ios-wineglass-outline:before { + content: "\f4d0"; +} + +.ion-ios-world:before { + content: "\f4d3"; +} + +.ion-ios-world-outline:before { + content: "\f4d2"; +} + +.ion-ipad:before { + content: "\f1f9"; +} + +.ion-iphone:before { + content: "\f1fa"; +} + +.ion-ipod:before { + content: "\f1fb"; +} + +.ion-jet:before { + content: "\f295"; +} + +.ion-key:before { + content: "\f296"; +} + +.ion-knife:before { + content: "\f297"; +} + +.ion-laptop:before { + content: "\f1fc"; +} + +.ion-leaf:before { + content: "\f1fd"; +} + +.ion-levels:before { + content: "\f298"; +} + +.ion-lightbulb:before { + content: "\f299"; +} + +.ion-link:before { + content: "\f1fe"; +} + +.ion-load-a:before { + content: "\f29a"; +} + +.ion-load-b:before { + content: "\f29b"; +} + +.ion-load-c:before { + content: "\f29c"; +} + +.ion-load-d:before { + content: "\f29d"; +} + +.ion-location:before { + content: "\f1ff"; +} + +.ion-lock-combination:before { + content: "\f4d4"; +} + +.ion-locked:before { + content: "\f200"; +} + +.ion-log-in:before { + content: "\f29e"; +} + +.ion-log-out:before { + content: "\f29f"; +} + +.ion-loop:before { + content: "\f201"; +} + +.ion-magnet:before { + content: "\f2a0"; +} + +.ion-male:before { + content: "\f2a1"; +} + +.ion-man:before { + content: "\f202"; +} + +.ion-map:before { + content: "\f203"; +} + +.ion-medkit:before { + content: "\f2a2"; +} + +.ion-merge:before { + content: "\f33f"; +} + +.ion-mic-a:before { + content: "\f204"; +} + +.ion-mic-b:before { + content: "\f205"; +} + +.ion-mic-c:before { + content: "\f206"; +} + +.ion-minus:before { + content: "\f209"; +} + +.ion-minus-circled:before { + content: "\f207"; +} + +.ion-minus-round:before { + content: "\f208"; +} + +.ion-model-s:before { + content: "\f2c1"; +} + +.ion-monitor:before { + content: "\f20a"; +} + +.ion-more:before { + content: "\f20b"; +} + +.ion-mouse:before { + content: "\f340"; +} + +.ion-music-note:before { + content: "\f20c"; +} + +.ion-navicon:before { + content: "\f20e"; +} + +.ion-navicon-round:before { + content: "\f20d"; +} + +.ion-navigate:before { + content: "\f2a3"; +} + +.ion-network:before { + content: "\f341"; +} + +.ion-no-smoking:before { + content: "\f2c2"; +} + +.ion-nuclear:before { + content: "\f2a4"; +} + +.ion-outlet:before { + content: "\f342"; +} + +.ion-paintbrush:before { + content: "\f4d5"; +} + +.ion-paintbucket:before { + content: "\f4d6"; +} + +.ion-paper-airplane:before { + content: "\f2c3"; +} + +.ion-paperclip:before { + content: "\f20f"; +} + +.ion-pause:before { + content: "\f210"; +} + +.ion-person:before { + content: "\f213"; +} + +.ion-person-add:before { + content: "\f211"; +} + +.ion-person-stalker:before { + content: "\f212"; +} + +.ion-pie-graph:before { + content: "\f2a5"; +} + +.ion-pin:before { + content: "\f2a6"; +} + +.ion-pinpoint:before { + content: "\f2a7"; +} + +.ion-pizza:before { + content: "\f2a8"; +} + +.ion-plane:before { + content: "\f214"; +} + +.ion-planet:before { + content: "\f343"; +} + +.ion-play:before { + content: "\f215"; +} + +.ion-playstation:before { + content: "\f30a"; +} + +.ion-plus:before { + content: "\f218"; +} + +.ion-plus-circled:before { + content: "\f216"; +} + +.ion-plus-round:before { + content: "\f217"; +} + +.ion-podium:before { + content: "\f344"; +} + +.ion-pound:before { + content: "\f219"; +} + +.ion-power:before { + content: "\f2a9"; +} + +.ion-pricetag:before { + content: "\f2aa"; +} + +.ion-pricetags:before { + content: "\f2ab"; +} + +.ion-printer:before { + content: "\f21a"; +} + +.ion-pull-request:before { + content: "\f345"; +} + +.ion-qr-scanner:before { + content: "\f346"; +} + +.ion-quote:before { + content: "\f347"; +} + +.ion-radio-waves:before { + content: "\f2ac"; +} + +.ion-record:before { + content: "\f21b"; +} + +.ion-refresh:before { + content: "\f21c"; +} + +.ion-reply:before { + content: "\f21e"; +} + +.ion-reply-all:before { + content: "\f21d"; +} + +.ion-ribbon-a:before { + content: "\f348"; +} + +.ion-ribbon-b:before { + content: "\f349"; +} + +.ion-sad:before { + content: "\f34a"; +} + +.ion-sad-outline:before { + content: "\f4d7"; +} + +.ion-scissors:before { + content: "\f34b"; +} + +.ion-search:before { + content: "\f21f"; +} + +.ion-settings:before { + content: "\f2ad"; +} + +.ion-share:before { + content: "\f220"; +} + +.ion-shuffle:before { + content: "\f221"; +} + +.ion-skip-backward:before { + content: "\f222"; +} + +.ion-skip-forward:before { + content: "\f223"; +} + +.ion-social-android:before { + content: "\f225"; +} + +.ion-social-android-outline:before { + content: "\f224"; +} + +.ion-social-angular:before { + content: "\f4d9"; +} + +.ion-social-angular-outline:before { + content: "\f4d8"; +} + +.ion-social-apple:before { + content: "\f227"; +} + +.ion-social-apple-outline:before { + content: "\f226"; +} + +.ion-social-bitcoin:before { + content: "\f2af"; +} + +.ion-social-bitcoin-outline:before { + content: "\f2ae"; +} + +.ion-social-buffer:before { + content: "\f229"; +} + +.ion-social-buffer-outline:before { + content: "\f228"; +} + +.ion-social-chrome:before { + content: "\f4db"; +} + +.ion-social-chrome-outline:before { + content: "\f4da"; +} + +.ion-social-codepen:before { + content: "\f4dd"; +} + +.ion-social-codepen-outline:before { + content: "\f4dc"; +} + +.ion-social-css3:before { + content: "\f4df"; +} + +.ion-social-css3-outline:before { + content: "\f4de"; +} + +.ion-social-designernews:before { + content: "\f22b"; +} + +.ion-social-designernews-outline:before { + content: "\f22a"; +} + +.ion-social-dribbble:before { + content: "\f22d"; +} + +.ion-social-dribbble-outline:before { + content: "\f22c"; +} + +.ion-social-dropbox:before { + content: "\f22f"; +} + +.ion-social-dropbox-outline:before { + content: "\f22e"; +} + +.ion-social-euro:before { + content: "\f4e1"; +} + +.ion-social-euro-outline:before { + content: "\f4e0"; +} + +.ion-social-facebook:before { + content: "\f231"; +} + +.ion-social-facebook-outline:before { + content: "\f230"; +} + +.ion-social-foursquare:before { + content: "\f34d"; +} + +.ion-social-foursquare-outline:before { + content: "\f34c"; +} + +.ion-social-freebsd-devil:before { + content: "\f2c4"; +} + +.ion-social-github:before { + content: "\f233"; +} + +.ion-social-github-outline:before { + content: "\f232"; +} + +.ion-social-google:before { + content: "\f34f"; +} + +.ion-social-google-outline:before { + content: "\f34e"; +} + +.ion-social-googleplus:before { + content: "\f235"; +} + +.ion-social-googleplus-outline:before { + content: "\f234"; +} + +.ion-social-hackernews:before { + content: "\f237"; +} + +.ion-social-hackernews-outline:before { + content: "\f236"; +} + +.ion-social-html5:before { + content: "\f4e3"; +} + +.ion-social-html5-outline:before { + content: "\f4e2"; +} + +.ion-social-instagram:before { + content: "\f351"; +} + +.ion-social-instagram-outline:before { + content: "\f350"; +} + +.ion-social-javascript:before { + content: "\f4e5"; +} + +.ion-social-javascript-outline:before { + content: "\f4e4"; +} + +.ion-social-linkedin:before { + content: "\f239"; +} + +.ion-social-linkedin-outline:before { + content: "\f238"; +} + +.ion-social-markdown:before { + content: "\f4e6"; +} + +.ion-social-nodejs:before { + content: "\f4e7"; +} + +.ion-social-octocat:before { + content: "\f4e8"; +} + +.ion-social-pinterest:before { + content: "\f2b1"; +} + +.ion-social-pinterest-outline:before { + content: "\f2b0"; +} + +.ion-social-python:before { + content: "\f4e9"; +} + +.ion-social-reddit:before { + content: "\f23b"; +} + +.ion-social-reddit-outline:before { + content: "\f23a"; +} + +.ion-social-rss:before { + content: "\f23d"; +} + +.ion-social-rss-outline:before { + content: "\f23c"; +} + +.ion-social-sass:before { + content: "\f4ea"; +} + +.ion-social-skype:before { + content: "\f23f"; +} + +.ion-social-skype-outline:before { + content: "\f23e"; +} + +.ion-social-snapchat:before { + content: "\f4ec"; +} + +.ion-social-snapchat-outline:before { + content: "\f4eb"; +} + +.ion-social-tumblr:before { + content: "\f241"; +} + +.ion-social-tumblr-outline:before { + content: "\f240"; +} + +.ion-social-tux:before { + content: "\f2c5"; +} + +.ion-social-twitch:before { + content: "\f4ee"; +} + +.ion-social-twitch-outline:before { + content: "\f4ed"; +} + +.ion-social-twitter:before { + content: "\f243"; +} + +.ion-social-twitter-outline:before { + content: "\f242"; +} + +.ion-social-usd:before { + content: "\f353"; +} + +.ion-social-usd-outline:before { + content: "\f352"; +} + +.ion-social-vimeo:before { + content: "\f245"; +} + +.ion-social-vimeo-outline:before { + content: "\f244"; +} + +.ion-social-whatsapp:before { + content: "\f4f0"; +} + +.ion-social-whatsapp-outline:before { + content: "\f4ef"; +} + +.ion-social-windows:before { + content: "\f247"; +} + +.ion-social-windows-outline:before { + content: "\f246"; +} + +.ion-social-wordpress:before { + content: "\f249"; +} + +.ion-social-wordpress-outline:before { + content: "\f248"; +} + +.ion-social-yahoo:before { + content: "\f24b"; +} + +.ion-social-yahoo-outline:before { + content: "\f24a"; +} + +.ion-social-yen:before { + content: "\f4f2"; +} + +.ion-social-yen-outline:before { + content: "\f4f1"; +} + +.ion-social-youtube:before { + content: "\f24d"; +} + +.ion-social-youtube-outline:before { + content: "\f24c"; +} + +.ion-soup-can:before { + content: "\f4f4"; +} + +.ion-soup-can-outline:before { + content: "\f4f3"; +} + +.ion-speakerphone:before { + content: "\f2b2"; +} + +.ion-speedometer:before { + content: "\f2b3"; +} + +.ion-spoon:before { + content: "\f2b4"; +} + +.ion-star:before { + content: "\f24e"; +} + +.ion-stats-bars:before { + content: "\f2b5"; +} + +.ion-steam:before { + content: "\f30b"; +} + +.ion-stop:before { + content: "\f24f"; +} + +.ion-thermometer:before { + content: "\f2b6"; +} + +.ion-thumbsdown:before { + content: "\f250"; +} + +.ion-thumbsup:before { + content: "\f251"; +} + +.ion-toggle:before { + content: "\f355"; +} + +.ion-toggle-filled:before { + content: "\f354"; +} + +.ion-transgender:before { + content: "\f4f5"; +} + +.ion-trash-a:before { + content: "\f252"; +} + +.ion-trash-b:before { + content: "\f253"; +} + +.ion-trophy:before { + content: "\f356"; +} + +.ion-tshirt:before { + content: "\f4f7"; +} + +.ion-tshirt-outline:before { + content: "\f4f6"; +} + +.ion-umbrella:before { + content: "\f2b7"; +} + +.ion-university:before { + content: "\f357"; +} + +.ion-unlocked:before { + content: "\f254"; +} + +.ion-upload:before { + content: "\f255"; +} + +.ion-usb:before { + content: "\f2b8"; +} + +.ion-videocamera:before { + content: "\f256"; +} + +.ion-volume-high:before { + content: "\f257"; +} + +.ion-volume-low:before { + content: "\f258"; +} + +.ion-volume-medium:before { + content: "\f259"; +} + +.ion-volume-mute:before { + content: "\f25a"; +} + +.ion-wand:before { + content: "\f358"; +} + +.ion-waterdrop:before { + content: "\f25b"; +} + +.ion-wifi:before { + content: "\f25c"; +} + +.ion-wineglass:before { + content: "\f2b9"; +} + +.ion-woman:before { + content: "\f25d"; +} + +.ion-wrench:before { + content: "\f2ba"; +} + +.ion-xbox:before { + content: "\f30c"; +} + +/*# sourceMappingURL=ionicons.css.map */ diff --git a/app/assets/stylesheets/app/_editor.scss b/app/assets/stylesheets/app/_editor.scss index edc06299a..0860d57be 100644 --- a/app/assets/stylesheets/app/_editor.scss +++ b/app/assets/stylesheets/app/_editor.scss @@ -24,14 +24,17 @@ $heading-height: 75px; #editor-title-bar { width: 100%; - padding: 20px; + + padding-top: 14px; + padding-left: 14px; padding-bottom: 10px; + padding-right: 10px; + background-color: white; border-bottom: none; z-index: 100; height: auto; - padding-right: 10px; overflow: visible; &.fullscreen { diff --git a/app/assets/stylesheets/app/_extensions.scss b/app/assets/stylesheets/app/_extensions.scss index e58a41cf7..65984cf1b 100644 --- a/app/assets/stylesheets/app/_extensions.scss +++ b/app/assets/stylesheets/app/_extensions.scss @@ -53,25 +53,6 @@ } } - .blue-box { - background-color: $blue-color; - color: white; - border-radius: 4px; - padding: 16px 20px; - - button { - background-color: white; - color: $blue-color; - border-radius: 3px; - font-weight: bold; - padding: 6px 20px; - width: 100%; - &:hover { - text-decoration: underline; - } - } - } - .dashboard-link { padding-top: 12px; font-weight: normal; diff --git a/app/assets/stylesheets/app/_footer.scss b/app/assets/stylesheets/app/_footer.scss index ef4e5658d..fb07054da 100644 --- a/app/assets/stylesheets/app/_footer.scss +++ b/app/assets/stylesheets/app/_footer.scss @@ -1,7 +1,6 @@ .fake-link { font-weight: bold; cursor: pointer; - color: $blue-color; &:hover { text-decoration: underline; @@ -31,7 +30,6 @@ h2 { a { font-weight: bold; cursor: pointer; - color: $blue-color; &.gray { color: $dark-gray; @@ -140,7 +138,6 @@ button.light { width: 100%; border: 1px solid rgba(gray, 0.15); cursor: pointer; - color: $blue-color; &:hover { background-color: rgba(gray, 0.10); @@ -170,7 +167,11 @@ a.disabled { } - +.icon.ion-locked { + margin-left: 5px; + border-left: 1px solid gray; + padding-left: 8px; +} @@ -211,7 +212,7 @@ a.disabled { border-right-color: transparent; border-radius: 50%; - &.blue { + &.tinted { border: 1px solid $blue-color; border-right-color: transparent; } diff --git a/app/assets/stylesheets/app/_lock-screen.scss b/app/assets/stylesheets/app/_lock-screen.scss new file mode 100644 index 000000000..4fef13adb --- /dev/null +++ b/app/assets/stylesheets/app/_lock-screen.scss @@ -0,0 +1,51 @@ +#lock-screen { + position: fixed; + margin-left: auto; + margin-right: auto; + left: 0; + right: 0; + top: 0; + bottom: 0; + z-index: 10000; + width: 100vw; + height: 100vh; + background-color: rgba(white, 0.5); + color: black; + font-size: 16px; + display: flex; + align-items: center; + + .background { + position: absolute; + z-index: -1; + width: 100%; + height: 100%; + } + + .content { + // box-shadow: 0 3px 3px rgba(0, 0, 0, 0.175); + border: 1px solid rgba(black, 0.1); + background-color: white; + width: 300px; + // height: 500px; + margin: auto; + padding: 10px 30px; + padding-bottom: 30px; + // position: absolute; + // top: 0; left: 0; bottom: 0; right: 0; + overflow-y: scroll; + + p { + margin-bottom: 8px; + margin-top: 0; + } + + h3 { + margin-bottom: 0; + } + + h4 { + margin-bottom: 6px; + } + } +} diff --git a/app/assets/stylesheets/app/_main.scss b/app/assets/stylesheets/app/_main.scss index d549d515e..83d9f0bad 100644 --- a/app/assets/stylesheets/app/_main.scss +++ b/app/assets/stylesheets/app/_main.scss @@ -29,10 +29,33 @@ $blue-color: #086dd6; } } -.blue { +.tinted { color: $blue-color; } +.tinted-selected { + color: white; +} + +.tinted-box { + background-color: $blue-color; + color: white; + border-radius: 4px; + padding: 16px 20px; + + button { + background-color: white; + color: $blue-color; + border-radius: 3px; + font-weight: bold; + padding: 6px 20px; + width: 100%; + &:hover { + text-decoration: underline; + } + } +} + html, body { font-family: -apple-system, BlinkMacSystemFont, @@ -67,6 +90,7 @@ body { } a { + color: $blue-color; text-decoration: none; &:hover { text-decoration: underline;; @@ -164,3 +188,7 @@ $section-header-height: 70px; } } } + +.icon { + margin-right: 4px; +} diff --git a/app/assets/stylesheets/app/_menus.scss b/app/assets/stylesheets/app/_menus.scss index 127371e8e..b46ba2bf9 100644 --- a/app/assets/stylesheets/app/_menus.scss +++ b/app/assets/stylesheets/app/_menus.scss @@ -1,7 +1,7 @@ ul.section-menu-bar { width: 100%; padding-top: 0px; - padding-left: 15px; + padding-left: 6px; padding-right: 21px; background-color: #f1f1f1; @@ -63,10 +63,13 @@ ul.section-menu-bar { > li { width: 100%; - height: 35px; + height: 40px; + padding-top: 3px; overflow: hidden; cursor: pointer; + border-bottom: 1px solid rgba(black, 0.1); + color: $selected-text-color; float: left; @@ -103,10 +106,6 @@ ul.section-menu-bar { overflow-y: scroll; max-height: calc(85vh - 90px); - &:not(:first-child) { - margin-top: 18px; - } - ul { margin-top: 0px; margin-bottom: 0px; @@ -143,7 +142,7 @@ ul.section-menu-bar { background-color: $blue-color; - .blue { + .tinted { color: white; } diff --git a/app/assets/stylesheets/app/_notes.scss b/app/assets/stylesheets/app/_notes.scss index f0997f265..2b3c39232 100644 --- a/app/assets/stylesheets/app/_notes.scss +++ b/app/assets/stylesheets/app/_notes.scss @@ -11,6 +11,8 @@ #notes-title-bar { color: rgba(black, 0.40); padding-top: 16px; + padding-left: 14px; + padding-right: 14px; height: $notes-title-bar-height; font-weight: normal; font-size: 18px; @@ -21,14 +23,15 @@ } #notes-add-button { - right: 20px; + right: 14px; } #tag-menu-bar { position: relative; - margin: 0 -20px; - width: auto; + margin: 0 -14px; margin-top: 14px; + // padding-left: 4px; + width: auto; } .filter-section { @@ -101,6 +104,20 @@ margin-top: 4px; } + .tags-string { + margin-bottom: 4px; + font-size: 13px; + } + + .pinned { + .icon { + display: inline-block; + vertical-align: top; + margin-top: 2px; + margin-right: 2px; + } + } + .note-preview { font-size: 15px; margin-top: 1px; @@ -117,6 +134,10 @@ &.selected { background-color: $blue-color; color: white; + + .pinned { + color: white; + } } } diff --git a/app/assets/stylesheets/app/_permissions-modal.scss b/app/assets/stylesheets/app/_permissions-modal.scss index 6da432ff5..c51800dd1 100644 --- a/app/assets/stylesheets/app/_permissions-modal.scss +++ b/app/assets/stylesheets/app/_permissions-modal.scss @@ -55,9 +55,6 @@ .status { color: orange; - &.trusted { - color: $blue-color; - } } .buttons { @@ -74,7 +71,7 @@ text-decoration: underline; } - &.blue { + &.tinted { background-color: $blue-color; color: white; } diff --git a/app/assets/stylesheets/app/_standard.scss b/app/assets/stylesheets/app/_standard.scss index e9a6a2cb4..0877302c9 100644 --- a/app/assets/stylesheets/app/_standard.scss +++ b/app/assets/stylesheets/app/_standard.scss @@ -22,6 +22,10 @@ margin-top: 2px !important; } +.mt-3 { + margin-top: 3px !important; +} + .mt-5 { margin-top: 5px !important; } diff --git a/app/assets/stylesheets/frontend.css.scss b/app/assets/stylesheets/frontend.css.scss index 4f084485d..249d4f9ed 100644 --- a/app/assets/stylesheets/frontend.css.scss +++ b/app/assets/stylesheets/frontend.css.scss @@ -10,3 +10,6 @@ $dark-gray: #2e2e2e; @import "app/extensions"; @import "app/menus"; @import "app/permissions-modal"; +@import "app/lock-screen"; + +@import "ionicons"; diff --git a/app/assets/templates/frontend/directives/account-menu.html.haml b/app/assets/templates/frontend/directives/account-menu.html.haml index 34b097b31..38cca022c 100644 --- a/app/assets/templates/frontend/directives/account-menu.html.haml +++ b/app/assets/templates/frontend/directives/account-menu.html.haml @@ -6,7 +6,7 @@ .mb-10 .step-one{"ng-if" => "!formData.showLogin && !formData.showRegister"} - %h3 Sign in or register to enable sync and encryption. + %h3 Sign in or register to enable sync and end-to-end encryption. .small-v-space .button-group.mt-5 @@ -31,10 +31,14 @@ %label.pull-left Sync Server Domain %input.form-control.mt-5{:name => 'server', :placeholder => 'Server URL', :required => true, :type => 'text', 'ng-model' => 'formData.url'} - .checkbox.mt-10{"ng-if" => "localNotesCount() > 0"} + .checkbox.mt-10 + %p + %input{"type" => "checkbox", "ng-model" => "formData.ephemeral", "ng-true-value" => "false", "ng-false-value" => "true"} + Stay signed in + .checkbox.mt-10{"ng-if" => "notesAndTagsCount() > 0"} %p %input{"type" => "checkbox", "ng-model" => "formData.mergeLocal", "ng-bind" => "true", "ng-change" => "mergeLocalChanged()"} - Merge local notes ({{localNotesCount()}} notes) + Merge local data ({{notesAndTagsCount()}} notes and tags) %button.ui-button.block.mt-10{"ng-click" => "submitAuthForm()"} {{formData.showLogin ? "Sign In" : "Register"}} .mt-15{"ng-if" => "formData.showRegister"} @@ -48,8 +52,8 @@ %div{"ng-if" => "user"} %h2 {{user.email}} %p {{server}} - %div.bold.mt-10.blue{"delay-hide" => "true", "show" => "syncStatus.syncOpInProgress || syncStatus.needsMoreSync", "delay" => "1000"} - .spinner.inline.mr-5.blue + %div.bold.mt-10.tinted{"delay-hide" => "true", "show" => "syncStatus.syncOpInProgress || syncStatus.needsMoreSync", "delay" => "1000"} + .spinner.inline.mr-5.tinted {{"Syncing" + (syncStatus.total > 0 ? ":" : "")}} %span{"ng-if" => "syncStatus.total > 0"} {{syncStatus.current}}/{{syncStatus.total}} %p.bold.mt-10.red.block{"ng-if" => "syncStatus.error"} Error syncing: {{syncStatus.error.message}} @@ -104,20 +108,37 @@ %input.form-control{:type => 'password', "ng-model" => "securityUpdateData.password", "placeholder" => "Enter password"} %button.ui-button.block{"ng-click" => "submitSecurityUpdateForm()"} Update %div.mt-5{"ng-if" => "securityUpdateData.processing"} - %p.blue Processing... + %p.tinted Processing... + .mt-25 + %h4 Encryption Status + %p + {{encryptionStatusString()}} + %div.mt-5{"ng-if" => "encryptionEnabled()"} + %i {{encryptionStatusForNotes()}} + + .mt-25 + %h4 Passcode Lock + %div{"ng-if" => "!hasPasscode() && passcodeOptionAvailable()"} + %p Add an app passcode to lock the app and encrypt on-device key storage. + %a.block.mt-5{"ng-click" => "addPasscodeClicked()", "ng-if" => "!formData.showPasscodeForm"} Add Passcode + + .mt-5{"ng-if" => "formData.showPasscodeForm"} + %p.bold Choose a passcode: + %input.form-control.mt-10{:type => 'password', "ng-model" => "formData.passcode", "placeholder" => "Passcode", "autofocus" => "true"} + %input.form-control.mt-10{:type => 'password', "ng-model" => "formData.confirmPasscode", "placeholder" => "Confirm Passcode"} + %button.standard.ui-button.block.tinted.mt-5{"ng-click" => "submitPasscodeForm()"} Set Passcode + %div{"ng-if" => "hasPasscode()"} + %p + Passcode lock is enabled. + %span{"ng-if" => "isDesktopApplication()"} Your passcode will be required on new sessions after app quit. + %a.block.mt-5{"ng-click" => "removePasscodePressed()"} Remove Passcode + %div{"ng-if" => "!passcodeOptionAvailable()"} + %p Passcode lock is only available to permanent sessions. (You chose not to stay signed in.) - .medium-v-space - - %h4 Local Encryption - %p Notes are encrypted locally before being sent to the server. Neither the server owner nor an intrusive entity can decrypt your locally encrypted notes. - %div.mt-5 - %label Status: - {{encryptionStatusForNotes()}} - .mt-25{"ng-if" => "!importData.loading"} %h4 Data Archives .mt-5{"ng-if" => "user"} @@ -132,25 +153,15 @@ %label.block.mt-5 %input{"type" => "file", "style" => "display: none;", "file-change" => "->", "handler" => "importFileSelected(files)"} - .fake-link Import Data from Archive + .fake-link.tinted Import Data from Archive %div{"ng-if" => "importData.requestPassword"} %p Enter the account password associated with the import file. - %input.form-control.mt-5{:type => 'password', "ng-model" => "importData.password"} - %button.standard.ui-button.block.blue.mt-5{"ng-click" => "submitImportPassword()"} Decrypt & Import + %input.form-control.mt-5{:type => 'password', "ng-model" => "importData.password", "autofocus" => "true"} + %button.standard.ui-button.block.tinted.mt-5{"ng-click" => "submitImportPassword()"} Decrypt & Import %p.mt-5{"ng-if" => "user"} Notes are downloaded in the Standard File format, which allows you to re-import back into this app easily. To download as plain text files, choose "Decrypted". .spinner.mt-10{"ng-if" => "importData.loading"} - .mt-25 - %h4 Analytics - %p - Help Standard Notes improve by sending anonymous data on general usage. - %a{"href" => "https://standardnotes.org/privacy", "target" => "_blank"} Learn more. - %div.mt-5 - %label Status: - {{analyticsManager.enabled ? "Enabled" : "Disabled"}} - %a{"ng-click" => "analyticsManager.toggleStatus()"} {{analyticsManager.enabled ? "Disable" : "Enable"}} - %a.block.mt-25.red{"ng-click" => "destroyLocalData()"} {{ user ? "Sign out and clear local data" : "Clear all local data" }} diff --git a/app/assets/templates/frontend/directives/contextual-menu.html.haml b/app/assets/templates/frontend/directives/contextual-menu.html.haml index 3a0582515..416bd3c24 100644 --- a/app/assets/templates/frontend/directives/contextual-menu.html.haml +++ b/app/assets/templates/frontend/directives/contextual-menu.html.haml @@ -10,7 +10,7 @@ %ul{"ng-if" => "!extension.hide"} %li.menu-item{"ng-repeat" => "action in extension.actionsWithContextForItem(item)", "ng-click" => "executeAction(action, extension);", "ng-class" => "{'faded' : !isActionEnabled(action, extension)}"} - .menu-item-title {{action.label}} + %label.menu-item-title {{action.label}} .menu-item-subtitle {{action.desc}} .small.normal{"ng-if" => "!isActionEnabled(action, extension)"} @@ -19,7 +19,7 @@ %div{"ng-if" => "action.showNestedActions"} %ul.mt-10 %li.menu-item.white-bg.nested-hover{"ng-repeat" => "subaction in action.subactions", "ng-click" => "executeAction(subaction, extension, action); $event.stopPropagation();", "style" => "margin-top: -1px;"} - .menu-item-title {{subaction.label}} + %label.menu-item-title {{subaction.label}} .menu-item-subtitle {{subaction.desc}} %span{"ng-if" => "subaction.running"} .spinner{"style" => "margin-top: 3px;"} diff --git a/app/assets/templates/frontend/directives/editor-menu.html.haml b/app/assets/templates/frontend/directives/editor-menu.html.haml index 5f2e3fd6b..b6165f264 100644 --- a/app/assets/templates/frontend/directives/editor-menu.html.haml +++ b/app/assets/templates/frontend/directives/editor-menu.html.haml @@ -4,7 +4,7 @@ %ul %li.menu-item{"ng-repeat" => "editor in editorManager.systemEditors", "ng-click" => "selectEditor($event, editor)"} %span.pull-left.mr-10{"ng-if" => "selectedEditor === editor"} ✓ - .menu-item-title.pull-left {{editor.name}} + %label.menu-item-title.pull-left {{editor.name}} %div{"ng-if" => "editorManager.externalEditors.length > 0"} .header @@ -13,6 +13,6 @@ %ul %li.menu-item{"ng-repeat" => "editor in editorManager.externalEditors", "ng-click" => "selectEditor($event, editor)"} %strong.red.medium{"ng-if" => "editor.conflict_of"} Conflicted copy - .menu-item-title + %label.menu-item-title {{editor.name}} - %span.inline.blue{"style" => "margin-left: 8px;", "ng-if" => "selectedEditor === editor"} ✓ + %span.inline.tinted{"style" => "margin-left: 8px;", "ng-if" => "selectedEditor === editor"} ✓ diff --git a/app/assets/templates/frontend/directives/global-extensions-menu.html.haml b/app/assets/templates/frontend/directives/global-extensions-menu.html.haml index a194396a8..5642da8cc 100644 --- a/app/assets/templates/frontend/directives/global-extensions-menu.html.haml +++ b/app/assets/templates/frontend/directives/global-extensions-menu.html.haml @@ -2,15 +2,15 @@ .panel-body .container .float-group.h20 - %h1.blue.pull-left Extensions + %h1.tinted.pull-left Extensions %a.block.pull-right.dashboard-link{"href" => "https://dashboard.standardnotes.org", "target" => "_blank"} Open Dashboard %div.clear{"ng-if" => "!extensionManager.extensions.length && !themeManager.themes.length && !editorManager.externalEditors.length"} %p Customize your experience with editors, themes, and actions. - .blue-box.mt-10 + .tinted-box.mt-10 %h3 Available as part of the Extended subscription. %p.mt-5 Note history %p.mt-5 Automated backups - %p.mt-5 All editors, themes, and actions + %p.mt-5 Editors, themes, and actions %a{"href" => "https://standardnotes.org/extensions", "target" => "_blank"} %button.mt-10 %h3 Learn More @@ -24,7 +24,7 @@ %h3 {{theme.name}} %a{"ng-if" => "!themeManager.isThemeActive(theme)", "ng-click" => "themeManager.activateTheme(theme); $event.stopPropagation();"} Activate %a{"ng-if" => "themeManager.isThemeActive(theme)", "ng-click" => "themeManager.deactivateTheme(theme); $event.stopPropagation();"} Deactivate - %div{"ng-if" => "theme.showDetails"} + .mt-3{"ng-if" => "theme.showDetails"} .link-group %a.red{"ng-click" => "deleteTheme(theme); $event.stopPropagation();"} Delete %a{"ng-click" => "theme.showLink = !theme.showLink; $event.stopPropagation();"} Show Link @@ -43,7 +43,7 @@ %div.mt-5{"ng-if" => "editor.showDetails"} .link-group %a{"ng-if" => "!editor.default", "ng-click" => "setDefaultEditor(editor); $event.stopPropagation();"} Make Default - %a.blue{"ng-if" => "editor.default", "ng-click" => "removeDefaultEditor(editor); $event.stopPropagation();"} Remove as Default + %a.tinted{"ng-if" => "editor.default", "ng-click" => "removeDefaultEditor(editor); $event.stopPropagation();"} Remove as Default %a{"ng-click" => "editor.showUrl = !editor.showUrl; $event.stopPropagation();"} Show Link %a.red{ "ng-click" => "deleteEditor(editor); $event.stopPropagation();"} Delete .wrap.mt-5.selectable{"ng-if" => "editor.showUrl"} {{editor.url}} @@ -86,8 +86,8 @@ %div .mt-5{"ng-if" => "action.repeat_mode"} - %button.light{"ng-if" => "extensionManager.isRepeatActionEnabled(action)", "ng-click" => "extensionManager.disableRepeatAction(action, extension); $event.stopPropagation();"} Disable - %button.light{"ng-if" => "!extensionManager.isRepeatActionEnabled(action)", "ng-click" => "extensionManager.enableRepeatAction(action, extension); $event.stopPropagation();"} Enable + %button.light.tinted{"ng-if" => "extensionManager.isRepeatActionEnabled(action)", "ng-click" => "extensionManager.disableRepeatAction(action, extension); $event.stopPropagation();"} Disable + %button.light.tinted{"ng-if" => "!extensionManager.isRepeatActionEnabled(action)", "ng-click" => "extensionManager.enableRepeatAction(action, extension); $event.stopPropagation();"} Enable %button.light.mt-10{"ng-if" => "!action.running && !action.repeat_mode", "ng-click" => "selectedAction(action, extension); $event.stopPropagation();"} Perform Action .spinner.mb-5.block{"ng-if" => "action.running"} @@ -109,7 +109,7 @@ %h3 {{component.name}} %a{"ng-if" => "!componentManager.isComponentActive(component)", "ng-click" => "componentManager.activateComponent(component); $event.stopPropagation();"} Activate %a{"ng-if" => "componentManager.isComponentActive(component)", "ng-click" => "componentManager.deactivateComponent(component); $event.stopPropagation();"} Deactivate - %div{"ng-if" => "component.showDetails"} + .mt-3{"ng-if" => "component.showDetails"} .link-group %a.red{"ng-click" => "deleteComponent(component); $event.stopPropagation();"} Delete %a{"ng-click" => "component.showLink = !component.showLink; $event.stopPropagation();"} Show Link @@ -117,10 +117,24 @@ %p.small.selectable.wrap{"ng-if" => "component.showLink"} {{component.url}} + %div{"ng-if" => "serverExtensions.length > 0"} + .container.no-bottom.section-margin + %h2 Server Extensions + %ul + %li{"ng-repeat" => "ext in serverExtensions", "ng-click" => "ext.showDetails = !ext.showDetails"} + .container + %strong.red.medium{"ng-if" => "ext.conflict_of"} Conflicted copy + %h3 {{nameForServerExtension(ext)}} + %div.mt-3{"ng-if" => "ext.showDetails"} + .link-group + %a{"ng-click" => "ext.showUrl = !ext.showUrl; $event.stopPropagation();"} Show Link + %a.red{ "ng-click" => "deleteServerExt(ext); $event.stopPropagation();"} Delete + .wrap.mt-5.selectable{"ng-if" => "ext.showUrl"} {{ext.url}} + .container.section-margin - %h2.blue Install + %h2.tinted Install %p.faded Enter an install link %form.mt-10.mb-10 %input.form-control{:autofocus => 'autofocus', :name => 'url', :required => true, :autocomplete => "off", :type => 'url', 'ng-model' => 'formData.installLink', "ng-keyup" => "$event.keyCode == 13 && submitInstallLink();"} - %p.blue{"ng-if" => "formData.successfullyInstalled"} Successfully installed extension. + %p.tinted{"ng-if" => "formData.successfullyInstalled"} Successfully installed extension. diff --git a/app/assets/templates/frontend/directives/permissions-modal.html.haml b/app/assets/templates/frontend/directives/permissions-modal.html.haml index 43262ea21..0ca1c7f70 100644 --- a/app/assets/templates/frontend/directives/permissions-modal.html.haml +++ b/app/assets/templates/frontend/directives/permissions-modal.html.haml @@ -12,7 +12,7 @@ %p {{permission}} %h4 Status - %p.status{"ng-class" => "{'trusted' : component.trusted}"} {{component.trusted ? 'Trusted' : 'Untrusted'}} + %p.status{"ng-class" => "{'trusted tinted' : component.trusted}"} {{component.trusted ? 'Trusted' : 'Untrusted'}} .learn-more %h4 Details @@ -22,4 +22,4 @@ .buttons %button.standard.white{"ng-click" => "deny()"} Deny - %button.standard.blue{"ng-click" => "accept()"} Accept + %button.standard.tinted{"ng-click" => "accept()"} Accept diff --git a/app/assets/templates/frontend/editor.html.haml b/app/assets/templates/frontend/editor.html.haml index 6a75e259d..89579d0dd 100644 --- a/app/assets/templates/frontend/editor.html.haml +++ b/app/assets/templates/frontend/editor.html.haml @@ -16,11 +16,24 @@ %li{"ng-class" => "{'selected' : ctrl.showMenu}", "click-outside" => "ctrl.showMenu = false;", "is-open" => "ctrl.showMenu"} %label{"ng-click" => "ctrl.showMenu = !ctrl.showMenu; ctrl.showExtensions = false; ctrl.showEditorMenu = false;"} Menu - %ul.dropdown-menu{"ng-if" => "ctrl.showMenu"} + %ul.dropdown-menu.sectioned-menu{"ng-if" => "ctrl.showMenu"} %li - %label{"ng-click" => "ctrl.selectedMenuItem($event); ctrl.toggleFullScreen()"} Toggle Fullscreen + %label{"ng-click" => "ctrl.selectedMenuItem($event); ctrl.togglePin()"} + %i.icon.ion-ios-flag + {{ctrl.note.pinned ? "Unpin" : "Pin"}} %li - %label{"ng-click" => "ctrl.selectedMenuItem($event); ctrl.deleteNote()"} Delete Note + %label{"ng-click" => "ctrl.selectedMenuItem($event); ctrl.toggleArchiveNote()"} + %i.icon.ion-ios-box + {{ctrl.note.archived ? "Unarcnive" : "Archive"}} + %li + %label{"ng-click" => "ctrl.selectedMenuItem($event); ctrl.deleteNote()"} + %i.icon.ion-trash-b + Delete + %li + %label{"ng-click" => "ctrl.selectedMenuItem($event); ctrl.toggleFullScreen()"} + %i.icon.ion-arrow-expand + Toggle Fullscreen + %li{"ng-if" => "ctrl.hasDisabledComponents()"} %label{"ng-click" => "ctrl.selectedMenuItem($event); ctrl.restoreDisabledComponents()"} Restore Disabled Components diff --git a/app/assets/templates/frontend/footer.html.haml b/app/assets/templates/frontend/footer.html.haml index d1f85155d..8814681e5 100644 --- a/app/assets/templates/frontend/footer.html.haml +++ b/app/assets/templates/frontend/footer.html.haml @@ -2,7 +2,7 @@ .pull-left .footer-bar-link{"click-outside" => "ctrl.showAccountMenu = false;", "is-open" => "ctrl.showAccountMenu"} %a{"ng-click" => "ctrl.accountMenuPressed()", "ng-class" => "{red: ctrl.error}"} Account - %account-menu{"ng-if" => "ctrl.showAccountMenu"} + %account-menu{"ng-if" => "ctrl.showAccountMenu", "on-successful-auth" => "ctrl.onAuthSuccess"} .footer-bar-link{"click-outside" => "ctrl.showExtensionsMenu = false;", "is-open" => "ctrl.showExtensionsMenu"} %a{"ng-click" => "ctrl.toggleExtensions()"} Extensions @@ -15,7 +15,7 @@ .pull-right .footer-bar-link{"ng-if" => "ctrl.newUpdateAvailable", "ng-click" => "ctrl.clickedNewUpdateAnnouncement()"} - %span.blue.normal New update downloaded. Installs on app restart. + %span.tinted.normal New update downloaded. Installs on app restart. .footer-bar-link{"style" => "margin-right: 5px;"} %div{"ng-if" => "ctrl.lastSyncDate", "style" => "float: left; font-weight: normal; margin-right: 8px;"} @@ -26,3 +26,6 @@ %strong{"ng-if" => "ctrl.offline"} Offline %a{"ng-if" => "!ctrl.offline", "ng-click" => "ctrl.refreshData()"} Refresh + + %span{"ng-if" => "ctrl.hasPasscode()"} + %i.icon.ion-locked{"ng-click" => "ctrl.lockApp()"} diff --git a/app/assets/templates/frontend/home.html.haml b/app/assets/templates/frontend/home.html.haml index 26adaaf96..bf98088c2 100644 --- a/app/assets/templates/frontend/home.html.haml +++ b/app/assets/templates/frontend/home.html.haml @@ -1,10 +1,11 @@ .main-ui-view - .app - %tags-section{"save" => "tagsSave", "add-new" => "tagsAddNew", "will-select" => "tagsWillMakeSelection", "selection-made" => "tagsSelectionMade", "all-tag" => "allTag", - "tags" => "tags", "remove-tag" => "removeTag"} + %lock-screen{"ng-if" => "needsUnlock", "on-success" => "onSuccessfulUnlock"} + .app{"ng-if" => "!needsUnlock"} + %tags-section{"save" => "tagsSave", "add-new" => "tagsAddNew", "will-select" => "tagsWillMakeSelection", "selection-made" => "tagsSelectionMade", + "all-tag" => "allTag", "archive-tag" => "archiveTag", "tags" => "tags", "remove-tag" => "removeTag"} %notes-section{"add-new" => "notesAddNew", "selection-made" => "notesSelectionMade", "tag" => "selectedTag"} %editor-section{"note" => "selectedNote", "remove" => "deleteNote", "save" => "saveNote", "update-tags" => "updateTagsForNote"} - %footer + %footer{"ng-if" => "!needsUnlock"} diff --git a/app/assets/templates/frontend/lock-screen.html.haml b/app/assets/templates/frontend/lock-screen.html.haml new file mode 100644 index 000000000..e79c7cbef --- /dev/null +++ b/app/assets/templates/frontend/lock-screen.html.haml @@ -0,0 +1,9 @@ +#lock-screen + .content + %h3.center-align Passcode Required + + %form.mt-20{"ng-submit" => "submitPasscodeForm()"} + %input.form-control.mt-10{:type => 'password', + "ng-model" => "formData.passcode", "autofocus" => "true", + "placeholder" => "Enter Passcode", "autocomplete" => "new-password"} + %button.standard.ui-button.block.tinted.mt-5{"type" => "submit"} Unlock diff --git a/app/assets/templates/frontend/notes.html.haml b/app/assets/templates/frontend/notes.html.haml index d49b4e3de..976548ed5 100644 --- a/app/assets/templates/frontend/notes.html.haml +++ b/app/assets/templates/frontend/notes.html.haml @@ -9,7 +9,7 @@ #search-clear-button{"ng-if" => "ctrl.noteFilter.text", "ng-click" => "ctrl.noteFilter.text = ''; ctrl.filterTextChanged()"} ✕ %ul.section-menu-bar#tag-menu-bar %li{"ng-class" => "{'selected' : ctrl.showMenu}"} - %label{"ng-click" => "ctrl.showMenu = !ctrl.showMenu"} Sort + %label{"ng-click" => "ctrl.showMenu = !ctrl.showMenu"} {{ctrl.sortByTitle()}} %ul.dropdown-menu{"ng-if" => "ctrl.showMenu"} %li @@ -27,12 +27,22 @@ .scrollable .infinite-scroll{"infinite-scroll" => "ctrl.paginate()", "can-load" => "true", "threshold" => "200"} - .note{"ng-repeat" => "note in (ctrl.sortedNotes = (ctrl.tag.notes | filter: ctrl.filterNotes | orderBy: ctrl.sortBy:ctrl.sortDescending | limitTo:ctrl.notesToDisplay))", + .note{"ng-repeat" => "note in (ctrl.sortedNotes = (ctrl.tag.notes | filter: ctrl.filterNotes | sortBy: ctrl.sortBy| limitTo:ctrl.notesToDisplay))", "ng-click" => "ctrl.selectNote(note)", "ng-class" => "{'selected' : ctrl.selectedNote == note}"} %strong.red.medium{"ng-if" => "note.conflict_of"} Conflicted copy %strong.red.medium{"ng-if" => "note.errorDecrypting"} Error decrypting + + .pinned.tinted{"ng-if" => "note.pinned", "ng-class" => "{'tinted-selected' : ctrl.selectedNote == note}"} + %i.icon.ion-ios-flag + %strong.medium Pinned + + .tags-string{"ng-if" => "ctrl.tag.all"} + .faded {{note.tagsString()}} + .name{"ng-if" => "note.title"} {{note.title}} .note-preview {{note.text}} - .date {{(note.created_at | appDateTime) || 'Now'}} + .date.faded + %span{"ng-if" => "ctrl.sortBy == 'updated_at'"} Modified {{note.updatedAtString() || 'Now'}} + %span{"ng-if" => "ctrl.sortBy != 'updated_at'"} {{note.createdAtString() || 'Now'}} diff --git a/app/assets/templates/frontend/tags.html.haml b/app/assets/templates/frontend/tags.html.haml index e8a061982..121705900 100644 --- a/app/assets/templates/frontend/tags.html.haml +++ b/app/assets/templates/frontend/tags.html.haml @@ -21,3 +21,6 @@ %a.item{"ng-click" => "ctrl.selectedRenameTag($event, tag)", "ng-if" => "!ctrl.editingTag"} Rename %a.item{"ng-click" => "ctrl.saveTag($event, tag)", "ng-if" => "ctrl.editingTag"} Save %a.item{"ng-click" => "ctrl.selectedDeleteTag(tag)"} Delete + .tag.faded{"ng-if" => "ctrl.archiveTag", "ng-click" => "ctrl.selectTag(ctrl.archiveTag)", "ng-class" => "{'selected' : ctrl.selectedTag == ctrl.archiveTag}"} + .info + %input.title{"ng-disabled" => "true", "ng-model" => "ctrl.archiveTag.title"} diff --git a/app/views/application/frontend.html.erb b/app/views/application/frontend.html.erb index 9e9426521..c1dea5432 100644 --- a/app/views/application/frontend.html.erb +++ b/app/views/application/frontend.html.erb @@ -1,5 +1,5 @@ - +
diff --git a/config/application.rb b/config/application.rb index 90f0f9d2d..29c28a298 100644 --- a/config/application.rb +++ b/config/application.rb @@ -53,12 +53,12 @@ module Neeto font_src: %w('self'), form_action: %w('self'), frame_ancestors: %w('none'), - img_src: %w('self' piwik.standardnotes.org data:), + img_src: %w('self' data:), manifest_src: %w('self'), media_src: %w('self'), object_src: %w('self'), plugin_types: %w(), - script_src: %w('self' 'unsafe-inline' piwik.standardnotes.org), + script_src: %w('self' 'unsafe-inline'), style_src: %w(* 'unsafe-inline'), upgrade_insecure_requests: false, # see https://www.w3.org/TR/upgrade-insecure-requests/ } diff --git a/package.json b/package.json index ac91eb5c9..6e3a91bc1 100644 --- a/package.json +++ b/package.json @@ -20,6 +20,7 @@ "grunt-babel": "^6.0.0", "grunt-browserify": "^5.0.0", "grunt-contrib-concat": "^1.0.1", + "grunt-contrib-copy": "^1.0.0", "grunt-contrib-cssmin": "^1.0.2", "grunt-contrib-sass": "^1.0.0", "grunt-contrib-uglify": "^2.0.0", diff --git a/vendor/assets/fonts/ionicons.eot b/vendor/assets/fonts/ionicons.eot new file mode 100644 index 000000000..92a3f20a3 Binary files /dev/null and b/vendor/assets/fonts/ionicons.eot differ diff --git a/vendor/assets/fonts/ionicons.svg b/vendor/assets/fonts/ionicons.svg new file mode 100644 index 000000000..49fc8f367 --- /dev/null +++ b/vendor/assets/fonts/ionicons.svg @@ -0,0 +1,2230 @@ + + + + diff --git a/vendor/assets/fonts/ionicons.ttf b/vendor/assets/fonts/ionicons.ttf new file mode 100644 index 000000000..c4e463248 Binary files /dev/null and b/vendor/assets/fonts/ionicons.ttf differ diff --git a/vendor/assets/fonts/ionicons.woff b/vendor/assets/fonts/ionicons.woff new file mode 100644 index 000000000..5f3a14e0a Binary files /dev/null and b/vendor/assets/fonts/ionicons.woff differ