Closes #671.
Unified · Split
| @@ -1,9 +1,23 @@ | |||
| 1 | class app.Settings | 1 | class app.Settings |
| 2 | DOCS_KEY = 'docs' | 2 | PREFERENCE_KEYS = [ |
| 3 | DARK_KEY = 'dark' | 3 | 'hideDisabled' |
| 4 | LAYOUT_KEY = 'layout' | 4 | 'hideIntro' |
| 5 | SIZE_KEY = 'size' | 5 | 'manualUpdate' |
| 6 | TIPS_KEY = 'tips' | 6 | 'fastScroll' |
| 7 | 'arrowScroll' | ||
| 8 | 'docs' | ||
| 9 | 'dark' | ||
| 10 | 'layout' | ||
| 11 | 'size' | ||
| 12 | 'tips' | ||
| 13 | ] | ||
| 14 | |||
| 15 | INTERNAL_KEYS = [ | ||
| 16 | 'count' | ||
| 17 | 'schema' | ||
| 18 | 'version' | ||
| 19 | 'news' | ||
| 20 | ] | ||
| 7 | 21 | ||
| 8 | @defaults: | 22 | @defaults: |
| 9 | count: 0 | 23 | count: 0 |
| @@ -32,24 +46,24 @@ class app.Settings · 22 unmodified lines | |||
| 32 | return | 46 | return |
| 33 | 47 | ||
| 34 | hasDocs: -> | 48 | hasDocs: -> |
| 35 | try !!@store.get(DOCS_KEY) | 49 | try !!@store.get('docs') |
| 36 | 50 | ||
| 37 | getDocs: -> | 51 | getDocs: -> |
| 38 | @store.get(DOCS_KEY)?.split('/') or app.config.default_docs | 52 | @store.get('docs')?.split('/') or app.config.default_docs |
| 39 | 53 | ||
| 40 | setDocs: (docs) -> | 54 | setDocs: (docs) -> |
| 41 | @set DOCS_KEY, docs.join('/') | 55 | @set 'docs', docs.join('/') |
| 42 | return | 56 | return |
| 43 | 57 | ||
| 44 | getTips: -> | 58 | getTips: -> |
| 45 | @store.get(TIPS_KEY)?.split('/') or [] | 59 | @store.get('tips')?.split('/') or [] |
| 46 | 60 | ||
| 47 | setTips: (tips) -> | 61 | setTips: (tips) -> |
| 48 | @set TIPS_KEY, tips.join('/') | 62 | @set 'tips', tips.join('/') |
| 49 | return | 63 | return |
| 50 | 64 | ||
| 51 | setLayout: (name, enable) -> | 65 | setLayout: (name, enable) -> |
| 52 | layout = (@store.get(LAYOUT_KEY) || '').split(' ') | 66 | layout = (@store.get('layout') || '').split(' ') |
| 53 | $.arrayDelete(layout, '') | 67 | $.arrayDelete(layout, '') |
| 54 | 68 | ||
| 55 | if enable | 69 | if enable |
| @@ -58,22 +72,34 @@ class app.Settings · 2 unmodified lines | |||
| 58 | $.arrayDelete(layout, name) | 72 | $.arrayDelete(layout, name) |
| 59 | 73 | ||
| 60 | if layout.length > 0 | 74 | if layout.length > 0 |
| 61 | @set LAYOUT_KEY, layout.join(' ') | 75 | @set 'layout', layout.join(' ') |
| 62 | else | 76 | else |
| 63 | @del LAYOUT_KEY | 77 | @del 'layout' |
| 64 | return | 78 | return |
| 65 | 79 | ||
| 66 | hasLayout: (name) -> | 80 | hasLayout: (name) -> |
| 67 | layout = (@store.get(LAYOUT_KEY) || '').split(' ') | 81 | layout = (@store.get('layout') || '').split(' ') |
| 68 | layout.indexOf(name) isnt -1 | 82 | layout.indexOf(name) isnt -1 |
| 69 | 83 | ||
| 70 | setSize: (value) -> | 84 | setSize: (value) -> |
| 71 | @set SIZE_KEY, value | 85 | @set 'size', value |
| 72 | return | 86 | return |
| 73 | 87 | ||
| 74 | dump: -> | 88 | dump: -> |
| 75 | @store.dump() | 89 | @store.dump() |
| 76 | 90 | ||
| 91 | export: -> | ||
| 92 | data = @dump() | ||
| 93 | delete data[key] for key in INTERNAL_KEYS | ||
| 94 | data | ||
| 95 | |||
| 96 | import: (data) -> | ||
| 97 | for key, value of @export() | ||
| 98 | @del key unless data.hasOwnProperty(key) | ||
| 99 | for key, value of data | ||
| 100 | @set key, value if PREFERENCE_KEYS.indexOf(key) isnt -1 | ||
| 101 | return | ||
| 102 | |||
| 77 | reset: -> | 103 | reset: -> |
| 78 | @store.reset() | 104 | @store.reset() |
| 79 | @cache = {} | 105 | @cache = {} |
| @@ -14,6 +14,7 @@ class @CookieStore · 13 unmodified lines | |||
| 14 | return | 14 | return |
| 15 | 15 | ||
| 16 | value = 1 if value == true | 16 | value = 1 if value == true |
| 17 | value = parseInt(value, 10) if value and INT.test?(value) | ||
| 17 | Cookies.set(key, '' + value, path: '/', expires: 1e8) | 18 | Cookies.set(key, '' + value, path: '/', expires: 1e8) |
| 18 | @constructor.onBlocked(key, value, @get(key)) if @get(key) != value | 19 | @constructor.onBlocked(key, value, @get(key)) if @get(key) != value |
| 19 | return | 20 | return |
| @@ -1,5 +1,8 @@ | |||
| 1 | [ | 1 | [ |
| 2 | [ | 2 | [ |
| 3 | "2017-09-10", | ||
| 4 | "<a href=\"/settings\">Preferences</a> can now be exported and imported." | ||
| 5 | ], [ | ||
| 3 | "2017-09-03", | 6 | "2017-09-03", |
| 4 | "New documentations: <a href=\"/d/\">D</a>, <a href=\"/nim/\">Nim</a> and <a href=\"/vulkan/\">Vulkan</a>" | 7 | "New documentations: <a href=\"/d/\">D</a>, <a href=\"/nim/\">Nim</a> and <a href=\"/vulkan/\">Vulkan</a>" |
| 5 | ], [ | 8 | ], [ |
| @@ -30,6 +30,10 @@ app.templates.notifInvalidLocation = -> · 29 unmodified lines | |||
| 30 | textNotif """ DevDocs must be loaded from #{app.config.production_host} """, | 30 | textNotif """ DevDocs must be loaded from #{app.config.production_host} """, |
| 31 | """ Otherwise things are likely to break. """ | 31 | """ Otherwise things are likely to break. """ |
| 32 | 32 | ||
| 33 | app.templates.notifImportInvalid = -> | ||
| 34 | textNotif """ Oops, an error occured. """, | ||
| 35 | """ The file you selected is invalid. """ | ||
| 36 | |||
| 33 | app.templates.notifNews = (news) -> | 37 | app.templates.notifNews = (news) -> |
| 34 | notif 'Changelog', """<div class="_notif-content _notif-news">#{app.templates.newsList(news, years: false)}</div>""" | 38 | notif 'Changelog', """<div class="_notif-content _notif-news">#{app.templates.newsList(news, years: false)}</div>""" |
| 35 | 39 | ||
| @@ -35,5 +35,10 @@ app.templates.settingsPage = (settings) -> """ · 34 unmodified lines | |||
| 35 | </div> | 35 | </div> |
| 36 | </div> | 36 | </div> |
| 37 | 37 | ||
| 38 | <button type="button" class="_btn-link _reset-btn" data-behavior="reset">Reset all preferences and data</button> | 38 | <p class="_hide-on-mobile"> |
| 39 | <button type="button" class="_btn" data-action="export">Export</button> | ||
| 40 | <label class="_btn _file-btn"><input type="file" form="settings" name="import" accept=".json">Import</label> | ||
| 41 | |||
| 42 | <p> | ||
| 43 | <button type="button" class="_btn-link _reset-btn" data-behavior="reset">Reset all preferences and data</button> | ||
| 39 | """ | 44 | """ |
| @@ -5,6 +5,7 @@ class app.views.SettingsPage extends app.View · 4 unmodified lines | |||
| 5 | @className: '_static' | 5 | @className: '_static' |
| 6 | 6 | ||
| 7 | @events: | 7 | @events: |
| 8 | click: 'onClick' | ||
| 8 | change: 'onChange' | 9 | change: 'onChange' |
| 9 | 10 | ||
| 10 | render: -> | 11 | render: -> |
| @@ -46,6 +47,34 @@ class app.views.SettingsPage extends app.View · 35 unmodified lines | |||
| 46 | app.settings.set(name, enable) | 47 | app.settings.set(name, enable) |
| 47 | return | 48 | return |
| 48 | 49 | ||
| 50 | export: -> | ||
| 51 | data = new Blob([JSON.stringify(app.settings.export())], type: 'application/json') | ||
| 52 | link = document.createElement('a') | ||
| 53 | link.href = URL.createObjectURL(data) | ||
| 54 | link.download = 'devdocs.json' | ||
| 55 | link.style.display = 'none' | ||
| 56 | document.body.appendChild(link) | ||
| 57 | link.click() | ||
| 58 | document.body.removeChild(link) | ||
| 59 | return | ||
| 60 | |||
| 61 | import: (file, input) -> | ||
| 62 | unless file and file.type is 'application/json' | ||
| 63 | new app.views.Notif 'ImportInvalid', autoHide: false | ||
| 64 | return | ||
| 65 | |||
| 66 | reader = new FileReader() | ||
| 67 | reader.onloadend = -> | ||
| 68 | data = try JSON.parse(reader.result) | ||
| 69 | unless data and data.constructor is Object | ||
| 70 | new app.views.Notif 'ImportInvalid', autoHide: false | ||
| 71 | return | ||
| 72 | app.settings.import(data) | ||
| 73 | $.trigger input.form, 'import' | ||
| 74 | return | ||
| 75 | reader.readAsText(file) | ||
| 76 | return | ||
| 77 | |||
| 49 | onChange: (event) => | 78 | onChange: (event) => |
| 50 | input = event.target | 79 | input = event.target |
| 51 | switch input.name | 80 | switch input.name |
| @@ -55,10 +84,20 @@ class app.views.SettingsPage extends app.View · 3 unmodified lines | |||
| 55 | @toggleLayout input.value, input.checked | 84 | @toggleLayout input.value, input.checked |
| 56 | when 'smoothScroll' | 85 | when 'smoothScroll' |
| 57 | @toggleSmoothScroll input.checked | 86 | @toggleSmoothScroll input.checked |
| 87 | when 'import' | ||
| 88 | @import input.files[0], input | ||
| 58 | else | 89 | else |
| 59 | @toggle input.name, input.checked | 90 | @toggle input.name, input.checked |
| 60 | return | 91 | return |
| 61 | 92 | ||
| 93 | onClick: (event) => | ||
| 94 | target = $.eventTarget(event) | ||
| 95 | switch target.getAttribute('data-action') | ||
| 96 | when 'export' | ||
| 97 | $.stopEvent(event) | ||
| 98 | @export() | ||
| 99 | return | ||
| 100 | |||
| 62 | onRoute: (context) -> | 101 | onRoute: (context) -> |
| 63 | @render() | 102 | @render() |
| 64 | return | 103 | return |
| @@ -9,6 +9,7 @@ class app.views.Settings extends app.View · 8 unmodified lines | |||
| 9 | backBtn: 'button[data-back]' | 9 | backBtn: 'button[data-back]' |
| 10 | 10 | ||
| 11 | @events: | 11 | @events: |
| 12 | import: 'onImport' | ||
| 12 | change: 'onChange' | 13 | change: 'onChange' |
| 13 | submit: 'onSubmit' | 14 | submit: 'onSubmit' |
| 14 | click: 'onClick' | 15 | click: 'onClick' |
| @@ -41,11 +42,16 @@ class app.views.Settings extends app.View · 26 unmodified lines | |||
| 41 | @addClass '_in' | 42 | @addClass '_in' |
| 42 | return | 43 | return |
| 43 | 44 | ||
| 44 | save: -> | 45 | save: (options = {}) -> |
| 45 | unless @saving | 46 | unless @saving |
| 46 | @saving = true | 47 | @saving = true |
| 47 | docs = @docPicker.getSelectedDocs() | 48 | |
| 48 | app.settings.setDocs(docs) | 49 | if options.import |
| 50 | docs = app.settings.getDocs() | ||
| 51 | else | ||
| 52 | docs = @docPicker.getSelectedDocs() | ||
| 53 | app.settings.setDocs(docs) | ||
| 54 | |||
| 49 | @saveBtn.textContent = if app.appCache then 'Downloading\u2026' else 'Saving\u2026' | 55 | @saveBtn.textContent = if app.appCache then 'Downloading\u2026' else 'Saving\u2026' |
| 50 | disabledDocs = new app.collections.Docs(doc for doc in app.docs.all() when docs.indexOf(doc.slug) is -1) | 56 | disabledDocs = new app.collections.Docs(doc for doc in app.docs.all() when docs.indexOf(doc.slug) is -1) |
| 51 | disabledDocs.uninstall -> | 57 | disabledDocs.uninstall -> |
| @@ -66,6 +72,11 @@ class app.views.Settings extends app.View · 14 unmodified lines | |||
| 66 | @save() | 72 | @save() |
| 67 | return | 73 | return |
| 68 | 74 | ||
| 75 | onImport: => | ||
| 76 | @addClass('_dirty') | ||
| 77 | @save(import: true) | ||
| 78 | return | ||
| 79 | |||
| 69 | onClick: (event) => | 80 | onClick: (event) => |
| 70 | return if event.which isnt 1 | 81 | return if event.which isnt 1 |
| 71 | if event.target is @backBtn | 82 | if event.target is @backBtn |
| @@ -398,12 +398,16 @@ · 397 unmodified lines | |||
| 398 | } | 398 | } |
| 399 | 399 | ||
| 400 | ._btn { | 400 | ._btn { |
| 401 | display: inline-block; | ||
| 402 | vertical-align: top; | ||
| 403 | line-height: normal; | ||
| 401 | white-space: nowrap; | 404 | white-space: nowrap; |
| 402 | padding: .375rem .675rem; | 405 | padding: .375rem .675rem; |
| 403 | background-image: linear-gradient(lighten($boxBackground, 4%), darken($boxBackground, 2%)); | 406 | background-image: linear-gradient(lighten($boxBackground, 4%), darken($boxBackground, 2%)); |
| 404 | border: 1px solid $boxBorder; | 407 | border: 1px solid $boxBorder; |
| 405 | border-radius: 3px; | 408 | border-radius: 3px; |
| 406 | box-shadow: 0 1px rgba($boxBorder, .08); | 409 | box-shadow: 0 1px rgba($boxBorder, .08); |
| 410 | cursor: pointer; | ||
| 407 | 411 | ||
| 408 | &:active { | 412 | &:active { |
| 409 | background-color: $boxBackground; | 413 | background-color: $boxBackground; |
| @@ -411,6 +415,20 @@ · 1 unmodified line | |||
| 411 | } | 415 | } |
| 412 | } | 416 | } |
| 413 | 417 | ||
| 418 | ._file-btn { | ||
| 419 | position: relative; | ||
| 420 | overflow: hidden; | ||
| 421 | |||
| 422 | > input { | ||
| 423 | position: absolute; | ||
| 424 | top: 0; | ||
| 425 | left: 0; | ||
| 426 | width: 100%; | ||
| 427 | height: 100%; | ||
| 428 | visibility: hidden; | ||
| 429 | } | ||
| 430 | } | ||
| 431 | |||
| 414 | ._btn-link { | 432 | ._btn-link { |
| 415 | line-height: inherit; | 433 | line-height: inherit; |
| 416 | color: $linkColor; | 434 | color: $linkColor; |