Unified · Split
| @@ -61,7 +61,7 @@ · 60 unmodified lines | ||
| 61 | 61 | bootOne: -> |
| 62 | 62 | @doc = new app.models.Doc @DOC |
| 63 | 63 | @docs.reset [@doc] |
| 64 | − @doc.load @start.bind(@), @onBootError.bind(@), readCache: true | |
| 64 | + @doc.load @bootDB.bind(@), @onBootError.bind(@), readCache: true | |
| 65 | 65 | new app.views.Notice 'singleDoc', @doc |
| 66 | 66 | delete @DOC |
| 67 | 67 | return |
| @@ -72,20 +72,25 @@ · 4 unmodified lines | ||
| 72 | 72 | (if docs.indexOf(doc.slug) >= 0 then @docs else @disabledDocs).add(doc) |
| 73 | 73 | @docs.sort() |
| 74 | 74 | @disabledDocs.sort() |
| 75 | − @docs.load @start.bind(@), @onBootError.bind(@), readCache: true, writeCache: true | |
| 75 | + @docs.load @bootDB.bind(@), @onBootError.bind(@), readCache: true, writeCache: true | |
| 76 | 76 | delete @DOCS |
| 77 | 77 | return |
| 78 | 78 | |
| 79 | − start: -> | |
| 79 | + bootDB: -> | |
| 80 | 80 | for doc in @docs.all() |
| 81 | 81 | @entries.add doc.toEntry() |
| 82 | 82 | @entries.add type.toEntry() for type in doc.types.all() |
| 83 | 83 | @entries.add doc.entries.all() |
| 84 | + | |
| 85 | + @db = new app.DB() | |
| 86 | + @db.init(@start.bind(@)) | |
| 87 | + return | |
| 88 | + | |
| 89 | + start: -> | |
| 84 | 90 | @trigger 'ready' |
| 85 | 91 | @router.start() |
| 86 | 92 | @hideLoading() |
| 87 | 93 | @welcomeBack() unless @doc |
| 88 | − | |
| 89 | 94 | @removeEvent 'ready bootError' |
| 90 | 95 | return |
| 91 | 96 | |
| @@ -8,3 +8,4 @@ app.config = · 7 unmodified lines | ||
| 8 | 8 | production_host: 'devdocs.io' |
| 9 | 9 | search_param: 'q' |
| 10 | 10 | sentry_dsn: '<%= App.sentry_dsn %>' |
| 11 | + version: '<%= Time.now.to_i %>' | |
| @@ -0,0 +1,115 @@ | ||
| 1 | +class app.DB | |
| 2 | + NAME = 'docs' | |
| 3 | + | |
| 4 | + constructor: -> | |
| 5 | + @useIndexedDB = @useIndexedDB() | |
| 6 | + | |
| 7 | + init: (@_callback) -> | |
| 8 | + if @useIndexedDB | |
| 9 | + @initIndexedDB() | |
| 10 | + else | |
| 11 | + @callback() | |
| 12 | + return | |
| 13 | + | |
| 14 | + initIndexedDB: -> | |
| 15 | + try | |
| 16 | + req = indexedDB.open(NAME, @indexedDBVersion()) | |
| 17 | + req.onerror = @callback | |
| 18 | + req.onsuccess = @onOpenSuccess | |
| 19 | + req.onupgradeneeded = @onUpgradeNeeded | |
| 20 | + catch | |
| 21 | + @callback() | |
| 22 | + return | |
| 23 | + | |
| 24 | + isEnabled: -> | |
| 25 | + !!@db | |
| 26 | + | |
| 27 | + callback: => | |
| 28 | + @_callback?() | |
| 29 | + @_callback = null | |
| 30 | + return | |
| 31 | + | |
| 32 | + onOpenSuccess: (event) => | |
| 33 | + try | |
| 34 | + @db = event.target.result | |
| 35 | + @db.transaction(['docs', app.docs.all()[0].slug], 'readwrite').abort() # https://bugs.webkit.org/show_bug.cgi?id=136937 | |
| 36 | + catch | |
| 37 | + @db = null | |
| 38 | + | |
| 39 | + @callback() | |
| 40 | + return | |
| 41 | + | |
| 42 | + onUpgradeNeeded: (event) => | |
| 43 | + db = event.target.result | |
| 44 | + | |
| 45 | + unless db.objectStoreNames.contains('docs') | |
| 46 | + db.createObjectStore('docs') | |
| 47 | + | |
| 48 | + for doc in app.docs.all() when not db.objectStoreNames.contains(doc.slug) | |
| 49 | + db.createObjectStore(doc.slug) | |
| 50 | + | |
| 51 | + for doc in app.disabledDocs.all() when not db.objectStoreNames.contains(doc.slug) | |
| 52 | + db.createObjectStore(doc.slug) | |
| 53 | + return | |
| 54 | + | |
| 55 | + store: (doc, data, onSuccess, onError) -> | |
| 56 | + txn = @db.transaction ['docs', doc.slug], 'readwrite' | |
| 57 | + txn.oncomplete = -> if txn.error then onError() else onSuccess() | |
| 58 | + | |
| 59 | + store = txn.objectStore(doc.slug) | |
| 60 | + store.clear() | |
| 61 | + store.add(content, path) for path, content of data | |
| 62 | + | |
| 63 | + store = txn.objectStore('docs') | |
| 64 | + store.put(doc.mtime, doc.slug) | |
| 65 | + return | |
| 66 | + | |
| 67 | + unstore: (doc, onSuccess, onError) -> | |
| 68 | + txn = @db.transaction ['docs', doc.slug], 'readwrite' | |
| 69 | + txn.oncomplete = -> if txn.error then onError() else onSuccess() | |
| 70 | + | |
| 71 | + store = txn.objectStore(doc.slug) | |
| 72 | + store.clear() | |
| 73 | + | |
| 74 | + store = txn.objectStore('docs') | |
| 75 | + store.delete(doc.slug) | |
| 76 | + return | |
| 77 | + | |
| 78 | + version: (doc, callback) -> | |
| 79 | + txn = @db.transaction ['docs'], 'readonly' | |
| 80 | + store = txn.objectStore('docs') | |
| 81 | + | |
| 82 | + req = store.get(doc.slug) | |
| 83 | + req.onsuccess = -> callback(!!req.result) | |
| 84 | + req.onerror = -> callback(false) | |
| 85 | + return | |
| 86 | + | |
| 87 | + load: (entry, onSuccess, onError) -> | |
| 88 | + if @isEnabled() | |
| 89 | + onError = @loadWithXHR.bind(@, entry, onSuccess, onError) | |
| 90 | + @loadWithIDB(entry, onSuccess, onError) | |
| 91 | + else | |
| 92 | + @loadWithXHR(entry, onSuccess, onError) | |
| 93 | + | |
| 94 | + loadWithXHR: (entry, onSuccess, onError) -> | |
| 95 | + ajax | |
| 96 | + url: entry.fileUrl() | |
| 97 | + dataType: 'html' | |
| 98 | + success: onSuccess | |
| 99 | + error: onError | |
| 100 | + | |
| 101 | + loadWithIDB: (entry, onSuccess, onError) -> | |
| 102 | + txn = @db.transaction [entry.doc.slug], 'readonly' | |
| 103 | + store = txn.objectStore(entry.doc.slug) | |
| 104 | + | |
| 105 | + req = store.get(entry.path) | |
| 106 | + req.onsuccess = -> if req.result then onSuccess(req.result) else onError() | |
| 107 | + req.onerror = onError | |
| 108 | + | |
| 109 | + txn | |
| 110 | + | |
| 111 | + useIndexedDB: -> | |
| 112 | + !app.isSingleDoc() and !!window.indexedDB | |
| 113 | + | |
| 114 | + indexedDBVersion: -> | |
| 115 | + if app.config.env is 'production' then app.config.version else Date.now() / 1000 | |
| @@ -4,6 +4,7 @@ class app.Router · 3 unmodified lines | ||
| 4 | 4 | @routes: [ |
| 5 | 5 | ['*', 'before' ] |
| 6 | 6 | ['/', 'root' ] |
| 7 | + ['/offline', 'offline' ] | |
| 7 | 8 | ['/about', 'about' ] |
| 8 | 9 | ['/news', 'news' ] |
| 9 | 10 | ['/help', 'help' ] |
| @@ -75,6 +76,10 @@ class app.Router · 65 unmodified lines | ||
| 75 | 76 | @triggerRoute 'root' |
| 76 | 77 | return |
| 77 | 78 | |
| 79 | + offline: -> | |
| 80 | + @triggerRoute 'offline' | |
| 81 | + return | |
| 82 | + | |
| 78 | 83 | about: (context) -> |
| 79 | 84 | context.page = 'about' |
| 80 | 85 | @triggerRoute 'page' |
| @@ -27,6 +27,9 @@ class app.models.Doc extends app.Model · 26 unmodified lines | ||
| 27 | 27 | fileUrl: (path) -> |
| 28 | 28 | "#{app.config.docs_host}#{@fullPath(path)}" |
| 29 | 29 | |
| 30 | + dbUrl: -> | |
| 31 | + "#{app.config.docs_host}/#{@db_path}?#{@mtime}" | |
| 32 | + | |
| 30 | 33 | indexUrl: -> |
| 31 | 34 | "#{app.indexHost()}/#{@index_path}?#{@mtime}" |
| 32 | 35 | |
| @@ -83,3 +86,39 @@ class app.models.Doc extends app.Model · 50 unmodified lines | ||
| 83 | 86 | _setCache: (data) -> |
| 84 | 87 | app.store.set @slug, [@mtime, data] |
| 85 | 88 | return |
| 89 | + | |
| 90 | + download: (onSuccess, onError) -> | |
| 91 | + return if @downloading | |
| 92 | + @downloading = true | |
| 93 | + | |
| 94 | + error = => | |
| 95 | + @downloading = null | |
| 96 | + onError() | |
| 97 | + | |
| 98 | + success = (data) => | |
| 99 | + @downloading = null | |
| 100 | + app.db.store @, data, onSuccess, error | |
| 101 | + | |
| 102 | + ajax | |
| 103 | + url: @dbUrl() | |
| 104 | + success: success | |
| 105 | + error: error | |
| 106 | + return | |
| 107 | + | |
| 108 | + undownload: (onSuccess, onError) -> | |
| 109 | + return if @downloading | |
| 110 | + @downloading = true | |
| 111 | + | |
| 112 | + success = => | |
| 113 | + @downloading = null | |
| 114 | + onSuccess() | |
| 115 | + | |
| 116 | + error = => | |
| 117 | + @downloading = null | |
| 118 | + onError() | |
| 119 | + | |
| 120 | + app.db.unstore @, success, error | |
| 121 | + | |
| 122 | + getDownloadStatus: (callback) -> | |
| 123 | + app.db.version @, (value) -> | |
| 124 | + callback downloaded: !!value, version: value | |
| @@ -41,8 +41,4 @@ class app.models.Entry extends app.Model · 40 unmodified lines | ||
| 41 | 41 | @doc.types.findBy 'name', @type |
| 42 | 42 | |
| 43 | 43 | loadFile: (onSuccess, onError) -> |
| 44 | − ajax | |
| 45 | − url: @fileUrl() | |
| 46 | − dataType: 'html' | |
| 47 | − success: onSuccess | |
| 48 | − error: onError | |
| 44 | + app.db.load(@, onSuccess, onError) | |
| @@ -0,0 +1,16 @@ | ||
| 1 | +app.templates.offlinePage = -> | |
| 2 | + """ <h1 class="_lined-heading">Offline</h1> | |
| 3 | + <table class="_docs"> | |
| 4 | + #{app.templates.render 'offlineDoc', app.docs.all()} | |
| 5 | + </table> """ | |
| 6 | + | |
| 7 | +app.templates.offlineDoc = (doc) -> | |
| 8 | + """<tr data-slug="#{doc.slug}"></tr>""" | |
| 9 | + | |
| 10 | +app.templates.offlineDocContent = (doc, status) -> | |
| 11 | + html = """<th class="_icon-#{doc.slug}">#{doc.name}</th>""" | |
| 12 | + html += if status.downloaded | |
| 13 | + """<td><a data-del>Delete</a></td>""" | |
| 14 | + else | |
| 15 | + """<td><a data-dl>Download</a></td>""" | |
| 16 | + html | |
| @@ -23,10 +23,11 @@ class app.views.Content extends app.View · 22 unmodified lines | ||
| 23 | 23 | @scrollMap = {} |
| 24 | 24 | @scrollStack = [] |
| 25 | 25 | |
| 26 | − @rootPage = new app.views.RootPage | |
| 27 | − @staticPage = new app.views.StaticPage | |
| 28 | − @typePage = new app.views.TypePage | |
| 29 | − @entryPage = new app.views.EntryPage | |
| 26 | + @rootPage = new app.views.RootPage | |
| 27 | + @staticPage = new app.views.StaticPage | |
| 28 | + @offlinePage = new app.views.OfflinePage | |
| 29 | + @typePage = new app.views.TypePage | |
| 30 | + @entryPage = new app.views.EntryPage | |
| 30 | 31 | |
| 31 | 32 | @entryPage |
| 32 | 33 | .on 'loading', @onEntryLoading |
| @@ -137,6 +138,8 @@ class app.views.Content extends app.View · 104 unmodified lines | ||
| 137 | 138 | @show @entryPage |
| 138 | 139 | when 'type' |
| 139 | 140 | @show @typePage |
| 141 | + when 'offline' | |
| 142 | + @show @offlinePage | |
| 140 | 143 | else |
| 141 | 144 | @show @staticPage |
| 142 | 145 | |
| @@ -0,0 +1,63 @@ | ||
| 1 | +class app.views.OfflinePage extends app.View | |
| 2 | + @className: '_static' | |
| 3 | + | |
| 4 | + @events: | |
| 5 | + click: 'onClick' | |
| 6 | + | |
| 7 | + @elements: | |
| 8 | + list: '_._docs' | |
| 9 | + | |
| 10 | + deactivate: -> | |
| 11 | + if super | |
| 12 | + @empty() | |
| 13 | + return | |
| 14 | + | |
| 15 | + render: -> | |
| 16 | + @html @tmpl('offlinePage') | |
| 17 | + @refreshElements() | |
| 18 | + app.docs.each(@renderDoc) | |
| 19 | + return | |
| 20 | + | |
| 21 | + renderDoc: (doc) => | |
| 22 | + doc.getDownloadStatus (status) => | |
| 23 | + html = app.templates.render('offlineDocContent', doc, status) | |
| 24 | + el = @docEl(doc) | |
| 25 | + el.className = '' | |
| 26 | + el.innerHTML = html | |
| 27 | + return | |
| 28 | + | |
| 29 | + getTitle: -> | |
| 30 | + 'Offline' | |
| 31 | + | |
| 32 | + getDoc: (el) -> | |
| 33 | + el = el.parentNode until slug = el.getAttribute('data-slug') | |
| 34 | + app.docs.findBy('slug', slug) | |
| 35 | + | |
| 36 | + docEl: (doc) -> | |
| 37 | + @find("[data-slug='#{doc.slug}']") | |
| 38 | + | |
| 39 | + onRoute: -> | |
| 40 | + @render() | |
| 41 | + return | |
| 42 | + | |
| 43 | + onClick: (event) => | |
| 44 | + if event.target.hasAttribute('data-dl') | |
| 45 | + action = 'download' | |
| 46 | + else if event.target.hasAttribute('data-del') | |
| 47 | + action = 'undownload' | |
| 48 | + | |
| 49 | + if action | |
| 50 | + $.stopEvent(event) | |
| 51 | + doc = @getDoc(event.target) | |
| 52 | + doc[action](@onDownloadSuccess.bind(@, doc), @onDownloadError.bind(@, doc)) | |
| 53 | + @docEl(doc).classList.add("#{action}ing") | |
| 54 | + return | |
| 55 | + | |
| 56 | + onDownloadSuccess: (doc) -> | |
| 57 | + @renderDoc(doc) | |
| 58 | + return | |
| 59 | + | |
| 60 | + onDownloadError: (doc) -> | |
| 61 | + el = @docEl(doc) | |
| 62 | + el.className = '' | |
| 63 | + el.classList.add('error') | |
| @@ -242,6 +242,51 @@ · 241 unmodified lines | ||
| 242 | 242 | td:first-child, td:last-child { white-space: nowrap; } |
| 243 | 243 | } |
| 244 | 244 | |
| 245 | +// | |
| 246 | +// Doc table | |
| 247 | +// | |
| 248 | + | |
| 249 | +._docs { | |
| 250 | + width: 100%; | |
| 251 | + line-height: 1.5rem; | |
| 252 | + | |
| 253 | + th { | |
| 254 | + max-width: 0; | |
| 255 | + padding-left: .5rem; | |
| 256 | + padding-right: .5rem; | |
| 257 | + white-space: nowrap; | |
| 258 | + font-weight: normal; | |
| 259 | + | |
| 260 | + &:before { | |
| 261 | + float: left; | |
| 262 | + margin: .25rem .5rem .25rem 0; | |
| 263 | + @extend %icon; | |
| 264 | + } | |
| 265 | + } | |
| 266 | + | |
| 267 | + td:last-child { text-align: right; } | |
| 268 | + td > a { cursor: pointer; } | |
| 269 | + | |
| 270 | + tr.downloading > td:last-child { | |
| 271 | + > a { display: none; } | |
| 272 | + &:before { content: 'Downloading…' } | |
| 273 | + } | |
| 274 | + | |
| 275 | + tr.undownloading > td:last-child { | |
| 276 | + > a { display: none; } | |
| 277 | + &:before { content: 'Deleting…' } | |
| 278 | + } | |
| 279 | + | |
| 280 | + tr.error > td:last-child { | |
| 281 | + > a { display: none; } | |
| 282 | + | |
| 283 | + &:before { | |
| 284 | + content: 'Error'; | |
| 285 | + color: red; | |
| 286 | + } | |
| 287 | + } | |
| 288 | +} | |
| 289 | + | |
| 245 | 290 | // |
| 246 | 291 | // News |
| 247 | 292 | // |
| @@ -122,7 +122,7 @@ class App < Sinatra::Application · 121 unmodified lines | ||
| 122 | 122 | erb :index |
| 123 | 123 | end |
| 124 | 124 | |
| 125 | − %w(about news help).each do |page| | |
| 125 | + %w(offline about news help).each do |page| | |
| 126 | 126 | get "/#{page}" do |
| 127 | 127 | redirect "/#/#{page}", 302 |
| 128 | 128 | end |