1import StackConstants from 'reader/core/stackers/StackConstants'; 2import MonthStacker from 'reader/core/stackers/MonthStacker'; 3import IssueStackKey from 'reader/core/stackers/IssueStackKey'; 4import TitleStacker from 'reader/core/stackers/TitleStacker'; 5import IssueStack from 'reader/core/stackers/IssueStack'; 6import Request from 'reader/Request'; 7import ApplicationController from 'reader/core/ApplicationController'; 8import Title from 'reader/lib/Title'; 9import Toolbar from 'reader/lib/Toolbar'; 10import API from 'reader/api/API'; 11import Routes from 'reader/Routes'; 12import { findAll, addClass } from 'reader/helpers/dom-utils'; 13import generateUUID from 'reader/helpers/generateUUID'; 14/* 15 * decaffeinate suggestions: 16 * DS102: Remove unnecessary code created because of implicit returns 17 * DS205: Consider reworking code to avoid use of IIFEs 18 * Full docs: https://github.com/decaffeinate/decaffeinate/blob/master/docs/suggestions.md 19 */ 20 21const ISSUES_PER_PAGE = 1000; 22 23export default class IssueViewController extends ApplicationController { 24 constructor(magazine, readerController, targetStackId) { 25 super('#issues'); 26 this.magazine = magazine; 27 this.readerController = readerController; 28 MonthStacker.setMonthNames(this.readerController.phraseFor('months').split(/,/)); 29 30 this.refreshPending = false; 31 32 // For progressive rendering, we will maintain our own (growing) list of 33 // available issues. Each time we retrieve another page of issues, we 34 // append them to the list, regenerate our stacks and adjust the visible 35 // issue-stack boxes to reflect what's changed. 36 this.issues = []; 37 38 this.rootStack = null; 39 40 this.currentStack = null; 41 42 this.targetStackId = `${targetStackId}`; 43 44 this.page = 1; 45 46 this.boxesByStackKey = new Map(); 47 48 if (this.visible()) { 49 this.refreshContent(); 50 this.readerController.showClientLogo(); 51 } 52 53 this.showClientLogo = true; 54 } 55 56 setMagazine(magazine, stackId) { 57 this.magazine = magazine; 58 59 this.targetStackId = stackId; 60 61 this.rootStack = null; 62 63 this.empty(); 64 } 65 66 show() { 67 super.show(); 68 69 Toolbar.hide(); 70 71 if (!this.rootStack) { this.refreshContent(); } 72 } 73 74 refreshContent() { 75 if (!this.magazine) { return; } 76 77 if (this.refreshPending) { return; } 78 79 this.refreshPending = true; 80 81 this.page = 1; 82 83 this.issues = []; 84 this.boxesByStackKey = new Map(); 85 86 this.requestPageOfIssues(); 87 } 88 89 requestPageOfIssues() { 90 const controller = this; 91 const endpoint = `/magazines/${this.magazine.id}/issues/list.json?page=${this.page}&per_page=${ISSUES_PER_PAGE}${controller.readerController.defaultUrlParams('&')}`; 92 93 new Request(endpoint).json() 94 .then((data) => { 95 controller.refreshPending = false; 96 controller.processIssues(data); 97 }) 98 .catch((error) => { 99 controller.refreshPending = false; 100 controller.readerController.reportError(error); 101 }); 102 } 103 104 processIssues({ 105 issues, policy, policyWebp, magazine: magazineId, publisher: publisherId, 106 }) { 107 if (this.page === 1) { 108 if (issues.length === 1) { 109 this.readerController.selectIssue(issues[0].i); 110 111 return; 112 } 113 } 114 115 issues.forEach((issue) => { 116 const coverSpec = `000000${issue.cp}`.slice(-6); 117 118 this.issues.push({ 119 id: issue.i, 120 name: issue.n, 121 coverDate: new Date(issue.c * 1000), 122 publicationDate: issue.p, 123 coverThumbnailWebpUrl: issue.w ? `https://d1zfca9r0ctlm4.cloudfront.net/${publisherId}/${magazineId}/${issue.i}/${issue.p}/webp/2/page-${coverSpec}.webp${policyWebp}` : null, 124 coverThumbnailUrl: `https://d1zfca9r0ctlm4.cloudfront.net/${publisherId}/${magazineId}/${issue.i}/${issue.p}/images/2/page-${coverSpec}.jpg${policy}`, 125 supplementGroup: issue.sg || 0, 126 supplementIndex: issue.si || 0, 127 publicationStatus: issue.ps, 128 }); 129 }); 130 131 this.prepareNewRootStack(); 132 133 this.issues.forEach((issue) => { 134 this.rootStack.addIssue(issue); 135 }); 136 137 // Render what we've got. 138 this.rootStack.condense(StackConstants.MINIMUM_VIABLE_SUBSTACKS); 139 140 const stack = this.rootStack.stackForId(this.targetStackId) || this.rootStack; 141 this.renderStack(stack, this.page > 1); 142 143 if (issues.length < ISSUES_PER_PAGE) { 144 this.targetStackId = ''; 145 return; 146 } 147 148 this.page += 1; 149 150 this.requestPageOfIssues(); 151 } 152 153 selectStack(stackId) { 154 if (this.rootStack) { 155 this.renderStack(this.rootStack.stackForId(stackId) || this.rootStack); 156 } 157 158 this.readerController.pushHistoryViewIssues(this.magazine, stackId); 159 160 this.targetStackId = stackId; 161 162 if (!this.rootStack) { 163 this.refreshContent(); 164 } 165 } 166 167 renderStack(stack, progressive = false) { 168 if (!progressive) { 169 this.empty(); 170 171 this.boxesByStackKey = new Map(); 172 173 const container = document.createElement('nav'); 174 addClass(container, 'issueGrid'); 175 container.setAttribute('aria-label', this.readerController.phraseFor('aria.cho
175oseissue')); 176 177 this.div.appendChild(container); 178 179 const row = document.createElement('div'); 180 addClass(row, 'issueGrid-grid'); 181 182 container.appendChild(row); 183 184 window.scrollTo({ top: 0, behavior: 'smooth' }); 185 } 186 187 this.currentStack = stack; 188 189 let pageTitle = this.magazine.name; 190 191 this.stackPath().forEach((pathElement) => { 192 pageTitle = `${pageTitle} / ${pathElement.label}`; 193 }); 194 195 Title.set(pageTitle); 196 197 this.readerController.updateToolbar(); 198 199 if (progressive) { this.removeRedundantBoxes(stack); } 200 201 return stack.substacks().map((substack, index) => ( 202 this.renderSubstack( 203 substack, 204 index, 205 this.div.querySelector('.issueGrid-grid'), 206 ))); 207 } 208 209 // During progressive rendering, we may create boxes for stacks which are no 210 // longer relevant when the next page of results comes in (e.g. single-issue 211 // stacks may turn into year stacks once more issues have arrived). Remove them 212 // by comparing their stack keys to the set of keys for current substacks. 213 removeRedundantBoxes(stack) { 214 const currentSubstackKeys = new Set(stack.substacks().map((s) => s.issueStackKey.encode())); 215 216 findAll('.issueGrid-issue', this.div).forEach((box) => { 217 const key = box.getAttribute('data-stack-key'); 218 219 if (!currentSubstackKeys.has(key)) { this.removeBox(box); } 220 }); 221 } 222 223 // Removes the specified box 224 removeBox(box) { 225 box.remove(); 226 this.boxesByStackKey.delete(box.getAttribute('data-stack-key')); 227 } 228 229 renderSubstack(substack, index, row) { 230 const encodedKey = substack.issueStackKey.encode(); 231 232 // Are we already showing a box for this stack? Retain it if so, but update 233 // the associated stack as it may now contain more substacks than it did 234 // when we last rendered it. 235 const existingBox = this.boxesByStackKey.get(encodedKey); 236 237 if (existingBox) { 238 existingBox.issueStack = substack; 239 240 return; 241 } 242 243 const controller = this; 244 245 const cover = document.createElement('magazine-cover'); 246 cover.classList.add('issueGrid-issue'); 247 const id = `cover-${generateUUID()}`; 248 cover.setAttribute('id', id); 249 let path; 250 let stack; 251 252 if (substack.singleIssue()) { 253 path = Routes.issueSpreadPath(substack.posterIssue().id); 254 } else { 255 stack = substack.substacks()[1].posterIssue(); 256 path = Routes.magazineIssuesPath(this.magazine.id, substack.idForStack()); 257 } 258 259 cover.setAttribute('path', path); 260 261 let { issue: apiIssue, coverUrls } = API.MagazineCover(substack.posterIssue(), stack); 262 263 if (!substack.singleIssue()) { 264 apiIssue.name = substack.label(); 265 } 266 267 cover.setAttribute('role', 'link'); 268 cover.setAttribute('aria-label', apiIssue.name); 269 270 apiIssue = JSON.stringify(apiIssue); 271 coverUrls = JSON.stringify(coverUrls); 272 273 cover.setAttribute('issue', apiIssue); 274 cover.setAttribute('coverUrls', coverUrls); 275 276 if (substack.posterIssue().supplementGroup) { 277 cover.setAttribute('data-supplement', substack.posterIssue().supplementGroup); 278 } 279 280 row.appendChild(cover); 281 282 const box = this.div.querySelector(`#${id}`); 283 284 box.setAttribute('data-stack-key', encodedKey); 285 box.issueStack = substack; 286 287 this.boxesByStackKey.set(encodedKey, box); 288 289 const findSupplements = () => Array.from( 290 document.querySelectorAll(`[data-supplement="${substack.posterIssue().supplementGroup}"]`), 291 ); 292 293 const highlightSupplements = () => { 294 const supplements = findSupplements(); 295 296 if (supplements.length) { 297 supplements.forEach((supplement) => { 298 supplement.classList.add('supplement-highlight'); 299 }); 300 } 301 }; 302 303 const unHighlightSupplements = () => { 304 const supplements = findSupplements(); 305 306 if (supplements.length) { 307 supplements.forEach((supplement) => { 308 supplement.classList.remove('supplement-highlight'); 309 }); 310 } 311 }; 312 313 if (substack.singleIssue()) { 314 box.addEventListener('mouseover', highlightSupplements); 315 box.addEventListener('mouseleave', unHighlightSupplements); 316 317 box.addEventListener('focus', highlightSupplements); 318 box.addEventListener('blur', unHighlightSupplements); 319 } 320 321 const onClick = (e) => { 322 if (e.metaKey || e.ctrlKey) { return; } 323 e.preventDefault(); 324 325 // Retrieve current stack - it may have changed since box was first 326 // rendered. 327 const myStack = box.issueStack; 328 329 if (myStack.singleIssue()) { 330 controller.readerController.selectIssue(myStack.posterIssue().id); 331 return; 332 } 333 334 const stackId = myStack.idForStack(); 335 336 // Set our target stack id so that any progressive updates don't switch 337 // back to the root stack 338 controller.targetStackId = stackId; 339 340 controller.renderStack(myStack); 341 342 controller.readerController.pushHistoryViewIssues(controller.magazine, stackId); 343 }; 344 345 box.addEventListener('click', onClick); 346 box.addEventListener('keydown', (e) => { 347 if (e.code === 'Enter') onClick(e); 348 }); 349 } 350 351 prepareNewRootStack() {
352 const key = new IssueStackKey(null, 0, 0, 0, StackConstants.STACK_SCALE_TITLE, false); 353 354 const stacker = new TitleStacker(); 355 356 this.rootStack = new IssueStack(key, stacker, null); 357 358 this.currentStack = null; 359 } 360 361 /** 362 * Returns an array of the non-root stacks which lead to the supplied issue. 363 * If issue is null then we return an array of non-root stacks which 364 * lead to the last-rendered stack. 365 */ 366 stackPath(issue = null) { 367 const ret = []; 368 369 if (this.rootStack) { 370 if (issue) { 371 if (this.rootStack) { this.rootStack.tracePath(issue, ret); } 372 } else if (this.currentStack) { 373 let stack = this.currentStack; 374 375 while (stack) { 376 if (stack.idForStack()) { ret.unshift({ label: stack.label(), id: stack.idForStack() }); } 377 378 stack = stack.parentStack; 379 } 380 } 381 } 382 383 return ret; 384 } 385 386 searchRestriction() { 387 // Only stacks with IDs provide useful filters (not root stack or single-issue) 388 if (!this.currentStack || !this.currentStack.idForStack()) { return null; } 389 390 const issues = this.currentStack.issues(); 391 392 const from = issues[issues.length - 1].publicationDate; 393 const to = issues[0].publicationDate; 394 395 return { dateFrom: from, dateTo: to, scope: this.currentStack.label() }; 396 } 397}
Line numbers count LF bytes from the start of the resource, as the search results do. Vendor segments are library code the classifier recognised; they are stored but not indexed. Bytes are shown as Latin1 characters, one per byte.