import { LitElement, HTMLTemplateResult, html } from 'lit'; import { customElement, property, state, query } from 'lit/decorators.js'; import { getModalStyles } from './modal-styles.js'; import { Constant } from '../constant.js'; import { AssetModel } from '../asset/asset-model.js'; import interactionTracker, { TrackerEvent } from './interaction-tracker.js'; import { isMobile } from '../utils.js'; export function formatTxHash(txHash: string): string { if (txHash.length < 60) { return ''; } const firstPart = txHash.slice(0, 6); const lastPart = txHash.slice(-6); return `${firstPart}...${lastPart}`; } export function generateCaptureEyeCloseSvg( color: string, size: number ): HTMLTemplateResult { return html` `; } export interface EngagementZone { image: string; link: string; } export interface ModalOptions { nid: string; layout?: string; color?: string; headingSource?: string; copyrightZoneTitle?: string; engagementZones?: EngagementZone[]; actionButtonText?: string; actionButtonLink?: string; position?: { top: number; left: number; name: string }; crPin?: string; } @customElement('capture-eye-modal') export class CaptureEyeModal extends LitElement { static override styles = getModalStyles(); @property({ type: String }) nid = ''; @property({ type: String }) layout = Constant.layout.original; @property({ type: Boolean }) modalHidden = true; @state() protected _color = ''; @state() protected _copyrightZoneTitle = ''; @state() protected _engagementZones: EngagementZone[] = []; @state() protected _engagementZoneIndex = 0; @state() protected _engagementZoneRotationInterval = 5000; @state() protected _engagementZoneRotationIntervalId: number | NodeJS.Timeout | undefined = undefined; @state() protected _headingSource = ''; @state() protected _position: | { top: number; left: number; name: string } | undefined = undefined; @state() protected _actionButtonText = ''; @state() protected _actionButtonLink = ''; @state() protected _asset: AssetModel | undefined = undefined; @state() protected _assetLoaded = false; @state() protected _imageLoaded = false; @state() protected _crPin = Constant.crPin.on; @query('.modal') modalElement!: HTMLDivElement; constructor() { super(); } override disconnectedCallback(): void { super.disconnectedCallback(); this.stopEngagementZoneRotation(); } override updated(changedProperties: Map) { if ( changedProperties.has('modalHidden') || changedProperties.has('_assetLoaded') ) { const closeButton = this.shadowRoot?.querySelector('.close-button'); if (this.modalElement && closeButton && !this.modalHidden) { this.updateModelPosition(closeButton as HTMLElement); } } } updateAsset(asset: AssetModel, setAsLoaded = false) { this._asset = { ...this._asset, ...asset }; if (setAsLoaded) this._assetLoaded = true; } updateModalOptions(options: ModalOptions) { if (options.nid) this.nid = options.nid; if (options.layout) this.layout = options.layout; if (options.color !== undefined && options.color !== this._color) { this._color = options.color; this.updateModalColor(); } if (options.headingSource) { this._headingSource = options.headingSource; } if (options.copyrightZoneTitle) this._copyrightZoneTitle = options.copyrightZoneTitle; if ( options.engagementZones !== undefined && JSON.stringify(options.engagementZones) !== JSON.stringify(this._engagementZones) ) { this._imageLoaded = false; this._engagementZones = options.engagementZones; } if (options.crPin !== undefined) { this._crPin = options.crPin; } this.preloadEngagementZoneImages(); if (options.actionButtonText) this._actionButtonText = options.actionButtonText; if (options.actionButtonLink) this._actionButtonLink = options.actionButtonLink; if (options.position) { this._position = options.position; } } clearModalOptions() { this.stopEngagementZoneRotation(); this.nid = ''; this.layout = Constant.layout.original; this._color = ''; this._copyrightZoneTitle = ''; this._engagementZones = []; this._engagementZoneIndex = 0; this._engagementZoneRotationIntervalId = undefined; this._actionButtonText = ''; this._actionButtonLink = ''; this._asset = undefined; this._assetLoaded = false; this._position = undefined; this._crPin = Constant.crPin.on; } private remToPixels(rem: number): number { return ( rem * parseFloat(getComputedStyle(document.documentElement).fontSize) ); } private startEngagementZoneRotation() { this.stopEngagementZoneRotation(); if (this._engagementZones.length <= 1) return; this._engagementZoneRotationIntervalId = setInterval(() => { this._engagementZoneIndex = (this._engagementZoneIndex + 1) % this._engagementZones.length; }, this._engagementZoneRotationInterval); } private stopEngagementZoneRotation() { if (this._engagementZoneRotationIntervalId) clearInterval(this._engagementZoneRotationIntervalId); } private rotateNext() { if (this._engagementZones.length <= 1) return; this._engagementZoneIndex = (this._engagementZoneIndex + 1) % this._engagementZones.length; } private rotatePrev() { if (this._engagementZones.length <= 1) return; this._engagementZoneIndex = (this._engagementZoneIndex - 1 + this._engagementZones.length) % this._engagementZones.length; } private preloadEngagementZoneImages(): Promise { return new Promise((resolve, reject) => { let loadedImages = 0; const imageUrls = this._engagementZones.length > 0 ? this._engagementZones.map((zone) => zone.image) : [Constant.url.defaultEngagementImage]; imageUrls.forEach((url) => { const img = new Image(); img.src = url; img.onload = () => { loadedImages++; if (loadedImages === imageUrls.length) { this.handleImageLoad(); resolve(); } }; img.onerror = () => { console.error(`Image failed to load: ${url}`); reject(new Error(`Image failed to load: ${url}`)); }; }); }); } private handleImageLoad() { this._imageLoaded = true; this.startEngagementZoneRotation(); } private isC2paSupported() { // Check if asset has C2PA support from backend return this._asset?.hasC2pa === true; } private isOriginal() { return this.layout == Constant.layout.original; } private renderBadges() { const generatedViaAi = this._asset?.digitalSourceType === 'trainedAlgorithmicMedia' ? html`Generated via AI` : html``; const contentCredentials = this._crPin !== Constant.crPin.off && this.isC2paSupported() ? html`
` : html``; return html`
${generatedViaAi} ${contentCredentials}
`; } private renderCreatorOrAssetSourceType() { // Render creator and showcase link if layout is original, otherwise render assetSourceType return this.isOriginal() ? html` ${this._asset?.creator ?? ''} ` : this._asset?.assetSourceType ?? ''; } private renderHeading() { // Preserve headline for backward compatibility // in case some references still use the older name const headingClass = 'heading headline'; const headingText = this._assetLoaded ? this._headingSource === 'abstract' ? this._asset?.abstract ?? '' : this._asset?.headline ?? '' : ''; return html`
${this._assetLoaded ? html`${headingText}` : html`
 
`}
`; } private renderTop() { const image = this._asset?.thumbnailUrl ? html`Profile` : typeof this._asset?.encodingFormat === 'string' && this._asset.encodingFormat.includes('audio/') ? html` ` : html`Profile`; /* * original: signed_metadata.capture_time or assetTree.assetTimestampCreated or uploaded_at * curated: integrity(signed_metadata) capture_time */ const date = this.isOriginal() ? this._asset?.captureTime ? this._asset?.captureTime : this._asset?.createdTime : this._asset?.captureTime; return html`
${this._copyrightZoneTitle || Constant.text.defaultCopyrightZoneTitle}
${this._assetLoaded ? html`${image}` : html`
`}
${this._assetLoaded ? this.renderCreatorOrAssetSourceType() : html`
`}
${this._assetLoaded ? date ?? '' : html`
`}
${this._assetLoaded ? this._asset?.captureLocation ?? '' : html`
`}
${this.renderHeading()}
`; } private renderIcon(iconUrl: string) { return html``; } private renderTransaction() { const transactionText = formatTxHash(this._asset?.initialTransaction ?? '') || 'N/A'; return html`${this._assetLoaded ? html`${this.renderIcon(Constant.url.txIcon)} Blockchain Tx: ${transactionText !== 'N/A' && this._asset?.explorerUrl ? html` ${transactionText} ` : html`${transactionText}`}` : html``}`; } private renderDefaultProvenanceZone() { return html`
${this.isOriginal() ? 'Origins' : 'Curated By'}
${this.isOriginal() ? html`
${this._assetLoaded ? html`${this.renderIcon(Constant.url.blockchainIcon)} Blockchain: ${Constant.text.numbersMainnet} ` : html``}
${this.renderTransaction()}
` : html`
${this._assetLoaded ? html`${this.renderIcon(Constant.url.curatorIcon)} ${this._asset?.backendOwnerName ?? ''} ` : html`
`}
`}
`; } private renderCustomProvenanceZone() { const captureEyeCustom = this._asset?.captureEyeCustom; if (!Array.isArray(captureEyeCustom)) { return html``; } const provenanceZoneItems = captureEyeCustom.filter( (item) => item.field && item.value ); return html`
${this.isOriginal() ? 'Origins' : 'Curated By'}
${provenanceZoneItems.map( (item) => html`
${item.iconSource ? this.renderIcon(item.iconSource) : html``} ${item.field}: ${item.url ? html`${item.value}` : html`${item.value}`}
` )}
${this.renderTransaction()}
`; } private renderBottom() { const actionButtonLink = this._actionButtonLink ? this._actionButtonLink : this._asset?.hasNftProduct ? `${Constant.url.collect}?nid=${this.nid}&from=capture-eye` : this.isOriginal() ? `${Constant.url.profile}/${this.nid}` : this._asset?.usedBy ?? ''; const actionButtonText = this._actionButtonText ? this._actionButtonText : this._asset?.hasNftProduct ? Constant.text.collect : Constant.text.viewMore; return html`
${this._assetLoaded ? html`Powered by Numbers Protocol` : html`
 
`}
`; } private get currentEngagementZone() { const defaultEngagementZone: EngagementZone = { image: Constant.url.defaultEngagementImage, link: Constant.url.defaultEngagementLink, }; const currentEngagementZone = this._engagementZones.length > 0 ? this._engagementZones[this._engagementZoneIndex] : defaultEngagementZone; return currentEngagementZone; } private renderEngagementZone() { return html`
Slideshow Image ${!this._imageLoaded ? html`
` : ''}
${this._engagementZones.length > 1 ? html` ` : ''}
`; } override render() { const mobile = isMobile(); const color = this._color ? this._color : mobile ? Constant.color.mobileEye : Constant.color.defaultEye; const size = mobile ? 24 : 32; return html` `; } private handleModalClick(event: MouseEvent) { event.stopPropagation(); // Check if the target of the event is an tag const isAnchorTag = (event.target as HTMLElement).closest('a') !== null; // Prevent default behavior only if the target is not an tag if (!isAnchorTag) { event.preventDefault(); } } private handleInspectContentCredentials() { // New way: directly open c2pa verify page with source URL parameter const assetUrl = `${Constant.url.ipfsGateway}/${this.nid}`; const url = `${Constant.url.c2paVerify}?source=${encodeURIComponent(assetUrl)}`; window.open(url, '_blank', 'noopener,noreferrer'); } private emitRemoveEvent() { // Emit remove event to trigger ModalManager to remove the modal this.dispatchEvent(new CustomEvent('remove-capture-eye-modal')); } private trackEngagement() { // 0: User-customized link (not provided by us) // 1: Default engagement link // 2, 3, ...: Future rotating engagement links const subid = this.currentEngagementZone.link === Constant.url.defaultEngagementLink ? '1' : '0'; interactionTracker.trackInteraction( TrackerEvent.ENGAGEMENT_ZONE, this.nid, subid ); } private isValidColor(color: string): boolean { return CSS.supports('color', color.trim()); } private updateModalColor() { // Clear colors first; set only if color is valid this.style.setProperty('--primary-color', ''); this.style.setProperty('--hover-color', ''); if (!this._color || !this.isValidColor(this._color)) { return; } // Set primary color this.style.setProperty('--primary-color', this._color); const ctx = document.createElement('canvas').getContext('2d'); if (!ctx) { return; } ctx.fillStyle = this._color; let hoverColor = ctx.fillStyle; const hexPattern = /^#[0-9a-fA-F]{6}$/; if (!hexPattern.test(hoverColor)) { return; } function fadeColor(start: number): number { const n = parseInt(hoverColor.substring(start, start + 2), 16); return Math.round(n + (255 - n) * 0.5); } const r = fadeColor(1); const g = fadeColor(3); const b = fadeColor(5); hoverColor = `#${((1 << 24) + (r << 16) + (g << 8) + b).toString(16).slice(1)}`; this.style.setProperty('--hover-color', hoverColor); } private updateModelPosition(closeButton: HTMLElement) { if (!this._position) { return; } const remOffset = this.remToPixels(1); // Convert 1rem to pixels const top = this._position.top + remOffset; const left = this._position.left + remOffset; const positions = this._position.name.split(' '); // Update modal position styles with 1rem offset // Calculate the modal position relative to the media let modelTop = top; let modelLeft = left; if ( modelTop > this.modalElement.offsetHeight && (positions.includes('bottom') || modelTop + this.modalElement.offsetHeight > document.documentElement.scrollHeight) ) { modelTop -= this.modalElement.offsetHeight; } else if ( modelTop + this.modalElement.offsetHeight > document.documentElement.scrollHeight ) { modelTop = Math.max( 0, document.documentElement.scrollHeight - this.modalElement.offsetHeight ); } if ( modelLeft > this.modalElement.offsetWidth && (positions.includes('right') || modelLeft + this.modalElement.offsetWidth > document.documentElement.scrollWidth) ) { modelLeft -= this.modalElement.offsetWidth; } else if ( modelLeft + this.modalElement.offsetWidth > document.documentElement.scrollWidth ) { modelLeft = Math.max( 0, document.documentElement.scrollWidth - this.modalElement.offsetWidth ); } this.modalElement.style.top = `${modelTop}px`; this.modalElement.style.left = `${modelLeft}px`; let startTop = top - modelTop; let startLeft = left - modelLeft; if ( startTop != 0 && startTop != this.modalElement.offsetHeight && startLeft != 0 && startLeft != this.modalElement.offsetWidth ) { // The starting position is not aligned with the edge if (startTop <= startLeft) { startTop = 0; } else { startLeft = 0; } } const modalContainer = this.shadowRoot?.querySelector('.modal-container'); if (modalContainer) { (modalContainer as HTMLDivElement).style.transformOrigin = `${startLeft}px ${startTop}px`; } closeButton.style.top = `${startTop - closeButton.offsetHeight / 2}px`; closeButton.style.left = `${startLeft - closeButton.offsetWidth / 2}px`; } private toggleHeading() { const heading = this.shadowRoot?.querySelector('.heading'); if (!heading) { return; } heading.classList.toggle('expand'); } } declare global { interface HTMLElementTagNameMap { 'capture-eye-modal': CaptureEyeModal; } }