diff --git a/package-lock.json b/package-lock.json index 77af5e69..1f2c7f8e 100644 --- a/package-lock.json +++ b/package-lock.json @@ -38,6 +38,7 @@ "file-replace-loader": "^1.4.2", "glob": "^11.0.1", "globals": "^16.0.0", + "happy-dom": "^20.12.0", "html-loader": "^5.1.0", "ignore-loader": "^0.1.2", "lit": "^3.2.1", @@ -80,35 +81,38 @@ } }, "node_modules/@emnapi/core": { - "version": "1.11.1", - "resolved": "https://registry.npmjs.org/@emnapi/core/-/core-1.11.1.tgz", - "integrity": "sha512-RSvbQmHzdKzNsLYa/wHrbc3KN4sYLKAdPZxqiM2HATqv/SBk2/ENSHpvXGaLOMcsAyz0poEGqkmmKYG3OWiJEQ==", + "version": "1.11.3", + "resolved": "https://registry.npmjs.org/@emnapi/core/-/core-1.11.3.tgz", + "integrity": "sha512-zLpS5asjEb7lq8jYLq37N6XKaE41DIexlY1rF/z4/tIl3wo13Sqm28fRyfIsKZD+NZ8mM5RoKkpW/rBcuoSZSg==", "dev": true, "license": "MIT", "optional": true, + "peer": true, "dependencies": { - "@emnapi/wasi-threads": "1.2.2", + "@emnapi/wasi-threads": "1.2.3", "tslib": "^2.4.0" } }, "node_modules/@emnapi/runtime": { - "version": "1.11.1", - "resolved": "https://registry.npmjs.org/@emnapi/runtime/-/runtime-1.11.1.tgz", - "integrity": "sha512-vgj7R3y3Wgx24IQaGPA/R6YFXLHVMOZ0uVEyIQPaWs+rd1AzfEMXlAC22FYwO1XkKR6NPsq7mUandH8oIRdZFw==", + "version": "1.11.3", + "resolved": "https://registry.npmjs.org/@emnapi/runtime/-/runtime-1.11.3.tgz", + "integrity": "sha512-Xz4Tpyki7XyrpbUK1jR1AhdAdaXyhhY4lZ3neLodmhpuWfy2PAQN5B46sAiU4liOXGLkHypn/qU+jvfWSCYYLA==", "dev": true, "license": "MIT", "optional": true, + "peer": true, "dependencies": { "tslib": "^2.4.0" } }, "node_modules/@emnapi/wasi-threads": { - "version": "1.2.2", - "resolved": "https://registry.npmjs.org/@emnapi/wasi-threads/-/wasi-threads-1.2.2.tgz", - "integrity": "sha512-c95qOXkHdydNKhscBTebqEC1CVAZpyqOfVfBzQ1qgzyl3gfeldUjIggDbIZgDKsHLgnsM+igH7TJ/eAasaVuMA==", + "version": "1.2.3", + "resolved": "https://registry.npmjs.org/@emnapi/wasi-threads/-/wasi-threads-1.2.3.tgz", + "integrity": "sha512-ELEBe8PsLvvJ6QMr0zLt8ffvOHW/dc1m3CEzNMg7aJUv3bMaoDtw2TXyDAwkYBuroxxuHEwhRTLJSe5sya547g==", "dev": true, "license": "MIT", "optional": true, + "peer": true, "dependencies": { "tslib": "^2.4.0" } @@ -750,6 +754,40 @@ "node": "^20.19.0 || >=22.12.0" } }, + "node_modules/@rolldown/binding-wasm32-wasi/node_modules/@emnapi/core": { + "version": "1.11.1", + "resolved": "https://registry.npmjs.org/@emnapi/core/-/core-1.11.1.tgz", + "integrity": "sha512-RSvbQmHzdKzNsLYa/wHrbc3KN4sYLKAdPZxqiM2HATqv/SBk2/ENSHpvXGaLOMcsAyz0poEGqkmmKYG3OWiJEQ==", + "dev": true, + "license": "MIT", + "optional": true, + "dependencies": { + "@emnapi/wasi-threads": "1.2.2", + "tslib": "^2.4.0" + } + }, + "node_modules/@rolldown/binding-wasm32-wasi/node_modules/@emnapi/runtime": { + "version": "1.11.1", + "resolved": "https://registry.npmjs.org/@emnapi/runtime/-/runtime-1.11.1.tgz", + "integrity": "sha512-vgj7R3y3Wgx24IQaGPA/R6YFXLHVMOZ0uVEyIQPaWs+rd1AzfEMXlAC22FYwO1XkKR6NPsq7mUandH8oIRdZFw==", + "dev": true, + "license": "MIT", + "optional": true, + "dependencies": { + "tslib": "^2.4.0" + } + }, + "node_modules/@rolldown/binding-wasm32-wasi/node_modules/@emnapi/wasi-threads": { + "version": "1.2.2", + "resolved": "https://registry.npmjs.org/@emnapi/wasi-threads/-/wasi-threads-1.2.2.tgz", + "integrity": "sha512-c95qOXkHdydNKhscBTebqEC1CVAZpyqOfVfBzQ1qgzyl3gfeldUjIggDbIZgDKsHLgnsM+igH7TJ/eAasaVuMA==", + "dev": true, + "license": "MIT", + "optional": true, + "dependencies": { + "tslib": "^2.4.0" + } + }, "node_modules/@rolldown/binding-win32-arm64-msvc": { "version": "1.1.4", "resolved": "https://registry.npmjs.org/@rolldown/binding-win32-arm64-msvc/-/binding-win32-arm64-msvc-1.1.4.tgz", @@ -934,6 +972,23 @@ "dev": true, "license": "MIT" }, + "node_modules/@types/whatwg-mimetype": { + "version": "3.0.2", + "resolved": "https://registry.npmjs.org/@types/whatwg-mimetype/-/whatwg-mimetype-3.0.2.tgz", + "integrity": "sha512-c2AKvDT8ToxLIOUlN51gTiHXflsfIFisS4pO7pDPoKouJCESkhZnEy623gwP9laCy5lnLDAw1vAzu2vM2YLOrA==", + "dev": true, + "license": "MIT" + }, + "node_modules/@types/ws": { + "version": "8.18.1", + "resolved": "https://registry.npmjs.org/@types/ws/-/ws-8.18.1.tgz", + "integrity": "sha512-ThVF6DCVhA8kUGy+aazFQ4kXQ7E1Ty7A3ypFOe0IcJV8O/M511G99AW24irKrW56Wt44yG9+ij8FaqoBGkuBXg==", + "dev": true, + "license": "MIT", + "dependencies": { + "@types/node": "*" + } + }, "node_modules/@typescript-eslint/eslint-plugin": { "version": "8.29.1", "dev": true, @@ -1641,6 +1696,19 @@ "dev": true, "license": "MIT" }, + "node_modules/buffer-image-size": { + "version": "0.6.4", + "resolved": "https://registry.npmjs.org/buffer-image-size/-/buffer-image-size-0.6.4.tgz", + "integrity": "sha512-nEh+kZOPY1w+gcCMobZ6ETUp9WfibndnosbpwB1iJk/8Gt5ZF2bhS6+B6bPYz424KtwsR6Rflc3tCz1/ghX2dQ==", + "dev": true, + "license": "MIT", + "dependencies": { + "@types/node": "*" + }, + "engines": { + "node": ">=4.0" + } + }, "node_modules/callsites": { "version": "3.1.0", "dev": true, @@ -2724,6 +2792,38 @@ "dev": true, "license": "MIT" }, + "node_modules/happy-dom": { + "version": "20.12.0", + "resolved": "https://registry.npmjs.org/happy-dom/-/happy-dom-20.12.0.tgz", + "integrity": "sha512-7uMYJu2SEwwL8vVcKp0C0lnt6d2LSGGe+T+oY79PiCJNNSgFpbxW8n5KuzpDQvrU4mt+fYiK1+Jy7Z2v39YR6g==", + "dev": true, + "license": "MIT", + "dependencies": { + "@types/node": ">=20.0.0", + "@types/whatwg-mimetype": "^3.0.2", + "@types/ws": "^8.18.1", + "buffer-image-size": "^0.6.4", + "entities": "^7.0.1", + "whatwg-mimetype": "^3.0.0", + "ws": "^8.21.0" + }, + "engines": { + "node": ">=20.0.0" + } + }, + "node_modules/happy-dom/node_modules/entities": { + "version": "7.0.1", + "resolved": "https://registry.npmjs.org/entities/-/entities-7.0.1.tgz", + "integrity": "sha512-TWrgLOFUQTH994YUyl1yT4uyavY5nNB5muff+RtWaqNVCAK408b5ZnnbNAUEWLTCpum9w6arT70i1XdQ4UeOPA==", + "dev": true, + "license": "BSD-2-Clause", + "engines": { + "node": ">=0.12" + }, + "funding": { + "url": "https://github.com/fb55/entities?sponsor=1" + } + }, "node_modules/has-flag": { "version": "4.0.0", "dev": true, @@ -5085,6 +5185,16 @@ "url": "https://opencollective.com/webpack" } }, + "node_modules/whatwg-mimetype": { + "version": "3.0.0", + "resolved": "https://registry.npmjs.org/whatwg-mimetype/-/whatwg-mimetype-3.0.0.tgz", + "integrity": "sha512-nt+N2dzIutVRxARx1nghPKGv1xHikU7HKdfafKkLNLindmPU/ch3U31NOCGGA/dmPcmb1VlofO0vnKAcsm0o/Q==", + "dev": true, + "license": "MIT", + "engines": { + "node": ">=12" + } + }, "node_modules/which": { "version": "2.0.2", "dev": true, @@ -5216,6 +5326,28 @@ "url": "https://github.com/chalk/strip-ansi?sponsor=1" } }, + "node_modules/ws": { + "version": "8.21.3", + "resolved": "https://registry.npmjs.org/ws/-/ws-8.21.3.tgz", + "integrity": "sha512-201TZ/kPWxoPr/OKWjquZR1SWKXcvxdH+e1xrx89b3YbmzLMFCLfnaG1HFIgWzJOEWZ7MvpK++odZufgYR50Rw==", + "dev": true, + "license": "MIT", + "engines": { + "node": ">=10.0.0" + }, + "peerDependencies": { + "bufferutil": "^4.0.1", + "utf-8-validate": ">=5.0.2" + }, + "peerDependenciesMeta": { + "bufferutil": { + "optional": true + }, + "utf-8-validate": { + "optional": true + } + } + }, "node_modules/yocto-queue": { "version": "0.1.0", "dev": true, diff --git a/package.json b/package.json index 438aaab1..dcf51868 100644 --- a/package.json +++ b/package.json @@ -53,6 +53,7 @@ "file-replace-loader": "^1.4.2", "glob": "^11.0.1", "globals": "^16.0.0", + "happy-dom": "^20.12.0", "html-loader": "^5.1.0", "ignore-loader": "^0.1.2", "lit": "^3.2.1", diff --git a/src/lib/components/common/skincraft_viewer_modal.ts b/src/lib/components/common/skincraft_viewer_modal.ts index 0756cf99..5b2fe0ea 100644 --- a/src/lib/components/common/skincraft_viewer_modal.ts +++ b/src/lib/components/common/skincraft_viewer_modal.ts @@ -4,7 +4,8 @@ import {classMap} from 'lit/directives/class-map.js'; import {guard} from 'lit/directives/guard.js'; import {createRef, ref} from 'lit/directives/ref.js'; import {styleMap} from 'lit/directives/style-map.js'; -import type {SkinCraftViewerTarget} from '../../services/skincraft_viewer_protocol'; +import type {SkinCraftListingDetails, SkinCraftViewerTarget} from '../../services/skincraft_viewer_protocol'; +import {FLOAT_CONDITION_BANDS} from '../../utils/skin'; import {MODAL_TRANSITION_MS, skinCraftViewerModalStyles} from './skincraft_viewer_modal_styles'; type LoadPhase = 'loading' | 'revealed' | 'error'; @@ -14,11 +15,56 @@ export type SkinCraftViewerModalOptions = { embedSrc: string; /** Heading for the item strip, e.g. "Inventory" — the modal itself is surface-agnostic. */ itemsTitle: string; + /** `grid` shows the item strip; `details` swaps it for a listing-details panel with prev/next. */ + layout?: 'grid' | 'details'; onClose: () => void; onRetry: () => void; onSelect: (target: SkinCraftViewerTarget) => void; + /** Fired when the user nears the end of the loaded items — strip scroll in `grid`, selection proximity in `details`. */ + onItemsNearEnd?: () => void; + /** Enables the details panel's View action; the host hands off to the surface's purchase flow. */ + onViewMarketListing?: (listingId: string) => void; + /** + * The headless keyboard path: the dialog keeps real focus, and bare presses it doesn't bind + * are handed to the host to replay inside the embed. Enabling this also makes the modal + * reclaim focus whenever a click into the 3D view hands it to the iframe. + */ + onViewerKey?: (action: 'down' | 'up', key: string, code: string) => void; }; +/** About two card rows — asking for more this early keeps strip scrolling seamless. */ +const ITEMS_NEAR_END_PX = 240; + +/** In details mode, prefetch the next page once selection comes within this many items of the end. */ +const ITEMS_NEAR_END_COUNT = 5; + +/** Full 0–1 wear range in the same bands the float bar uses. */ +const WEAR_SEGMENTS = FLOAT_CONDITION_BANDS.map((band) => ({width: band.max - band.min, color: band.color})); + +function renderChevron(direction: 'left' | 'right'): TemplateResult { + return html` + + `; +} + +function formatRestrictionDays(days: number): string { + if (days === 7) return 'one week'; + if (days === 1) return 'one day'; + return `${days} days`; +} + function mixHexColors(base: string, tint: string, tintAmount: number): string { const mixChannel = (offset: number): number => { const baseChannel = Number.parseInt(base.slice(offset, offset + 2), 16); @@ -57,12 +103,15 @@ export class SkinCraftViewerModal { private readonly root: ShadowRoot; private readonly dialogRef = createRef(); private readonly frameRef = createRef(); + private readonly itemGridRef = createRef(); + private readonly detailsScrollRef = createRef(); private target?: SkinCraftViewerTarget; private items: SkinCraftViewerTarget[] = []; private phase: LoadPhase = 'loading'; private progress: number | null = null; private errorMessage = ''; + private viewNotice = ''; private entering = false; private closing = false; private iconReady = false; @@ -70,6 +119,9 @@ export class SkinCraftViewerModal { private closeTimer?: number; private entryFrame?: number; private iconRequest = 0; + private pageOverflow?: string; + private detailsGhost?: HTMLElement; + private detailsEnter?: Animation; constructor(private readonly options: SkinCraftViewerModalOptions) { // Closed so page scripts can't reach into the viewer we host on their document. @@ -100,8 +152,14 @@ export class SkinCraftViewerModal { } show(target: SkinCraftViewerTarget): void { + const fromIndex = this.isOpen ? this.selectedIndex : -1; this.target = target; + const toIndex = this.selectedIndex; + const step = fromIndex >= 0 && toIndex >= 0 ? Math.sign(toIndex - fromIndex) : 0; + // Captured before the render below swaps the panel content in place. + const ghost = step ? this.captureDetailsGhost() : undefined; this.iconReady = false; + this.viewNotice = ''; const request = ++this.iconRequest; this.cancelClose(); @@ -111,6 +169,8 @@ export class SkinCraftViewerModal { void this.revealIconWhenDecoded(target.iconUrl, request); this.scrollSelectedIntoView(); + if (ghost) this.animateDetailsChange(ghost, step); + this.maybeRequestMore(toIndex); if (opening) this.openDialog(); } @@ -142,66 +202,46 @@ export class SkinCraftViewerModal { this.update(); } + /** Surfaces a failed hand-off next to the View action; cleared on the next selection. */ + setViewNotice(message: string): void { + this.viewNotice = message; + this.update(); + } + // `host` binds `this` inside the template's @event handlers, the way LitElement does it. private update(): void { render(this.template(), this.root, {host: this}); } + private get layout(): 'grid' | 'details' { + return this.options.layout ?? 'grid'; + } + private template(): TemplateResult { const revealed = this.phase === 'revealed'; + const details = this.layout === 'details'; return html` - + ${details ? nothing : this.renderHeader()}