1import { SetImage } from "../src/ui/ImageRepository.mjs"; 2 3import { CountSpins } from "../src/ui/Toasty.mjs"; 4import { AssetWindow } from "../src/ui/AssetWindow.mjs"; 5import { DeclarePane } from "../src/ui/AssetPane.mjs"; 6import { AuthorCaption } from "../src/ui/AuthorCaption.mjs"; 7import { ComponentGrid } from "../src/skinr/ComponentGrid.mjs"; 8import { DnaStage } from "../src/dna/DnaStage.mjs"; 9import { CurrentSofSource, SkinrAvailable } from "../src/api/SiteSource.mjs"; 10import { UiChromeUrl } from "../src/api/Icons.mjs"; 11import { HasParagonHub, UnavailableReason } from "../src/api/SourceFeatures.mjs"; 12import { Dropdown } from "../src/ui/Dropdown.mjs"; 13import { EveTypeIconURL, EveTypeRenderURL } from "../src/api/EveImageServer.mjs"; 14import { UNRESPONSIVE } from "../src/api/SiteFetch.mjs"; 15import { Busy } from "../src/ui/GridBusy.mjs"; 16import { PageSettings } from "../src/PageSettings.mjs"; 17import { ShareLink } from "../src/dna/DnaAddress.mjs"; 18import { Share } from "../src/ui/ShareDialog.mjs"; 19import { Settings } from "../src/Settings.mjs"; 20import { PlexPrice } from "../src/PlexPrice.mjs"; 21import { RibbonMenu } from "../src/ui/RibbonMenu.mjs"; 22import "../src/ui/OwnershipDisplay.mjs"; 23import { CostTip } from "../src/skinr/CostTip.mjs"; 24import { StageTools, ToolRow } from "../src/ui/StageTools.mjs"; 25import { BannerControl } from "../src/ui/BannerControl.mjs"; 26import { PostProcessControl } from "../src/ui/PostProcessControl.mjs"; 27import { KillmarkControl } from "../src/ui/KillmarkControl.mjs"; 28import { StageFullscreen } from "../src/ui/StageFullscreen.mjs"; 29import { CameraMotionControl } from "../src/ui/CameraMotionControl.mjs"; 30import { SunControl } from "../src/ui/SunControl.mjs"; 31import { SceneLightsControl } from "../src/ui/SceneLightsControl.mjs"; 32import { AutoRotateControl } from "../src/ui/AutoRotateControl.mjs"; 33import { InstallWheelInvert } from "../src/ui/WheelInvert.mjs"; 34import { SceneReflectionControl } from "../src/ui/SceneReflectionControl.mjs"; 35import { ShipAnnotations } from "../src/render/ShipAnnotations.mjs"; 36import { SceneCustomControl } from "../src/ui/SceneCustomControl.mjs"; 37import { StageMemory } from "../src/ui/StageMemory.mjs"; 38import { Json } from "../src/api/Json.mjs"; 39import { CameraAngleControl } from "../src/ui/CameraAngleControl.mjs"; 40import { DesignCost } from "../src/skinr/DesignCostClient.mjs"; 41import { ColumnResizer } from "../src/ui/ColumnResizer.mjs"; 42import { nebulaCatalogue } from "../src/api/Nebulae.mjs"; 43import { ViewSettings } from "../src/ui/ViewSettings.mjs"; 44import { SkinrCardElement, SkinrRenderURL } from "../src/skinr/SkinrCardElement.mjs"; 45import { NUMERIC_GLYPHS, TextScramble } from "../src/ui/TextScramble.mjs"; 46import { UiLayers } from "../src/ui/LayerStack.mjs"; 47import { Me } from "../src/api/Me.mjs"; 48import { AttachSearchClear } from "../src/ui/SearchClear.mjs"; 49import { Remember } from "../src/PageMemory.mjs"; 50import { Ownership } from "../src/api/Ownership.mjs"; 51 52const FINDING_CAPSULEER = "FINDING CAPSULEER"; 53 54const PAGE_SIZE = 1000; 55 56const SORT_DESCENDS = Object.freeze({ 57 recent: true, 58 tier: true, 59 price: false, 60 name: false, 61 hull: false 62}); 63 64const PLEX_TYPE_ID = 44992; 65 66const GROUPS = Object.freeze({ 67 none: null, 68 creator: entry => CharacterLabel(entry.creatorId, "NO CREATOR", entry.creatorName), 69 seller: entry => CharacterLabel(entry.sellerId, "NO SELLER", entry.sellerName), 70 ship: entry => HullHeading(entry), 71 category: entry => (entry.category || "UNCLASSIFIED").toUpperCase(), 72 73 tier: entry => (entry.tierLevel ? `TIER ${String(entry.tierLevel).padStart(2, "0")}` : "NO TIER"), 74 currency: entry => (entry.price?.kind ?? "no price").toUpperCase(), 75 state: entry => (entry.state ?? "unknown").replace(/_/gu, " ").toUpperCase() 76}); 77 78function CharacterLabel(characterId, absent, known = null) 79{ 80 81 if (known) return String(known).toUpperCase(); 82 83 const id = Number(characterId); 84 85 if (!Number.isFinite(id) || id <= 0) return absent; 86 87 const held = identities.get(id); 88 const name = held && typeof held.then !== "function" ? held?.name : null; 89 90 return (name ?? FINDING_CAPSULEER).toUpperCase(); 91} 92 93const MODES = [ 94 { 95 name: "icons", 96 title: "Tiles â click for the list" 97 }, 98 { 99 name: "list", 100 title: "List â click for tiles" 101 } 102]; 103 104const paneEl = document.getElementById("skinr-pane"); 105const filtersEl = document.getElementById("skinr-filters"); 106const columnsEl = document.getElementById("skinr-grid").parentElement; 107const gridHostEl = document.getElementById("skinr-grid"); 108const statusEl = document.getElementById("skinr-status"); 109const searchEl = document.getElementById("skinr-search"); 110 111const modeEl = null; 112 113const nameEl = document.getElementById("skinr-foot-name"); 114const subEl = document.getElementById("skinr-foot-sub"); 115const shipIconEl = document.getElementById("skinr-detail-ship"); 116const pricesEl = document.getElementById("skinr-foot-prices"); 117const stateEl = document.getElementById("skinr-foot-state"); 118 119const detailEl = document.getElementById("skinr-detail"); 120 121const previewEl = document.getElementById("skinr-preview"); 122 123const author = new AuthorCaption({ element: document.getElementById("skinr-detail-author") }); 124 125let sdeBuild = null; 126 127const stageTools = new StageTools({ 128 collapsed: true, 129 storageKey: "skindr:hub:tools", 130 131 icon: path => UiChromeUrl(`res:/ui/texture/${path}`), 132 lookAt: kind => previewStage.renderer?.LookAtDecal?.(kind) 133}); 134 135const skinrToolsHostEl = document.getElementById("skinr-tools"); 136 137skinrToolsHostEl.appendChild(stageTools.element); 138 139const fullscreen = new StageFullscreen({ stage: document.getElementById("skinr-stage"), host: skinrToolsHostEl }); 140 141const hubStage = new StageMemory({ key: "cppc.stage.hub" }); 142 143new CameraMotionControl({ tools: stageTools, renderer: () => previewStage.renderer, memory: hubStage }); 144 145const sunControl = new SunControl({ tools: stageTools, renderer: () => previewStage.renderer, memory: hubStage }); 146 147new SceneLightsControl({ 148 tools: stageTools, 149 renderer: () => previewStage.renderer, 150 memory: hubStage, 151 host: skinrToolsHostEl, 152 before: fullscreen.button, 153 defaultOn: true 154}); 155 156new AutoRotateControl({ 157 tools: stageTools, 158 renderer: () => previewStage.renderer, 159 memory: hubStage, 160 host: skinrToolsHostEl, 161 before: fullscreen.button 162}); 163 164InstallWheelInvert(() => Settings.Load().invertWheel === true); 165 166const nebulaEl = document.createElement("select"); 167 168const BACKGROUNDS = Object.freeze({ 169 none: null, 170 black: [ 0, 0, 0, 1 ] 171}); 172 173const backgroundEl = document.createElement("select"); 174
175for (const name of Object.keys(BACKGROUNDS)) 176{ 177 const option = document.createElement("option"); 178 179 option.value = name; 180 option.textContent = name.toUpperCase(); 181 backgroundEl.appendChild(option); 182} 183 184function ApplySky() 185{ 186 const colour = BACKGROUNDS[backgroundEl.value] ?? null; 187 188 previewStage.renderer?.SetEnvironment?.(!colour); 189 190 if (colour) previewStage.renderer?.SetClearColour?.(colour); 191} 192 193backgroundEl.addEventListener("change", ApplySky); 194 195let stagedNebula = null; 196 197let nebulaPaths = []; 198 199function RollNebula() 200{ 201 202 const pool = nebulaPaths.filter(path => !/empty_background/i.test(path)); 203 204 return pool.length 205 ? pool[Math.floor(Math.random() * pool.length)] 206 : ""; 207} 208 209async function ApplyScene() 210{ 211 const wanted = nebulaEl.value || stagedNebula || RollNebula(); 212 213 if (wanted && wanted !== stagedNebula) 214 { 215 stagedNebula = wanted; 216 await previewStage.SetNebula(wanted).catch(() => { stagedNebula = null; }); 217 sunControl.RestoreFlare(); 218 } 219 220 ApplySky(); 221} 222 223backgroundEl.value = "black"; 224 225hubStage.Bind(backgroundEl, "background"); 226 227nebulaEl.addEventListener("change", async () => 228{ 229 230 stagedNebula = nebulaEl.value || RollNebula(); 231 await previewStage.SetNebula(stagedNebula); 232 sunControl.RestoreFlare(); 233 234 ApplySky(); 235}); 236 237new SceneCustomControl({ tools: stageTools, renderer: () => previewStage.renderer, memory: hubStage }); 238 239stageTools.scene.appendChild(ToolRow("NEBULA", nebulaEl)); 240 241new PostProcessControl({ 242 tools: stageTools, 243 renderer: () => previewStage.renderer, 244 source: CurrentSofSource, 245 row: ToolRow, 246 memory: hubStage 247}); 248stageTools.scene.appendChild(ToolRow("BACKGROUND", backgroundEl)); 249 250new SceneReflectionControl({ tools: stageTools, renderer: () => previewStage.renderer, memory: hubStage }); 251 252nebulaCatalogue.List().then(list => 253{ 254 if (!list.length) 255 { 256 nebulaEl.closest(".dna-tool")?.remove(); 257 258 return; 259 } 260 261 const random = document.createElement("option"); 262 263 random.value = ""; 264 random.textContent = "RANDOM"; 265 nebulaEl.appendChild(random); 266 267 for (const nebula of list) 268 { 269 const option = document.createElement("option"); 270 271 option.value = nebula.path; 272 option.textContent = nebula.name; 273 nebulaEl.appendChild(option); 274 } 275 276 nebulaPaths = list.map(nebula => nebula.path).filter(Boolean); 277 278 hubStage.Bind(nebulaEl, "nebula"); 279}); 280 281const previewStage = new DnaStage({ 282 stage: document.getElementById("skinr-stage"), 283 status: document.getElementById("skinr-stage-status"), 284 tools: stageTools.element, 285 286 squareUp: "always", 287 288 pose: { rotationX: 5.9, rotationY: 0.45 }, 289 290 environment: false, 291 292 clearColour: [ 0.001518, 0.001518, 0.001518, 1 ], 293 294 fitMargin: 1.02 295}); 296 297const annotations = new ShipAnnotations({ 298 host: () => document.getElementById("skinr-stage"), 299 renderer: () => previewStage.renderer 300}); 301 302new BannerControl({ 303 tools: stageTools, 304 renderer: () => previewStage.renderer, 305 memory: hubStage, 306 onHover: on => annotations.Banners(on) 307}); 308 309new KillmarkControl({ 310 tools: stageTools, 311 renderer: () => previewStage.renderer, 312 memory: hubStage, 313 onHover: on => annotations.Killmarks(on) 314}); 315 316const cameraAngle = new CameraAngleControl({ stage: previewStage, storageKey: "skindr:hub:angle" }); 317 318stageTools.camera.appendChild(cameraAngle.row); 319 320const hullNames = new Map(); 321 322const identities = new Map(); 323 324const creatorListings = new Map(); 325 326const filters = { currency: "", state: "", availability: "all" }; 327 328const PERSONAL = new Set([ "own", "mine", "corp", "alliance" ]); 329 330function Availability() 331{ 332 if (!Me.SignedIn()) return "market"; 333 334 const chosen = filters.availability; 335 336 const held = chosen === "own" ? Owned() : personal[chosen]; 337 338 return held === 0 ? "all" : chosen; 339} 340 341const personal = { own: null, mine: null, corp: null, alliance: null }; 342 343let licences = []; 344 345const EMPTY = Object.freeze({ 346 own: "NOTHING OF YOURS YET", 347 mine: "YOU HAVE NOTHING LISTED", 348 corp: "NOTHING AIMED AT YOUR CORP", 349 alliance: "NOTHING AIMED AT YOUR ALLIANCE" 350}); 351 352function ShowAvailability() 353{ 354 const signedIn = Me.SignedIn(); 355 const buttons = [ ...filtersEl.querySelectorAll('button[data-filter="availability"]') ]; 356 357 for (const button of buttons) 358 { 359 const value = button.dataset.value; 360 361 if (value === "market") button.disabled = false;
362 else if (!signedIn) button.disabled = true; 363 else if (value === "all") button.disabled = false; 364 else 365 { 366 const held = value === "own" ? Owned() : personal[value]; 367 368 button.disabled = held === 0; 369 } 370 } 371 372 if (!Me.Known()) return; 373 374 const live = buttons.filter(button => !button.disabled); 375 376 if (live.length === 1 && filters.availability !== live[0].dataset.value) 377 { 378 filters.availability = live[0].dataset.value; 379 ApplyFilterButtons(); 380 } 381} 382 383const WARMING_ATTEMPTS = 5; 384const WARMING_WAIT_MS = 2000; 385 386async function Availabilities() 387{ 388 let answer = null; 389 390 for (let attempt = 0; attempt < WARMING_ATTEMPTS; attempt++) 391 { 392 const next = await Json("/api/me/hub/availability").catch(() => null); 393 394 if (next?.state === "ok") answer = next; 395 396 if (answer && !Object.values(answer.counts).some(count => count === null)) return answer; 397 398 await new Promise(done => setTimeout(done, WARMING_WAIT_MS)); 399 } 400 401 return answer; 402} 403 404function Owned() 405{ 406 if (personal.own === null) return null; 407 408 return personal.own + licences.length; 409} 410 411let shuffleSeed = 0; 412 413let generation = 0; 414let detailGeneration = 0; 415let facetsLoaded = false; 416let plexRate = null; 417let rows = []; 418 419let selectedEntry = null; 420 421let view = Remember("skinr", { 422 423 search: "", 424 mode: MODES[0].name, 425 showPrice: true, 426 showHull: true, 427 previewWidth: 0, 428 429 sort: "random", 430 descending: false, 431 group: "none", 432 tier: "", 433 currency: "", 434 state: "", 435 availability: "all" 436}); 437 438const viewPanelEl = document.getElementById("catalog-view-menu"); 439 440if (view.sort === "price-desc") 441{ 442 view.sort = "price"; 443 view.descending = true; 444} 445 446function ApplyView(redraw = true) 447{ 448 const shared = ViewSettings.Get(); 449 450 ViewSettings.ApplyTo(paneEl); 451 grid.overlays = { tag: shared.showTag }; 452 grid.showName = shared.showName; 453 454 paneEl.classList.toggle("no-colour", !shared.showColour); 455 456 paneEl.classList.toggle("no-price", !view.showPrice); 457 458 if (redraw) grid.Redraw(); 459 else grid.EnsureScrollable(); 460} 461 462ViewSettings.Subscribe(() => ApplyView(true)); 463 464for (const [ id, key ] of Object.entries({ "view-show-price": "showPrice", "view-show-hull": "showHull" })) 465{ 466 const input = document.getElementById(id); 467 468 if (!input) continue; 469 470 input.checked = Boolean(view[key]); 471 input.addEventListener("input", () => 472 { 473 view[key] = input.checked; 474 475 ApplyView(key === "showHull"); 476 }); 477} 478 479const groupDropdown = new Dropdown({ onChange: () => 480{ 481 grid.GroupBy(groupDropdown.Value() || "none"); 482 483 RememberRibbon(); 484} }); 485const tierDropdown = new Dropdown({ onChange: () => Load() }); 486const sortDropdown = new Dropdown({ onChange: () => 487{ 488 489 view.descending = SORT_DESCENDS[sortDropdown.Value()] ?? false; 490 ApplyDirection(); 491 Load(); 492} }); 493 494const grid = new ComponentGrid({ 495 sort: "none", 496 groups: GROUPS, 497 onSelect: (entry, card) => Select(entry, card), 498 card: entry => new SkinrCardElement(entry, { 499 hullName: HullName, 500 tag: ViewSettings.Get().showTag, 501 name: ViewSettings.Get().showName, 502 503 price: true, 504 hull: view.showHull 505 }) 506}); 507 508const listWindowEl = document.createElement("div"); 509const listEl = document.createElement("table"); 510 511listWindowEl.className = "skinr-list-window"; 512listEl.className = "skinr-list"; 513listEl.innerHTML = ` 514 <colgroup> 515 <col class="skinr-col-name"><col class="skinr-col-line"><col class="skinr-col-hull"> 516 <col class="skinr-col-tier"><col class="skinr-col-price"><col class="skinr-col-when"> 517 </colgroup> 518 <thead><tr> 519 <th scope="col">SKIN</th><th scope="col">LINE</th><th scope="col">HULL</th> 520 <th scope="col">TIER</th><th scope="col">PRICE</th><th scope="col">LISTED</th> 521 </tr></thead> 522 <tbody></tbody>`; 523listWindowEl.appendChild(listEl); 524 525const footName = new TextScramble(nameEl, { glitch: true }); 526 527const detailLayer = UiLayers.Register({ 528 isOpen: () => detailEl.classList.contains("open"), 529 close: CloseDetail 530}); 531 532gridHostEl.replaceWith(grid.element); 533document.getElementById("skinr-group").appendChild(groupDropdown.element); 534document.getElementById("skinr-tier").appendChild(tierDropdown.element); 535document.getElementById("skinr-sort").appendChild(sortDropdown.element); 536 537groupDropdown.Show([ 538 { value: "none", label: "NONE" }, 539 { value: "creator", label: "CREATOR" }, 540 { value: "seller", label: "SELLER" }, 541 { value: "ship", label: "SHIP" }, 542 { value: "category", label: "SHIP CLASS" }, 543 { value: "tier", label: "TIER" }, 544 { value: "currency", label: "CURRENCY" }, 545 { value: "state", label: "STATE" } 546], view.group); 547tierDropdown.Show([ { value: "", label: "ANY" } ], view.tier); 548 549sortDropdown.Show([ 550 551 { value: "random", label: "RANDOM" }, 552 { value: "recent", label: "LISTED" }, 553 { value: "price", label: "PRICE" }, 554 { value: "tier", label: "TIER" }, 555 { value: "name", label: "NAME" }, 556 { value: "hull", label: "HULL" } 557], view.sort); 558 559grid.GroupBy(view.group || "none"); 560 561const directionEl = document.getElementById("skinr-sort-direction"); 562 563function ApplyDirection() 564{ 565 566 const shuffling = sortDropdown.Value() === "random"; 567 568 directionEl.classList.toggle("shuffling", shuffling); 569 directionEl.disabled = false;
570 571 if (shuffling) 572 { 573 574 directionEl.removeAttribute("aria-pressed"); 575 directionEl.title = "Shuffle again"; 576 directionEl.setAttribute("aria-label", "Shuffle again"); 577 578 return; 579 } 580 581 directionEl.setAttribute("aria-pressed", view.descending ? "true" : "false"); 582 directionEl.title = view.descending ? "Descending" : "Ascending"; 583 directionEl.setAttribute("aria-label", "Sort direction"); 584} 585 586directionEl.addEventListener("click", () => 587{ 588 589 if (sortDropdown.Value() !== "random") view.descending = !view.descending; 590 591 ApplyDirection(); 592 Load(); 593}); 594 595ApplyDirection(); 596 597const skinrMenu = new RibbonMenu({ ribbon: filtersEl, label: "Paragon Hub settings", id: "skinr-ribbon-menu" }); 598 599PlexPrice.OnChange(() => 600{ 601 rows = rows.map(entry => ({ ...entry, priceLines: PriceLines(entry.price) })); 602 Draw(); 603 604 if (selectedEntry) ShowPrices(selectedEntry, latestCount, latestCost(selectedEntry.skinrId)); 605}); 606 607filters.currency = view.currency; 608filters.state = view.state; 609filters.availability = view.availability ?? "all"; 610 611searchEl.value = view.search ?? ""; 612ApplyFilterButtons(); 613ShowAvailability(); 614 615Me.Load().then(async () => 616{ 617 ShowAvailability(); 618 ApplyFilterButtons(); 619 620 if (filters.availability !== "market") Load(); 621 622 if (!Me.SignedIn()) return; 623 624 const [ counts ] = await Promise.all([ 625 Availabilities(), 626 627 Ownership.LoadDesigns() 628 ]); 629 630 licences = Ownership.Designs(); 631 632 if (counts?.state === "ok") Object.assign(personal, counts.counts); 633 634 const before = filters.availability; 635 636 ShowAvailability(); 637 638 if (filters.availability !== before) Load(); 639}); 640 641ApplyMode(); 642 643async function Build() 644{ 645 if (sdeBuild) return sdeBuild; 646 647 const { target } = CurrentSofSource(); 648 649 try 650 { 651 const response = await fetch(`/${target}/latest/build`); 652 const document_ = response.ok ? await response.json() : null; 653 654 sdeBuild = document_?.builds?.sde ?? "latest"; 655 } 656 catch 657 { 658 sdeBuild = "latest"; 659 } 660 661 return sdeBuild; 662} 663 664function HullName(typeID) 665{ 666 if (!typeID) return Promise.resolve(null); 667 if (hullNames.has(typeID)) return hullNames.get(typeID); 668 669 const pending = Build() 670 .then(build => Json(`/${CurrentSofSource().target}/${build}/types/${typeID}?lang=en`)) 671 .then(type => ({ 672 name: type?.name?.text ?? `TYPE ${typeID}`, 673 group: type?.groupName?.text ?? null, 674 675 category: type?.groupName?.text ?? null 676 })) 677 .catch(() => ({ name: `TYPE ${typeID}`, group: null, category: null })); 678 679 hullNames.set(typeID, pending); 680 681 return pending; 682} 683 684async function PlexRate() 685{ 686 try 687 { 688 const answer = await Json("/v1/market/plex"); 689 690 plexRate = Number.isFinite(Number(answer?.isk)) ? answer : null; 691 PlexPrice.SetMarket(plexRate); 692 } 693 catch 694 { 695 plexRate = null; 696 PlexPrice.SetMarket(null); 697 } 698} 699 700const Grouped = value => Number(value).toLocaleString("en-US", { maximumFractionDigits: 0 }); 701 702function CompactNumber(value) 703{ 704 705 const Short = (scaled, unit) => 706 `${scaled >= 10 ? Math.round(scaled) : Math.round(scaled * 10) / 10}${unit}`; 707 708 if (value >= 1e15) return Short(value / 1e15, "Q"); 709 if (value >= 1e12) return Short(value / 1e12, "T"); 710 if (value >= 1e9) return Short(value / 1e9, "B"); 711 if (value >= 1e6) return Short(value / 1e6, "M"); 712 713 return Grouped(value); 714} 715 716function CompactIsk(value) 717{ 718 return CompactNumber(value); 719} 720 721function PriceText(price) 722{ 723 if (!price || price.value === null || price.value === undefined) return ""; 724 725 const value = Number(price.value); 726 727 return price.kind === "plex" ? `${Grouped(value)} PLEX` : `${CompactIsk(value)} ISK`; 728} 729 730function Query(withoutSearch = false) 731{ 732 const sort = sortDropdown.Value() ?? "random"; 733 const query = new URLSearchParams({ limit: String(PAGE_SIZE), sort }); 734 735 if (sort === "random") query.set("seed", String(shuffleSeed)); 736 737 if (sort !== "random") query.set("direction", view.descending ? "desc" : "asc"); 738 739 if (!withoutSearch && searchEl.value.trim()) query.set("search", searchEl.value.trim()); 740 if (tierDropdown.Value()) query.set("tier", tierDropdown.Value()); 741 if (filters.currency) query.set("currency", filters.currency); 742 if (filters.state) query.set("state", filters.state); 743 744 if (!filters.currency && String(sortDropdown.Value()).startsWith("price")) query.set("currency", "isk"); 745 746 return query; 747} 748 749function ApplyFilterButtons() 750{ 751 for (const button of filtersEl.querySelectorAll("button[data-filter]")) 752 { 753
754 const current = button.dataset.filter === "availability" 755 ? Availability() 756 : filters[button.dataset.filter] ?? ""; 757 758 button.classList.toggle("active", current === button.dataset.value); 759 } 760} 761 762function RememberRibbon() 763{ 764 view.sort = sortDropdown.Value() ?? "random"; 765 view.descending = Boolean(view.descending); 766 view.group = groupDropdown.Value() ?? "none"; 767 view.tier = tierDropdown.Value() ?? ""; 768 view.currency = filters.currency; 769 view.state = filters.state; 770 view.availability = filters.availability; 771 772 view.search = searchEl.value; 773} 774 775filtersEl.addEventListener("click", event => 776{ 777 const button = event.target.closest("button[data-filter]"); 778 779 if (!button || button.disabled) return; 780 781 filters[button.dataset.filter] = button.dataset.value; 782 783 for (const sibling of button.parentElement.querySelectorAll("button")) 784 { 785 sibling.classList.toggle("active", sibling === button); 786 } 787 788 Load(); 789}); 790 791let debounce; 792searchEl.addEventListener("input", () => 793{ 794 clearTimeout(debounce); 795 debounce = setTimeout(Load, 200); 796}); 797 798AttachSearchClear(searchEl, () => { clearTimeout(debounce); Load(); }); 799 800modeEl?.addEventListener("click", () => 801{ 802 const at = MODES.findIndex(mode => mode.name === view.mode); 803 804 view.mode = MODES[(at + 1) % MODES.length].name; 805 ApplyMode(); 806 Draw(); 807}); 808 809function ApplyMode() 810{ 811 const current = MODES.find(mode => mode.name === view.mode) ?? MODES[0]; 812 813 view.mode = current.name; 814 if (modeEl) 815 { 816 modeEl.title = current.title; 817 modeEl.setAttribute("aria-label", current.title); 818 } 819 820 for (const icon of modeEl?.querySelectorAll("[data-mode-icon]") ?? []) 821 { 822 icon.hidden = icon.dataset.modeIcon !== current.name; 823 } 824 825 columnsEl.replaceChildren(current.name === "icons" ? grid.element : listWindowEl); 826} 827 828const hullFacts = new Map(); 829 830const RACES = Object.freeze({ 831 1: "Caldari", 832 2: "Minmatar", 833 4: "Amarr", 834 8: "Gallente", 835 16: "Jove", 836 32: "Society of Conscious Thought" 837}); 838 839let hullIndexBuilt = false; 840 841let hullIndexReady = Promise.resolve(); 842 843function HullFacts(typeID) 844{ 845 if (!typeID) return Promise.resolve(null); 846 if (hullFacts.has(typeID)) return Promise.resolve(hullFacts.get(typeID)); 847 848 const pending = Build() 849 .then(build => Json(`/${CurrentSofSource().target}/${build}/types/${typeID}`)) 850 .then(type => ({ 851 name: type?.name?.text ?? "", 852 race: RACES[type?.raceID] ?? "", 853 group: type?.groupName?.text ?? "" 854 })) 855 .catch(() => ({ name: "", race: "", group: "" })) 856 .then((facts) => 857 { 858 hullFacts.set(typeID, facts); 859 860 return facts; 861 }); 862 863 hullFacts.set(typeID, pending); 864 865 return pending; 866} 867 868function BuildHullIndex(hulls) 869{ 870 if (hullIndexBuilt) return hullIndexReady; 871 872 hullIndexBuilt = true; 873 874 const queue = hulls.map(hull => hull.shipTypeId).filter(Boolean); 875 876 const worker = async () => 877 { 878 while (queue.length) 879 { 880 await HullFacts(queue.shift()); 881 } 882 }; 883 884 hullIndexReady = Promise.all(Array.from({ length: 8 }, worker)).then(() => 885 { 886 887 if (searchEl.value.trim()) Load(); 888 }); 889 890 return hullIndexReady; 891} 892 893function MatchesText(entry, text) 894{ 895 return [ 896 entry.name, 897 entry.line, 898 entry.creatorName, 899 entry.sellerName 900 ].some(value => String(value ?? "").toLowerCase().includes(text)) 901 || MatchesHull(entry, text); 902} 903 904function MatchesHull(entry, text) 905{ 906 const facts = hullFacts.get(entry.typeID); 907 908 if (!facts || typeof facts.then === "function") return false; 909 910 return `${facts.name} ${facts.race} ${facts.group}`.toLowerCase().includes(text); 911} 912 913async function Load() 914{ 915 const mine = ++generation; 916 917 Busy(grid.element, true); 918 919 if (!SkinrAvailable()) 920 { 921 Busy(grid.element, false); 922 grid.Show([]); 923 statusEl.textContent = "TRANQUILITY ONLY"; 924 925 return; 926 } 927 928 shuffleSeed = Math.trunc(Math.random() * 0x7fffffff); 929 RememberRibbon(); 930 931 statusEl.textContent = "LOADING"; 932 933 const availability = Availability(); 934 935 if (PERSONAL.has(availability)) 936 { 937 await ShowPersonal(availability, mine); 938 939 return; 940 } 941 942 try 943 { 944 const text = searchEl.value.trim().toLowerCase(); 945 946 const page = await Json(`/v1/skinr/cards?${Query(Boolean(text))}`); 947 948 if (mine !== generation) return; 949 950 let cards = page.cards; 951
952 const key = sortDropdown.Value(); 953 const whole = text || key === "hull" || groupDropdown.Value() === "ship"; 954 955 if (whole) 956 { 957 while (cards.length < page.total && cards.length < PAGE_SIZE * 4) 958 { 959 const next = await Json(`/v1/skinr/cards?${Query(true)}&offset=${cards.length}`); 960 961 if (mine !== generation) return; 962 if (!next.cards?.length) break; 963 964 cards = cards.concat(next.cards); 965 } 966 } 967 968 rows = cards.map(RowOf); 969 970 if (text) 971 { 972 const named = new Set((await Json(`/v1/skinr/cards?${Query()}`)).cards.map(card => card.listingId)); 973 974 await hullIndexReady; 975 976 if (mine !== generation) return; 977 978 rows = rows.filter(entry => named.has(entry.listingId) || MatchesHull(entry, text)); 979 } 980 981 if (key === "hull" || groupDropdown.Value() === "ship") await hullIndexReady; 982 983 if (mine !== generation) return; 984 985 if (sortDropdown.Value() === "hull") 986 { 987 rows = ByHull(rows); 988 if (view.descending) rows.reverse(); 989 } 990 991 Draw(); 992 993 const matched = text ? rows.length : page.total; 994 995 statusEl.textContent = matched 996 ? `${rows.length.toLocaleString()} SHOWN · ${matched.toLocaleString()} MATCH` 997 : "NO MATCHES"; 998 } 999 catch (error) 1000 { 1001 if (mine !== generation) return; 1002 1003 rows = []; 1004 Draw(); 1005 1006 Busy(grid.element, false); 1007 statusEl.textContent = /501/u.test(error.message) ? "NO HARVEST YET" : UNRESPONSIVE; 1008 } 1009} 1010 1011function PriceLines(price) 1012{ 1013 const value = price ? Number(price.value) : null; 1014 1015 if (!price || !Number.isFinite(value)) return []; 1016 1017 const rate = Number(PlexPrice.Rate()); 1018 const rateKnown = Number.isFinite(rate) && rate > 0; 1019 const listedPlex = price.kind === "plex"; 1020 1021 return [ { 1022 kind: listedPlex ? "plex" : "isk", 1023 icon: listedPlex ? EveTypeIconURL(PLEX_TYPE_ID, { size: 32 }) : null, 1024 text: listedPlex ? CompactNumber(value) : CompactIsk(value), 1025 1026 tip: rateKnown 1027 1028 ? `â ${listedPlex 1029 ? `${CompactIsk(value * rate)} ISK` 1030 : `${value / rate < 1 ? "<1" : CompactNumber(Math.round(value / rate))} PLEX`}` 1031 : null 1032 } ]; 1033} 1034 1035async function ShowPersonal(target, mine) 1036{ 1037 try 1038 { 1039 1040 const [ answer ] = await Promise.all([ 1041 Json(`/api/me/hub/listings?target=${target}`), 1042 target === "own" ? Ownership.LoadDesigns() : null 1043 ]); 1044 1045 if (mine !== generation) return; 1046 1047 licences = Ownership.Designs(); 1048 1049 if (answer.state !== "ok") 1050 { 1051 rows = []; 1052 Draw(); 1053 1054 statusEl.textContent = answer.state === "warming" 1055 ? "ASKING EVE â TRY AGAIN" 1056 1057 : answer.state === "unauthorised" ? "SIGN IN TO SEE THESE" : UNRESPONSIVE; 1058 1059 return; 1060 } 1061 1062 personal[target] = answer.listings.length; 1063 ShowAvailability(); 1064 1065 const all = target === "own" 1066 ? [ ...licences.map(CardOfLicence), ...answer.listings.map(CardOfListing) ].map(RowOf) 1067 : answer.listings.map(CardOfListing).map(RowOf); 1068 1069 await Icons(all); 1070 1071 if (mine !== generation) return; 1072 1073 const text = searchEl.value.trim().toLowerCase(); 1074 1075 if (text) await hullIndexReady; 1076 1077 if (mine !== generation) return; 1078 1079 rows = text ? all.filter(entry => MatchesText(entry, text)) : all; 1080 1081 Draw(); 1082 1083 statusEl.textContent = rows.length 1084 ? `${rows.length.toLocaleString()} SHOWN${text ? ` OF ${all.length.toLocaleString()}` : ""}` 1085 : text 1086 ? "NO MATCHES" 1087 : EMPTY[target] ?? "NOTHING AIMED AT YOU"; 1088 } 1089 catch 1090 { 1091 if (mine !== generation) return; 1092 1093 rows = []; 1094 Draw(); 1095 statusEl.textContent = UNRESPONSIVE; 1096 } 1097} 1098 1099async function Icons(rows) 1100{ 1101 const ids = [ ...new Set(rows.map(row => row.skinrId).filter(Boolean)) ]; 1102 1103 if (!ids.length) return; 1104 1105 try 1106 { 1107 const answer = await Json(`/api/skinr/icons?ids=${ids.map(encodeURIComponent).join(",")}`); 1108 const urls = new Map((answer?.icons ?? []).map(icon => [ icon.skinrID, icon ])); 1109 1110 for (const row of rows) 1111 { 1112 const icon = urls.get(row.skinrId); 1113 1114 if (icon?.url) row.iconURL = icon.url; 1115 } 1116 } 1117 catch 1118 { 1119 1120 } 1121} 1122 1123function CardOfLicence(licence) 1124{ 1125 const design = licence.design ?? {}; 1126 1127 return { 1128 listingId: `licence:${licence.skinrID}`, 1129 skinrId: licence.skinrID, 1130 shipTypeId: design.shipTypeID ?? null, 1131 1132 ...(design.name ? { name: design.name } : {}), 1133 line: design.line ?? "", 1134 tierLevel: design.tierLevel ?? null, 1135 creatorId: design.creatorID ?? null, 1136 creatorName: design.creatorName ?? null, 1137 quantity: licence.unactivated, 1138 1139 state: null, 1140 price: null 1141 }; 1142} 1143 1144function CardOfListing(listing) 1145{ 1146 1147 const price = listing.price?.plex !== null && listing.price?.plex !== undefined 1148 ? { kind: "plex", value: listing.price.plex } 1149 : listing.price?.isk !== null && listing.price?.isk !== undefined 1150 ? { kind: "isk", value: listing.price.isk } 1151 : null; 1152 1153 const design = listing.design ?? {}; 1154 1155 return { 1156 listingId: listing.id, 1157 skinrId: listing.skinrID, 1158 sellerId: listing.sellerID, 1159 quantity: listing.quantity, 1160 state: listing.state, 1161 created: listing.created,
1162 expires: listing.expires, 1163 shipTypeId: design.shipTypeID ?? null, 1164 ...(design.name ? { name: design.name } : {}), 1165 line: design.line ?? "", 1166 tierLevel: design.tierLevel ?? null, 1167 creatorId: design.creatorID ?? null, 1168 1169 creatorName: design.creatorName ?? null, 1170 sellerName: listing.sellerName ?? null, 1171 price 1172 }; 1173} 1174 1175function RowOf(card) 1176{ 1177 return { 1178 listingId: card.listingId, 1179 skinrId: card.skinrId, 1180 typeID: card.shipTypeId ?? null, 1181 1182 harvested: typeof card.name === "string", 1183 name: card.name ?? "NOT HARVESTED", 1184 line: card.line ?? "", 1185 1186 hull: "", 1187 tierLevel: card.tierLevel ?? null, 1188 priceText: PriceText(card.price), 1189 priceLines: PriceLines(card.price), 1190 price: card.price ?? null, 1191 creatorId: card.creatorId ?? null, 1192 sellerId: card.sellerId ?? null, 1193 1194 creatorName: card.creatorName ?? null, 1195 sellerName: card.sellerName ?? null, 1196 quantity: card.quantity ?? null, 1197 state: card.state ?? null, 1198 created: card.created ?? null, 1199 expires: card.expires ?? null, 1200 observedAt: card.observedAt ?? null 1201 }; 1202} 1203 1204function ByHull(list) 1205{ 1206 return [ ...list ].sort((left, right) => NamedHull(left).localeCompare(NamedHull(right)) 1207 || (left.name ?? "").localeCompare(right.name ?? "")); 1208} 1209 1210function NamedHull(entry) 1211{ 1212 const held = hullNames.get(entry.typeID); 1213 const named = held && typeof held.then !== "function" ? held.name : null; 1214 const facts = hullFacts.get(entry.typeID); 1215 const indexed = facts && typeof facts.then !== "function" ? facts.name : null; 1216 1217 return named || entry.hull || indexed || `TYPE ${entry.typeID ?? ""}`; 1218} 1219 1220function HullHeading(entry) 1221{ 1222 return (NamedHull(entry) || "UNKNOWN HULL").toUpperCase(); 1223} 1224 1225function Draw() 1226{ 1227 if (view.mode === "icons") 1228 { 1229 Busy(grid.element, false); 1230 grid.Show(rows); 1231 1232 return; 1233 } 1234 1235 const body = listEl.querySelector("tbody"); 1236 1237 body.replaceChildren(); 1238 1239 for (const entry of rows) 1240 { 1241 const tr = document.createElement("tr"); 1242 1243 tr.className = "skinr-row"; 1244 tr.tabIndex = 0; 1245 tr.dataset.listingId = entry.listingId; 1246 1247 for (const text of [ 1248 entry.name, 1249 entry.line || "â", 1250 1251 NamedHull(entry), 1252 entry.tierLevel ?? "â", 1253 entry.priceText || "â", 1254 entry.created ? entry.created.slice(0, 10) : "â" 1255 ]) 1256 { 1257 const td = document.createElement("td"); 1258 1259 td.textContent = String(text); 1260 td.title = String(text); 1261 tr.appendChild(td); 1262 } 1263 1264 tr.addEventListener("click", () => Select(entry, tr)); 1265 tr.addEventListener("keydown", event => 1266 { 1267 if (event.key !== "Enter" && event.key !== " ") return; 1268 1269 event.preventDefault(); 1270 Select(entry, tr); 1271 }); 1272 1273 body.appendChild(tr); 1274 1275 if (entry.typeID) 1276 { 1277 HullName(entry.typeID).then(named => 1278 { 1279 if (!named) return; 1280 1281 entry.hull = named.name; 1282 entry.hullGroup = named.group ?? null; 1283 entry.category = named.category ?? null; 1284 tr.children[2].textContent = entry.hull; 1285 tr.children[2].title = entry.hull; 1286 }); 1287 } 1288 } 1289} 1290 1291async function CountForDesign(entry) 1292{ 1293 if (!entry.creatorId || !entry.skinrId) 1294 { 1295 return { 1296 value: null, 1297 note: "Not counted: this listing's design is not harvested, so it has no creator to count by." 1298 }; 1299 } 1300 1301 if (!creatorListings.has(entry.creatorId)) 1302 { 1303 creatorListings.set(entry.creatorId, Json( 1304 `/v1/skinr/cards?limit=1000&creatorId=${entry.creatorId}` 1305 ).catch(() => null)); 1306 } 1307 1308 const page = await creatorListings.get(entry.creatorId); 1309 1310 if (!page) return { value: null, note: "Not counted: the creator's listings could not be read." }; 1311 1312 if (page.total > page.cards.length) 1313 { 1314 return { 1315 value: null, 1316 note: `Not counted: this creator has ${page.total.toLocaleStr
1316ing()} listings,` 1317 + " more than one page carries." 1318 }; 1319 } 1320 1321 return { 1322 value: page.cards.filter(card => card.skinrId === entry.skinrId).length, 1323 note: "Listings advertising this design, counted across this creator's listings." 1324 }; 1325} 1326 1327function PriceCells(entry, count, cost) 1328{ 1329 const price = entry.price; 1330 const kind = price?.kind ?? null; 1331 const value = price ? Number(price.value) : null; 1332 const rate = Number(PlexPrice.Rate()); 1333 const rateKnown = Number.isFinite(rate) && rate > 0; 1334 const isk = kind === "isk" 1335 ? { text: Grouped(value), estimate: false } 1336 : kind === "plex" && rateKnown 1337 1338 ? { text: `â ${Grouped(value * rate)}`, estimate: true } 1339 : { text: "â", estimate: false }; 1340 1341 const plex = kind === "plex" 1342 ? { text: Grouped(value), estimate: false } 1343 : kind === "isk" && rateKnown 1344 ? { text: `â ${Grouped(value / rate)}`, estimate: true } 1345 : { text: "â", estimate: false }; 1346 1347 const iskNote = isk.estimate ? `â ${isk.text.replace(/^â /u, "")} ISK at the current rate.` : ""; 1348 const plexNote = plex.estimate ? `â ${plex.text.replace(/^â /u, "")} PLEX at the current rate.` : ""; 1349 1350 return [ 1351 { 1352 kind: "count", 1353 label: "LISTINGS", 1354 text: count?.value === null || count === null ? "â" : Grouped(count.value), 1355 estimate: false, 1356 title: count?.note ?? "Counting" 1357 }, 1358 { 1359 kind: "count", 1360 1361 label: "QTY", 1362 size: "qty", 1363 text: entry.quantity === null || entry.quantity === undefined ? "â" : Grouped(entry.quantity), 1364 estimate: false, 1365 title: "How many of this design are in this listing." 1366 }, 1367 1368 { kind: "price", size: "plex", label: "PLEX", icon: EveTypeIconURL(PLEX_TYPE_ID, { size: 32 }), ...plex }, 1369 { kind: "price", label: "ISK", ...isk }, 1370 CostCell(cost, isk.text.replace(/^â\s*/u, "")) 1371 ]; 1372} 1373 1374function CostCell(cost, waitingAt) 1375{ 1376 const cell = { kind: "price", label: "EST. BUILD" }; 1377 1378 const rate = Number(PlexPrice.Rate()); 1379 const Build = answer => 1380 { 1381 if (!answer || answer.state !== "priced") return null; 1382 if (!answer.sequencing?.plex) return null; 1383 if (!Number.isFinite(rate) || rate <= 0) return null; 1384 1385 return answer.total + answer.sequencing.plex * rate; 1386 }; 1387 1388 if (!cost) 1389 { 1390 return { 1391 ...cell, 1392 text: waitingAt || "0.00", 1393 estimate: false, 1394 pending: true 1395 }; 1396 } 1397 1398 const build = Build(cost); 1399 1400 if (build !== null) 1401 { 1402 return { 1403 ...cell, 1404 text: Grouped(build), 1405 1406 estimate: true, 1407 cost 1408 }; 1409 } 1410 1411 if (cost.state === "priced") 1412 { 1413 return { 1414 ...cell, 1415 text: Grouped(cost.total), 1416 estimate: true, 1417 cost 1418 }; 1419 } 1420 1421 const unpriced = cost.parts?.filter(part => part.state === "unpriced").length ?? 0; 1422 const failed = cost.parts?.filter(part => part.state === "error").length ?? 0; 1423 const total = cost.parts?.length ?? 0; 1424 1425 return { 1426 ...cell, 1427 text: "UNKNOWN", 1428 estimate: false, 1429 unknown: true, 1430 1431 title: total === 0 1432 ? "The parts could not be looked up." 1433 : failed 1434 ? `${failed} of ${total} parts could not be looked up, so this cannot be totalled.` 1435 : `${unpriced} of ${total} parts have no sell orders in Jita, so this cannot be totalled.`, 1436 cost 1437 }; 1438} 1439 1440const CELL_STAGGER_MS = 70; 1441 1442let priceTimers = []; 1443 1444let latestCount = null; 1445 1446const seenCosts = new Map(); 1447 1448function latestCost(designID) 1449{ 1450 return designID ? seenCosts.get(designID) ?? null : null; 1451} 1452 1453function ShowPrices(entry, count, cost = null, untilCost = null) 1454{ 1455 const stats = document.createElement("dl"); 1456 const figures = []; 1457 1458 stats.className = "cat-market-stats"; 1459 1460 for (const timer of priceTimers) clearTimeout(timer); 1461 priceTimers = []; 1462 1463 for (const cell of PriceCells(entry, count, cost)) 1464 { 1465 const pair = document.createElement("div"); 1466 const term = document.createElement("dt"); 1467 const detail = document.createElement("dd"); 1468 1469 pair.className = `cat-market-pair ${cell.kind}${cell.size ? ` ${cell.size}` : ""}`; 1470 1471 detail.textContent = cell.text; 1472 1473 if (cell.title) detail.title = cell.title; 1474 if (cell.estimate) detail.classList.add("estimate"); 1475 if (cell.unknown) detail.classList.add("unknown"); 1476 term.textContent = cell.label; 1477 1478 if (cell.cost?.parts?.length) 1479 { 1480 pair.classList.add("has-tip"); 1481 pair.appendChild(CostTip(cell.cost)); 1482 } 1483 1484 if (cell.icon) 1485 { 1486 const icon = document.createElement("img"); 1487 1488 icon.className = "cat-market-icon"; 1489 icon.src = cell.icon; 1490 icon.alt = ""; 1491 1492 icon.setAttribute("aria-hidden", "true");
1493 term.prepend(icon); 1494 } 1495 1496 pair.append(detail, term); 1497 stats.appendChild(pair); 1498 figures.push({ detail, cell }); 1499 } 1500 1501 pricesEl.replaceChildren(stats); 1502 1503 const waiting = Boolean(untilCost) && !cost; 1504 1505 figures.forEach(({ detail, cell }, index) => 1506 { 1507 1508 if (waiting) 1509 { 1510 const scramble = new TextScramble(detail, { glyphs: NUMERIC_GLYPHS }); 1511 1512 const settled = cell.text; 1513 1514 priceTimers.push(setTimeout(() => scramble.Hold(settled, untilCost), index * CELL_STAGGER_MS)); 1515 1516 return; 1517 } 1518 1519 const scramble = new TextScramble(detail, { glyphs: NUMERIC_GLYPHS }); 1520 1521 priceTimers.push(setTimeout(() => scramble.Play(), index * CELL_STAGGER_MS)); 1522 }); 1523 1524 const marks = []; 1525 1526 if (Ownership.HasDesign(entry.skinrId)) marks.push(OwnedMark()); 1527 1528 if (entry.state) 1529 { 1530 const state = document.createElement("span"); 1531 1532 state.className = "skinr-state"; 1533 state.textContent = String(entry.state).toUpperCase().replace(/_/gu, " "); 1534 state.tabIndex = 0; 1535 marks.push(state); 1536 } 1537 1538 stateEl.replaceChildren(...marks, DateTip(entry), ShareControl(entry)); 1539 1540 PaintCloseControl(); 1541} 1542 1543function OwnedMark() 1544{ 1545 const owned = document.createElement("span"); 1546 1547 owned.className = "skinr-state skinr-state-owned"; 1548 owned.textContent = "OWNED"; 1549 owned.tabIndex = 0; 1550 1551 return owned; 1552} 1553 1554let closeControl = null; 1555 1556function ShowCloseControl() 1557{ 1558 1559 const ribbon = document.getElementById("skinr-filters"); 1560 1561 if (!ribbon) return false; 1562 1563 closeControl = closeControl ?? BuildCloseControl(); 1564 1565 if (closeControl.parentNode !== ribbon) ribbon.appendChild(closeControl); 1566 1567 PaintCloseControl(); 1568 1569 return true; 1570} 1571 1572function PaintCloseControl() 1573{ 1574 if (!closeControl) return false; 1575 1576 const open = previewEl.classList.contains("open"); 1577 1578 const ready = Boolean(selectedEntry); 1579 const title = !ready 1580 ? "Pick a design to see it painted" 1581 : (open ? "Close the painted view" : "Show the painted view"); 1582 1583 closeControl.disabled = !ready; 1584 closeControl.title = title; 1585 closeControl.setAttribute("aria-label", title); 1586 closeControl.setAttribute("aria-expanded", String(open)); 1587 closeControl.classList.toggle("collapsed", !open); 1588 1589 return true; 1590} 1591 1592function BuildCloseControl() 1593{ 1594 const close = document.createElement("button"); 1595 1596 close.type = "button"; 1597 close.className = "skinr-state-close skinr-ribbon-fold"; 1598 1599 close.innerHTML = '<svg viewBox="0 0 12 12" aria-hidden="true"><path d="M7.5 1.5 3 6l4.5 4.5"/></svg>'; 1600 1601 close.addEventListener("click", (event) => 1602 { 1603 1604 event.stopPropagation(); 1605 1606 if (previewEl.classList.contains("open")) ClosePreview(); 1607 else OpenPreview(); 1608 1609 PaintCloseControl(); 1610 }); 1611 1612 return close; 1613} 1614 1615ShowCloseControl(); 1616 1617CountSpins({ stage: document.getElementById("skinr-stage"), renderer: () => previewStage.renderer }); 1618 1619function ShareControl(entry) 1620{ 1621 const button = document.createElement("button"); 1622 1623 button.type = "button"; 1624 button.className = "skinr-share"; 1625 button.textContent = "SHARE"; 1626 1627 const settings = Settings.Load(); 1628 1629 if (!ShareLink(entry, settings)) return document.createDocumentFragment(); 1630 1631 button.addEventListener("click", event => 1632 { 1633 event.stopPropagation(); 1634 1635 Share({ 1636 link: view => ShareLink(entry, settings, view), 1637 options: [ { 1638 key: "fullscreen", 1639 label: "Fullscreen", 1640 title: "Opens with the picture filling their screen" 1641 } ] 1642 }); 1643 }); 1644 1645 return button; 1646} 1647 1648async function Select(entry, opener = null) 1649{ 1650 if (!entry) return; 1651 1652 const mine = ++detailGeneration; 1653 1654 selectedEntry = entry; 1655 latestCount = null; 1656 1657 detailLayer.Open(opener); 1658 detailEl.classList.add("open"); 1659 1660 OpenPreview(); 1661 1662 for (const chosen of listEl.querySelectorAll("tr.chosen")) chosen.classList.remove("chosen"); 1663 if (opener?.tagName === "TR") opener.classList.add("chosen"); 1664 1665 let painted = null; 1666 const untilPainted = new Promise(resolve => { painted = resolve; }); 1667 1668 author.until = untilPainted; 1669 ShowTitle(entry, mine, untilPainted); 1670 1671 author.Show(entry); 1672 1673 const design = entry.skinrId; 1674 const pricing = design ? DesignCost(design) : null; 1675 1676 ShowPrices(entry, null, latestCost(design), pricing); 1677 1678 if (pricing) 1679 { 1680 pricing.then(cost => 1681 { 1682 1683 const answer = cost ?? { state: "error", parts: [] }; 1684 1685 seenCosts.set(design, answer); 1686 1687 if (mine !== detailGeneration) return; 1688 1689 ShowPrices(entry, latestCount, answer); 1690 }); 1691 } 1692 1693 const count = await CountForDesign(entry); 1694 1695 if (mine !== detailGeneration) return; 1696 1697 latestCount = count; 1698 ShowPrices(entry, count, latestCost(design), pricing); 1699 1700 if (!entry.skinrId) 1701 { 1702 previewStage.Show(null); 1703 1704 return; 1705 } 1706 1707 try 1708 { 1709 1710 const design = await Json(`/api/skinr/designs/${entry.skinrId}`); 1711 1712 if (mine !== detailGeneration) return; 1713 1714 window.skinrSelected = design; 1715 1716 const pose = hubResumePose; 1717 1718 hubResumePose = null; 1719 previewStage.ShowSkinr(design).then(() => 1720 { 1721 if (pose && mine === detailGeneration) previewStage.renderer?.SetCameraPose?.(pose); 1722 1723 ApplyScene(); 1724 painted(); 1725 }, painted); 1726 } 1727 catch 1728 { 1729 if (mine === detailGeneration) window.skinrSelected = null; 1730 painted(); 1731 } 1732} 1733 1734function DateTip(entry) 1735{ 1736 const tip = document.createElement("div"); 1737 const heading = document.createElement("strong"); 1738 1739 tip.className = "hub-tip skinr-datetip"; 1740 1741 heading.textContent = entry.state 1742 ? (entry.state === "listed" ? "ON OFFER" : "NOT ON OFFER") 1743 : "OWNED"; 1744 tip.appendChild(heading); 1745 1746 for (const [ label, value ] of [ 1747 [ "LISTED", entry.created ], 1748 [ "EXPIRES", entry.expires ], 1749 [ "SEEN", entry.observedAt ] 1750 ].filter(([ , stamp ]) => stamp).map(([ label, stamp ]) => [ label, When(stamp) ])) 1751 { 1752 const term = document.createElement("span"); 1753 const figure = document.createElement("span"); 1754 1755 term.className = "hub-tip-label"; 1756 term.textContent = label; 1757 figure.className = "hub-tip-value"; 1758 figure.textContent = value; 1759 tip.append(term, figure); 1760 } 1761 1762 return tip; 1763} 1764 1765function ShowShipIcon(typeID) 1766{ 1767 const img = shipIconEl?.querySelector("img"); 1768 1769 if (!img) return; 1770 1771 img.hidden = false;
1772 SetImage(img, typeID ? EveTypeIconURL(typeID, { size: 64 }) : null); 1773} 1774 1775function When(stamp) 1776{ 1777 return stamp ? stamp.replace("T", " ").slice(0, 16) : "â"; 1778} 1779 1780function OpenPreview() 1781{ 1782 if (previewEl.classList.contains("open")) return; 1783 1784 previewEl.classList.add("open"); 1785 previewStage.Activate(); 1786 1787 PaintCloseControl(); 1788 1789 const focused = document.activeElement; 1790 const tag = String(focused?.tagName ?? "").toLowerCase(); 1791 1792 if (tag === "input" || tag === "textarea") focused.blur?.(); 1793} 1794 1795function ClosePreview() 1796{ 1797 if (!previewEl.classList.contains("open")) return; 1798 1799 previewEl.classList.remove("open"); 1800 previewStage.Deactivate(); 1801 PaintCloseControl(); 1802} 1803 1804function CloseDetail() 1805{ 1806 ++detailGeneration; 1807 selectedEntry = null; 1808 latestCount = null; 1809 detailEl.classList.remove("open"); 1810 detailLayer.Closed(); 1811 ClosePreview(); 1812 PaintCloseControl(); 1813 1814 for (const chosen of grid.element.querySelectorAll(".cmp-card.chosen")) chosen.classList.remove("chosen"); 1815 for (const chosen of listEl.querySelectorAll("tr.chosen")) chosen.classList.remove("chosen"); 1816 1817 footName.Play(""); 1818 nameEl.removeAttribute("title"); 1819 subEl.textContent = ""; 1820 author.Clear(); 1821 pricesEl.replaceChildren(); 1822 stateEl.replaceChildren(); 1823 grid.EnsureScrollable(); 1824} 1825 1826async function LoadFacets() 1827{ 1828 if (facetsLoaded) return; 1829 1830 facetsLoaded = true; 1831 1832 try 1833 { 1834 const facets = await Json("/v1/skinr/facets"); 1835 1836 BuildHullIndex(facets.hulls ?? []); 1837 1838 const tiers = facets.tiers ?? []; 1839 const keepTier = tiers.some(entry => String(entry.tier) === view.tier) ? view.tier : ""; 1840 1841 tierDropdown.Show([ 1842 { value: "", label: "ANY" }, 1843 ...tiers.map(entry => ({ 1844 value: String(entry.tier), 1845 label: `${entry.tier} (${entry.n})` 1846 })) 1847 ], keepTier); 1848 1849 for (const [ field, measured, key ] of [ 1850 [ "currency", facets.currencies, "currency" ], 1851 [ "state", facets.states, "state" ] 1852 ]) 1853 { 1854 for (const button of filtersEl.querySelectorAll(`button[data-filter="${field}"]`)) 1855 { 1856 const found = measured?.find(entry => entry[key] === button.dataset.value); 1857 1858 if (found) button.title = `${found.n.toLocaleString()} at this harvest`; 1859 } 1860 } 1861 1862 ApplyFilterButtons(); 1863 } 1864 catch 1865 { 1866 facetsLoaded = false; 1867 } 1868} 1869 1870function Identity(characterId) 1871{ 1872 const id = Number(characterId); 1873 1874 if (!Number.isFinite(id) || id <= 0) return Promise.resolve(null); 1875 if (identities.has(id)) return identities.get(id); 1876 1877 const pending = fetch(`/v1/identity/characters/${id}`) 1878 .then(response => (response.ok ? response.json() : null)) 1879 .catch(() => null); 1880 1881 identities.set(id, pending); 1882 1883 return pending; 1884} 1885 1886async function ShowTitle(entry, mine, untilPainted = null) 1887{ 1888 const skin = entry.harvested ? entry.name : "DESIGN NOT HARVESTED"; 1889 1890 if (untilPainted) footName.Hold(skin.toUpperCase(), untilPainted); 1891 else footName.Play(skin.toUpperCase()); 1892 nameEl.title = entry.harvested ? skin : "This listing's design has not been harvested yet"; 1893 1894 subEl.textContent = ""; 1895 ShowShipIcon(entry.typeID); 1896 1897 if (!entry.typeID) return; 1898 1899 const named = await HullName(entry.typeID); 1900 1901 if (mine !== detailGeneration || !named) return; 1902 1903 const facts = await HullFacts(entry.typeID); 1904 1905 if (mine !== detailGeneration) return; 1906 1907 const line = [ named.name, named.group, facts?.race ] 1908 .filter(Boolean).join(" / ").toUpperCase(); 1909 1910 if (untilPainted) new TextScramble(subEl).Hold(line, untilPainted); 1911 else subEl.textContent = line; 1912} 1913 1914DeclarePane({ 1915 name: "skinr", 1916 label: "PARAGON HUB", 1917 feature: "hub", 1918 pane: paneEl, 1919 1920 available: HasParagonHub, 1921 unavailable: () => UnavailableReason("The Paragon Hub"), 1922 1923 catalog: true, 1924 focus: searchEl, 1925 activate: async () => 1926 { 1927 1928 skinrMenu.Adopt(viewPanelEl); 1929 ViewSettings.ShowSection(viewPanelEl, "skinr"); 1930 ApplyView(false); 1931 1932 await PlexRate(); 1933 await LoadFacets(); 1934 1935 if (rows.length && RestoreHubTab()) return; 1936 1937 await Load(); 1938 RestoreHubSelection(); 1939 }, 1940 deactivate: () => 1941 { 1942 ++generation; 1943 1944 CaptureHubTab(); 1945 1946 CloseDetail(); 1947 groupDropdown.Close(); 1948 tierDropdown.Close(); 1949 sortDropdown.Close(); 1950 } 1951}); 1952 1953Object.defineProperty(window, "skinrPlexRate", { get: () => plexRate }); 1954 1955const previewResizer = new ColumnResizer({ 1956 host: paneEl, 1957 column: document.getElementById("skinr-preview"), 1958 variable: "--skinr-preview-column", 1959 width: Number(view.previewWidth) || 0, 1960 onChange: width => 1961 { 1962 view.previewWidth = width; 1963 1964 previewStage.Reframe?.(); 1965 } 1966}); 1967 1968const HUB_RESUME_KEY = "skindr:hub:resume"; 1969 1970let hubResumePose = null; 1971 1972let hubResumeConsumed = false;
1973 1974let hubTab = null; 1975 1976function ScrollHost() 1977{ 1978 const candidates = [ grid.element, listWindowEl, grid.element?.parentElement, paneEl ]; 1979 1980 return candidates.find(el => el && el.scrollHeight > el.clientHeight + 1) ?? null; 1981} 1982 1983function CaptureHubTab() 1984{ 1985 hubTab = { 1986 listingId: selectedEntry?.listingId ?? null, 1987 skinrId: selectedEntry?.skinrId ?? null, 1988 scrollTop: ScrollHost()?.scrollTop ?? 0, 1989 1990 pose: previewStage.renderer?.ReadCameraPose?.() ?? null 1991 }; 1992} 1993 1994function RestoreHubTab() 1995{ 1996 const state = hubTab; 1997 1998 hubTab = null; 1999 2000 if (!state) return false; 2001 2002 const found = state.listingId || state.skinrId 2003 ? grid.Reveal(row => (state.listingId && row.listingId === state.listingId) 2004 || (state.skinrId && row.skinrId === state.skinrId)) 2005 : null; 2006 2007 if (found) 2008 { 2009 2010 hubResumePose = state.pose ?? null; 2011 Select(found.entry, view.mode === "icons" 2012 ? found.card 2013 : listEl.querySelector(`tr[data-listing-id="${CSS.escape(found.entry.listingId)}"]`)); 2014 } 2015 2016 const host = ScrollHost(); 2017 2018 if (host) host.scrollTop = state.scrollTop; 2019 2020 return true; 2021} 2022 2023window.addEventListener("pagehide", () => 2024{ 2025 try 2026 { 2027 2028 if (!selectedEntry) sessionStorage.removeItem(HUB_RESUME_KEY); 2029 else sessionStorage.setItem(HUB_RESUME_KEY, JSON.stringify({ 2030 listingId: selectedEntry.listingId ?? null, 2031 skinrId: selectedEntry.skinrId ?? null, 2032 2033 pose: previewStage.renderer?.ReadCameraPose?.() ?? null 2034 })); 2035 } 2036 catch { } 2037}); 2038 2039function RestoreHubSelection() 2040{ 2041 if (hubResumeConsumed) return; 2042 2043 hubResumeConsumed = true; 2044 2045 let record = null; 2046 2047 try 2048 { 2049 record = JSON.parse(sessionStorage.getItem(HUB_RESUME_KEY) || "null"); 2050 sessionStorage.removeItem(HUB_RESUME_KEY); 2051 } 2052 catch { } 2053 2054 if (!record || selectedEntry) return; 2055 2056 const entry = rows.find(row => row.listingId === record.listingId) 2057 ?? rows.find(row => record.skinrId && row.skinrId === record.skinrId); 2058 2059 if (!entry) return; 2060 2061 hubResumePose = record.pose ?? null; 2062 Select(entry); 2063}
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.