├── src ├── pages │ ├── preview │ │ └── save-to-library-modal.ts │ ├── portfolio │ │ └── index.ts │ ├── publish │ │ └── publish-page.ts │ ├── cta-sdk-page.ts │ ├── home-page.ts.css │ ├── compress-assets-page.ts │ ├── home-page.ts │ ├── folder-size │ │ ├── folder-tree-view.ts │ │ └── folder-treemap-view.ts │ ├── base64-converter.ts │ └── imba-packer-page.ts ├── fw │ ├── layout-component-base.ts │ ├── component-base.ts │ ├── index.ts │ ├── from-query.ts │ ├── image-popup.ts │ ├── di.ts │ ├── update-notification.ts │ ├── version-checker.ts │ └── router.ts ├── services │ ├── PreviewServiceValidators │ │ ├── index.ts │ │ ├── types.ts │ │ ├── GeneralValidator.ts │ │ ├── FacebookValidator.ts │ │ └── MraidValidator.ts │ ├── MetadataService.ts │ ├── types.ts │ ├── Base64ConverterService.ts │ └── ImbaPackerService.ts ├── vite-env.d.ts ├── assets │ ├── lit.svg │ ├── preview-presets.json │ ├── cta-sdk.md │ └── platforms-config.json ├── utils │ ├── url-utils.ts │ └── AsstesExtractor.ts ├── Layout │ ├── gamepad-icon.svg │ ├── site-logo.ts │ ├── nav-menu.ts │ └── main-layout.ts ├── app-root.ts ├── theme.css └── sw-version-handler.js ├── public ├── publish-data │ ├── TikTok │ │ └── config.json │ ├── cta.Facebook.js │ ├── cta.Facebook_Zip.js │ ├── cta.Moloco.js │ ├── cta.AdColony.js │ ├── cta.Applovin.js │ ├── cta.Mraid2.js │ ├── cta.Mintegral.js │ ├── cta.Google.js │ ├── cta.TikTok.js │ ├── cta.Vungle.js │ ├── cta.dv360.js │ ├── cta.Unity.js │ └── cta.IronSource.js ├── pwa.png ├── big-logo.jpg ├── PngChpocker.png ├── files │ ├── base.apk │ └── PngChpocker.zip ├── large-image.jpg ├── small-logo.jpg ├── version.json ├── backgrounds │ ├── back2.svg │ ├── back1.svg │ ├── checkboard.svg │ └── dark-checkboard.svg ├── manifest.webmanifest ├── PlayableTools │ └── playable-tools.svg ├── test-simple-playable.html ├── gamepad.svg ├── playable-tools.svg ├── zip-preview-sw.js ├── playable-screenshot.js ├── fb_validator.js └── mraid.js ├── media ├── pwa.png ├── big-logo.jpg ├── large-image.jpg ├── large-image.psd ├── small-logo.jpg ├── backgrounds │ ├── back1.jpg │ ├── back2.jpg │ ├── back3.jpg │ ├── back4.jpg │ ├── back2.svg │ ├── back1.svg │ ├── checkboard.svg │ └── dark-checkboard.svg ├── app-screenshots │ ├── base64.png │ └── previewer.jpg └── playable-screenshots │ └── LBR_Construct3d.png ├── TestPlayableSources ├── TestFacebookCta.c3p ├── FacebookMinimalTest │ ├── facebook_test.zip │ └── facebook_test.html └── Construct3 │ └── Construct3PlayableTest_Facebook.zip ├── .gitignore ├── .env.local.example ├── .htaccess-example ├── nginx-version-config.example ├── vite-plugin-rewrite-base-href.ts ├── package.json ├── tsconfig.json ├── vite-plugin-yandex-metrika.ts ├── test-version-fetch.js ├── test-facebook-validator.html ├── .github └── workflows │ └── static.yml ├── vite-plugin-version.ts ├── vite.config.ts ├── test-playable.html ├── index.html ├── README.md └── VERSION_CHECKING.md /src/pages/preview/save-to-library-modal.ts: -------------------------------------------------------------------------------- 1 | -------------------------------------------------------------------------------- /src/pages/portfolio/index.ts: -------------------------------------------------------------------------------- 1 | export * from "./portfolio-page"; 2 | -------------------------------------------------------------------------------- /public/publish-data/TikTok/config.json: -------------------------------------------------------------------------------- 1 | { 2 | "playable_orientation": 0 3 | } -------------------------------------------------------------------------------- /media/pwa.png: -------------------------------------------------------------------------------- https://raw.githubusercontent.com/gritsenko/PlayableTools/main/media/pwa.png -------------------------------------------------------------------------------- /public/pwa.png: -------------------------------------------------------------------------------- https://raw.githubusercontent.com/gritsenko/PlayableTools/main/public/pwa.png -------------------------------------------------------------------------------- /media/big-logo.jpg: -------------------------------------------------------------------------------- https://raw.githubusercontent.com/gritsenko/PlayableTools/main/media/big-logo.jpg -------------------------------------------------------------------------------- /public/big-logo.jpg: -------------------------------------------------------------------------------- https://raw.githubusercontent.com/gritsenko/PlayableTools/main/public/big-logo.jpg -------------------------------------------------------------------------------- /media/large-image.jpg: -------------------------------------------------------------------------------- https://raw.githubusercontent.com/gritsenko/PlayableTools/main/media/large-image.jpg -------------------------------------------------------------------------------- /media/large-image.psd: -------------------------------------------------------------------------------- https://raw.githubusercontent.com/gritsenko/PlayableTools/main/media/large-image.psd -------------------------------------------------------------------------------- /media/small-logo.jpg: -------------------------------------------------------------------------------- https://raw.githubusercontent.com/gritsenko/PlayableTools/main/media/small-logo.jpg -------------------------------------------------------------------------------- /public/PngChpocker.png: -------------------------------------------------------------------------------- https://raw.githubusercontent.com/gritsenko/PlayableTools/main/public/PngChpocker.png -------------------------------------------------------------------------------- /public/files/base.apk: -------------------------------------------------------------------------------- https://raw.githubusercontent.com/gritsenko/PlayableTools/main/public/files/base.apk -------------------------------------------------------------------------------- /public/large-image.jpg: -------------------------------------------------------------------------------- https://raw.githubusercontent.com/gritsenko/PlayableTools/main/public/large-image.jpg -------------------------------------------------------------------------------- /public/small-logo.jpg: -------------------------------------------------------------------------------- https://raw.githubusercontent.com/gritsenko/PlayableTools/main/public/small-logo.jpg -------------------------------------------------------------------------------- /media/backgrounds/back1.jpg: -------------------------------------------------------------------------------- https://raw.githubusercontent.com/gritsenko/PlayableTools/main/media/backgrounds/back1.jpg -------------------------------------------------------------------------------- /media/backgrounds/back2.jpg: -------------------------------------------------------------------------------- https://raw.githubusercontent.com/gritsenko/PlayableTools/main/media/backgrounds/back2.jpg -------------------------------------------------------------------------------- /media/backgrounds/back3.jpg: -------------------------------------------------------------------------------- https://raw.githubusercontent.com/gritsenko/PlayableTools/main/media/backgrounds/back3.jpg -------------------------------------------------------------------------------- /media/backgrounds/back4.jpg: -------------------------------------------------------------------------------- https://raw.githubusercontent.com/gritsenko/PlayableTools/main/media/backgrounds/back4.jpg -------------------------------------------------------------------------------- /public/files/PngChpocker.zip: -------------------------------------------------------------------------------- https://raw.githubusercontent.com/gritsenko/PlayableTools/main/public/files/PngChpocker.zip -------------------------------------------------------------------------------- /media/app-screenshots/base64.png: -------------------------------------------------------------------------------- https://raw.githubusercontent.com/gritsenko/PlayableTools/main/media/app-screenshots/base64.png -------------------------------------------------------------------------------- /public/version.json: -------------------------------------------------------------------------------- 1 | { 2 | "version": "1.4.4-dev", 3 | "buildTime": "2025-12-10T08:15:14.136Z", 4 | "hash": "fb9d35ff" 5 | } -------------------------------------------------------------------------------- /media/app-screenshots/previewer.jpg: -------------------------------------------------------------------------------- https://raw.githubusercontent.com/gritsenko/PlayableTools/main/media/app-screenshots/previewer.jpg -------------------------------------------------------------------------------- /TestPlayableSources/TestFacebookCta.c3p: -------------------------------------------------------------------------------- https://raw.githubusercontent.com/gritsenko/PlayableTools/main/TestPlayableSources/TestFacebookCta.c3p -------------------------------------------------------------------------------- /media/playable-screenshots/LBR_Construct3d.png: -------------------------------------------------------------------------------- https://raw.githubusercontent.com/gritsenko/PlayableTools/main/media/playable-screenshots/LBR_Construct3d.png -------------------------------------------------------------------------------- /src/fw/layout-component-base.ts: -------------------------------------------------------------------------------- 1 | import { ComponentBase } from "./component-base"; 2 | 3 | export class LayoutComponentBase extends ComponentBase { 4 | } 5 | -------------------------------------------------------------------------------- /TestPlayableSources/FacebookMinimalTest/facebook_test.zip: -------------------------------------------------------------------------------- https://raw.githubusercontent.com/gritsenko/PlayableTools/main/TestPlayableSources/FacebookMinimalTest/facebook_test.zip -------------------------------------------------------------------------------- /TestPlayableSources/Construct3/Construct3PlayableTest_Facebook.zip: -------------------------------------------------------------------------------- https://raw.githubusercontent.com/gritsenko/PlayableTools/main/TestPlayableSources/Construct3/Construct3PlayableTest_Facebook.zip -------------------------------------------------------------------------------- /public/publish-data/cta.Facebook.js: -------------------------------------------------------------------------------- 1 | var scriptElt = { "type": "ok" }; //to avoid script error in Facebook 2 | document.CTA = { 3 | onClick: function () { 4 | FbPlayableAd.onCTAClick(); 5 | window.console.log("CTA Clicked"); 6 | } 7 | }; 8 | document._xrq_ = window[atob("WE1MSHR0cHBSZXF1ZXN0")]; 9 | -------------------------------------------------------------------------------- /public/publish-data/cta.Facebook_Zip.js: -------------------------------------------------------------------------------- 1 | var scriptElt = { "type": "ok" }; //to avoid script error in Facebook 2 | document.CTA = { 3 | onClick: function () { 4 | FbPlayableAd.onCTAClick(); 5 | window.console.log("CTA Clicked"); 6 | } 7 | }; 8 | document._xrq_ = window[atob("WE1MSHR0cHBSZXF1ZXN0")]; 9 | -------------------------------------------------------------------------------- /public/publish-data/cta.Moloco.js: -------------------------------------------------------------------------------- 1 | var scriptElt = { "type": "ok" }; //to avoid script error in Facebook 2 | document.CTA = { 3 | onClick: function () { 4 | FbPlayableAd.onCTAClick(); 5 | window.console.log("CTA Clicked"); 6 | } 7 | }; 8 | 9 | document._xrq_ = window[atob("WE1MSHR0cHBSZXF1ZXN0")]; 10 | -------------------------------------------------------------------------------- /src/services/PreviewServiceValidators/index.ts: -------------------------------------------------------------------------------- 1 | export { GeneralValidator } from './GeneralValidator'; 2 | export { FacebookValidator } from './FacebookValidator'; 3 | export { MraidValidator } from './MraidValidator'; 4 | export type { Validator, ValidationResult, ValidationCategory, ValidationCheck, ValidatorFactory } from './types'; -------------------------------------------------------------------------------- /media/backgrounds/back2.svg: -------------------------------------------------------------------------------- 1 | 2 | 3 | 4 | 5 | 6 | 7 | 8 | 9 | -------------------------------------------------------------------------------- /public/backgrounds/back2.svg: -------------------------------------------------------------------------------- 1 | 2 | 3 | 4 | 5 | 6 | 7 | 8 | 9 | -------------------------------------------------------------------------------- /media/backgrounds/back1.svg: -------------------------------------------------------------------------------- 1 | 2 | 3 | 4 | 5 | 6 | 7 | 8 | 9 | -------------------------------------------------------------------------------- /public/backgrounds/back1.svg: -------------------------------------------------------------------------------- 1 | 2 | 3 | 4 | 5 | 6 | 7 | 8 | 9 | -------------------------------------------------------------------------------- /src/fw/component-base.ts: -------------------------------------------------------------------------------- 1 | import { LitElement } from 'lit'; 2 | 3 | export class ComponentBase extends LitElement { 4 | protected static useShadowDom = false; 5 | // Override createRenderRoot to respect the `useShadowDom` setting 6 | createRenderRoot() { 7 | if ((this.constructor as typeof ComponentBase).useShadowDom) { 8 | return super.createRenderRoot(); // Shadow DOM 9 | } else { 10 | return this; // Light DOM 11 | } 12 | } 13 | } -------------------------------------------------------------------------------- /.gitignore: -------------------------------------------------------------------------------- 1 | # Logs 2 | logs 3 | *.log 4 | npm-debug.log* 5 | yarn-debug.log* 6 | yarn-error.log* 7 | pnpm-debug.log* 8 | lerna-debug.log* 9 | 10 | node_modules 11 | dist-ssr 12 | *.local 13 | 14 | dev-dist 15 | dist 16 | 17 | # Environment variables - NEVER commit these files 18 | .env 19 | .env.local 20 | .env.*.local 21 | .env.production 22 | 23 | # Editor directories and files 24 | .vscode/* 25 | !.vscode/extensions.json 26 | .idea 27 | .DS_Store 28 | *.suo 29 | *.ntvs* 30 | *.njsproj 31 | *.sln 32 | *.sw? 33 | -------------------------------------------------------------------------------- /public/publish-data/cta.AdColony.js: -------------------------------------------------------------------------------- 1 | document.CTA = { 2 | onClick: function (store) { 3 | 4 | if (store === undefined) 5 | store = navigator.userAgent.toLowerCase().indexOf("android") > -1 ? "google" : "apple"; 6 | 7 | var urls = { 8 | "google": "{{google}}", 9 | "apple": "{{apple}}" 10 | }; 11 | var url = urls[store]; 12 | window.console.log("CTA Clicked store: " + store + " link: " + url); 13 | mraid.open(url); 14 | } 15 | }; -------------------------------------------------------------------------------- /public/publish-data/cta.Applovin.js: -------------------------------------------------------------------------------- 1 | document.CTA = { 2 | onClick: function (store) { 3 | 4 | if (store === undefined) 5 | store = navigator.userAgent.toLowerCase().indexOf("android") > -1 ? "google" : "apple"; 6 | 7 | var urls = { 8 | "google": "{{google}}", 9 | "apple": "{{apple}}" 10 | }; 11 | var url = urls[store]; 12 | window.console.log("CTA Clicked store: " + store + " link: " + url); 13 | mraid.open(url); 14 | } 15 | }; -------------------------------------------------------------------------------- /public/publish-data/cta.Mraid2.js: -------------------------------------------------------------------------------- 1 | document.CTA = { 2 | onClick: function (store) { 3 | 4 | if (store === undefined) 5 | store = navigator.userAgent.toLowerCase().indexOf("android") > -1 ? "google" : "apple"; 6 | 7 | var urls = { 8 | "google": "{{google}}", 9 | "apple": "{{apple}}" 10 | }; 11 | var url = urls[store]; 12 | window.console.log("CTA Clicked store: " + store + " link: " + url); 13 | mraid.open(url); 14 | } 15 | }; -------------------------------------------------------------------------------- /src/vite-env.d.ts: -------------------------------------------------------------------------------- 1 | /// 2 | 3 | interface ImportMetaEnv { 4 | readonly VITE_FIREBASE_API_KEY: string; 5 | readonly VITE_FIREBASE_AUTH_DOMAIN: string; 6 | readonly VITE_FIREBASE_PROJECT_ID: string; 7 | readonly VITE_FIREBASE_STORAGE_BUCKET: string; 8 | readonly VITE_FIREBASE_MESSAGING_SENDER_ID: string; 9 | readonly VITE_FIREBASE_APP_ID: string; 10 | readonly VITE_FIREBASE_DATABASE_URL: string; 11 | } 12 | 13 | interface ImportMeta { 14 | readonly env: ImportMetaEnv; 15 | } 16 | -------------------------------------------------------------------------------- /.env.local.example: -------------------------------------------------------------------------------- 1 | # Firebase Configuration 2 | # Copy this file to .env.local and fill in your actual Firebase credentials 3 | # .env.local is git-ignored and should never be committed 4 | 5 | VITE_FIREBASE_API_KEY=your_firebase_api_key_here 6 | VITE_FIREBASE_AUTH_DOMAIN=your_auth_domain_here 7 | VITE_FIREBASE_PROJECT_ID=your_project_id_here 8 | VITE_FIREBASE_STORAGE_BUCKET=your_storage_bucket_here 9 | VITE_FIREBASE_MESSAGING_SENDER_ID=your_messaging_sender_id_here 10 | VITE_FIREBASE_APP_ID=your_app_id_here 11 | VITE_FIREBASE_DATABASE_URL=your_database_url_here 12 | -------------------------------------------------------------------------------- /src/services/PreviewServiceValidators/types.ts: -------------------------------------------------------------------------------- 1 | export interface ValidationCheck { 2 | name: string; 3 | passed: boolean; 4 | details?: string; 5 | isWarning?: boolean; 6 | } 7 | 8 | export interface ValidationCategory { 9 | name: string; 10 | checks: ValidationCheck[]; 11 | } 12 | 13 | export interface ValidationResult { 14 | categories: ValidationCategory[]; 15 | } 16 | 17 | export interface Validator { 18 | validate(content: string, fileSize: number): ValidationResult; 19 | } 20 | 21 | export interface ValidatorFactory { 22 | create(): Validator; 23 | } -------------------------------------------------------------------------------- /.htaccess-example: -------------------------------------------------------------------------------- 1 | # Prevent caching of version.json file 2 | 3 | Header set Cache-Control "no-cache, no-store, must-revalidate" 4 | Header set Pragma "no-cache" 5 | Header set Expires "0" 6 | Header set ETag "" 7 | Header unset Last-Modified 8 | 9 | 10 | # Alternative using regex pattern 11 | 12 | Header set Cache-Control "no-cache, no-store, must-revalidate" 13 | Header set Pragma "no-cache" 14 | Header set Expires "0" 15 | Header set ETag "" 16 | Header unset Last-Modified 17 | 18 | -------------------------------------------------------------------------------- /public/manifest.webmanifest: -------------------------------------------------------------------------------- 1 | { 2 | "name": "Gritsenko Playable Tools", 3 | "short_name": "PlayableTools", 4 | "start_url": "/", 5 | "display": "standalone", 6 | "background_color": "#ffffff", 7 | "theme_color": "#3b82f6", 8 | "description": "Open-source tools for HTML5 playable ads developers.", 9 | "icons": [ 10 | { 11 | "src": "/playable-tools.svg", 12 | "sizes": "192x192", 13 | "type": "image/svg+xml" 14 | }, 15 | { 16 | "src": "/playable-tools.svg", 17 | "sizes": "512x512", 18 | "type": "image/svg+xml" 19 | } 20 | ] 21 | } 22 | -------------------------------------------------------------------------------- /public/PlayableTools/playable-tools.svg: -------------------------------------------------------------------------------- 1 | 2 | 3 | 4 | 5 | 6 | 7 | 8 | 9 | 10 | -------------------------------------------------------------------------------- /public/publish-data/cta.Mintegral.js: -------------------------------------------------------------------------------- 1 | document.CTA = { 2 | onClick: function (store) { 3 | window.install && window.install(); 4 | }, 5 | 6 | gameEnd: function () { 7 | window.gameEnd && window.gameEnd(); 8 | }, 9 | 10 | gameReady: function () { 11 | window.gameReady && window.gameReady(); 12 | } 13 | 14 | }; 15 | 16 | window.addEventListener('load', (event) => { 17 | window.gameReady && window.gameReady(); 18 | }); 19 | 20 | function gameStart() { 21 | console.log("Game started"); 22 | } 23 | 24 | function gameClose() { 25 | console.log("Game closed"); 26 | } -------------------------------------------------------------------------------- /src/assets/lit.svg: -------------------------------------------------------------------------------- 1 | -------------------------------------------------------------------------------- /nginx-version-config.example: -------------------------------------------------------------------------------- 1 | # Nginx configuration to prevent caching of version.json 2 | # Add this to your server block or location block 3 | 4 | location ~* /version\.json$ { 5 | add_header Cache-Control "no-cache, no-store, must-revalidate" always; 6 | add_header Pragma "no-cache" always; 7 | add_header Expires "0" always; 8 | add_header ETag "" always; 9 | 10 | # Remove any existing ETag or Last-Modified headers 11 | more_clear_headers "ETag"; 12 | more_clear_headers "Last-Modified"; 13 | 14 | # Ensure the file is served with proper MIME type 15 | add_header Content-Type "application/json" always; 16 | } 17 | -------------------------------------------------------------------------------- /src/fw/index.ts: -------------------------------------------------------------------------------- 1 | import { customElement, property, state } from 'lit/decorators.js'; 2 | import { inject } from './di'; 3 | import { route } from './router'; 4 | import { html, css } from 'lit'; 5 | 6 | import {fromQuery} from './from-query'; 7 | 8 | export * from './component-base'; 9 | export * from './di'; 10 | export * from './router'; 11 | export * from './layout-component-base'; 12 | export * from './image-popup'; 13 | export * from './update-notification'; 14 | export * from './version-checker'; 15 | 16 | export { 17 | html, 18 | css, 19 | customElement, 20 | property, 21 | state, 22 | inject, 23 | route, 24 | fromQuery 25 | }; 26 | -------------------------------------------------------------------------------- /media/backgrounds/checkboard.svg: -------------------------------------------------------------------------------- 1 | 2 | 3 | 4 | 5 | 6 | 7 | 8 | 9 | 10 | 11 | -------------------------------------------------------------------------------- /public/backgrounds/checkboard.svg: -------------------------------------------------------------------------------- 1 | 2 | 3 | 4 | 5 | 6 | 7 | 8 | 9 | 10 | 11 | -------------------------------------------------------------------------------- /media/backgrounds/dark-checkboard.svg: -------------------------------------------------------------------------------- 1 | 2 | 3 | 4 | 5 | 6 | 7 | 8 | 9 | 10 | 11 | -------------------------------------------------------------------------------- /public/backgrounds/dark-checkboard.svg: -------------------------------------------------------------------------------- 1 | 2 | 3 | 4 | 5 | 6 | 7 | 8 | 9 | 10 | 11 | -------------------------------------------------------------------------------- /src/services/MetadataService.ts: -------------------------------------------------------------------------------- 1 | export interface PageMetadata { 2 | title?: string; 3 | description?: string; 4 | } 5 | 6 | class MetadataService { 7 | update(metadata: PageMetadata) { 8 | if (metadata.title) { 9 | document.title = metadata.title; 10 | } 11 | 12 | let descriptionEl = document.querySelector('meta[name="description"]'); 13 | if (!descriptionEl) { 14 | descriptionEl = document.createElement('meta'); 15 | descriptionEl.setAttribute('name', 'description'); 16 | document.head.appendChild(descriptionEl); 17 | } 18 | descriptionEl.setAttribute('content', metadata.description || ''); 19 | } 20 | } 21 | 22 | export const metadataService = new MetadataService(); 23 | -------------------------------------------------------------------------------- /vite-plugin-rewrite-base-href.ts: -------------------------------------------------------------------------------- 1 | import { Plugin } from 'vite'; 2 | import fs from 'fs'; 3 | import path from 'path'; 4 | 5 | export default function rewriteBaseHrefPlugin(): Plugin { 6 | let outDir = 'dist'; 7 | return { 8 | name: 'rewrite-base-href', 9 | configResolved(resolvedConfig) { 10 | outDir = resolvedConfig.build?.outDir || 'dist'; 11 | }, 12 | closeBundle() { 13 | const indexPath = path.resolve(process.cwd(), outDir, 'index.html'); 14 | if (fs.existsSync(indexPath)) { 15 | let html = fs.readFileSync(indexPath, 'utf8'); 16 | html = html.replace('', ''); 17 | fs.writeFileSync(indexPath, html, 'utf8'); 18 | } 19 | }, 20 | }; 21 | } 22 | -------------------------------------------------------------------------------- /public/publish-data/cta.Google.js: -------------------------------------------------------------------------------- 1 | var headNode = document.getElementsByTagName("head")[0], 2 | script = document.createElement("script"); 3 | 4 | // script attribute 5 | script.setAttribute("type", "text/javascript"); 6 | script.setAttribute("charset", "utf-8"); 7 | script.setAttribute("src", "//tpc.googlesyndication.com/pagead/gadgets/html5/api/exitapi.js"); 8 | // inject elements 9 | headNode.appendChild(script); 10 | 11 | script.onload = function () { 12 | ExitApi.delayCloseButton(0); 13 | }; 14 | 15 | document.CTA = { 16 | onClick: function () { 17 | try { 18 | ExitApi.exit(); 19 | } catch (b) { 20 | console.log(b); 21 | } 22 | window.console.log("CTA Clicked"); 23 | } 24 | }; -------------------------------------------------------------------------------- /public/publish-data/cta.TikTok.js: -------------------------------------------------------------------------------- 1 | var headNode = document.getElementsByTagName("head")[0], 2 | script = document.createElement("script"); 3 | 4 | // script attribute 5 | script.setAttribute("type", "text/javascript"); 6 | script.setAttribute("charset", "utf-8"); 7 | script.setAttribute("src", "https://sf16-muse-va.ibytedtos.com/obj/union-fe-nc-i18n/playable/sdk/playable-sdk.js"); 8 | // inject elements 9 | headNode.appendChild(script); 10 | 11 | script.onload = function () { 12 | //sdk api loaded 13 | }; 14 | 15 | document.CTA = { 16 | onClick: function () { 17 | try { 18 | window.playableSDK.openAppStore(); 19 | } catch (b) { 20 | console.log(b); 21 | } 22 | window.console.log("CTA Clicked"); 23 | } 24 | }; -------------------------------------------------------------------------------- /src/utils/url-utils.ts: -------------------------------------------------------------------------------- 1 | // Utility for URL rewriting and subfolder deployment support 2 | export class UrlUtils { 3 | /** 4 | * Returns the base directory URL for subfolder deployments, ensuring trailing slash. 5 | */ 6 | static getBaseDir(): string { 7 | const basePath = window.location.origin + window.location.pathname.replace(/([?#].*)$/, ""); 8 | return basePath.endsWith("/") ? basePath : basePath + "/"; 9 | } 10 | 11 | /** 12 | * Builds a fetch URL for a resource in a subfolder deployment. 13 | * @param subPath Subfolder path (e.g., 'publish-data/') 14 | * @param resource Resource filename (e.g., 'cta.Facebook.js') 15 | */ 16 | static buildFetchUrl(subPath: string, resource: string): string { 17 | return this.getBaseDir() + subPath + resource; 18 | } 19 | } 20 | -------------------------------------------------------------------------------- /package.json: -------------------------------------------------------------------------------- 1 | { 2 | "name": "playable_tools", 3 | "private": true, 4 | "version": "1.4.4", 5 | "type": "module", 6 | "scripts": { 7 | "dev": "vite", 8 | "build": "tsc && vite build", 9 | "preview": "vite preview" 10 | }, 11 | "dependencies": { 12 | "@picocss/pico": "^2.1.1", 13 | "@types/d3": "^7.4.3", 14 | "@types/jszip": "^3.4.0", 15 | "d3": "^7.9.0", 16 | "fw": "file:./src/fw", 17 | "jszip": "^3.10.1", 18 | "lit": "^3.3.0", 19 | "marked": "^16.0.0", 20 | "pako": "^2.1.0", 21 | "reflect-metadata": "^0.2.2", 22 | "vite-plugin-pwa": "^1.0.1", 23 | "vite-plugin-string": "^1.2.3" 24 | }, 25 | "devDependencies": { 26 | "@types/node": "^24.0.13", 27 | "@types/pako": "^2.0.3", 28 | "typescript": "~5.8.3", 29 | "vite": "^7.0.4" 30 | } 31 | } 32 | -------------------------------------------------------------------------------- /public/publish-data/cta.Vungle.js: -------------------------------------------------------------------------------- 1 | var headNode = document.getElementsByTagName("head")[0], 2 | script = document.createElement("script"); 3 | 4 | // script attribute 5 | script.setAttribute("type", "text/javascript"); 6 | script.setAttribute("charset", "utf-8"); 7 | script.setAttribute("src", "mraid.js"); 8 | // inject elements 9 | headNode.appendChild(script); 10 | 11 | document.CTA = { 12 | onClick: function (store) { 13 | 14 | if (store === undefined) 15 | store = navigator.userAgent.toLowerCase().indexOf("android") > -1 ? "google" : "apple"; 16 | 17 | var urls = { 18 | "google": "{{google}}", 19 | "apple": "{{apple}}" 20 | }; 21 | var url = urls[store]; 22 | window.console.log("CTA Clicked store: " + store + " link: " + url); 23 | mraid.open(url); 24 | } 25 | }; -------------------------------------------------------------------------------- /src/fw/from-query.ts: -------------------------------------------------------------------------------- 1 | // Decorator to inject value from hash-based query string 2 | export function fromQuery(paramName: string) { 3 | return function (target: any, propertyKey: string) { 4 | const getter = function () { 5 | const hash = window.location.hash; 6 | if (hash) { 7 | const queryIndex = hash.indexOf('?'); 8 | if (queryIndex !== -1) { 9 | const query = hash.substring(queryIndex + 1); 10 | const params = new URLSearchParams(query); 11 | return params.get(paramName); 12 | } 13 | } 14 | return null; 15 | }; 16 | Object.defineProperty(target, propertyKey, { 17 | get: getter, 18 | enumerable: true, 19 | configurable: true 20 | }); 21 | }; 22 | } 23 | -------------------------------------------------------------------------------- /tsconfig.json: -------------------------------------------------------------------------------- 1 | { 2 | "compilerOptions": { 3 | "target": "ES2022", 4 | "experimentalDecorators": true, 5 | "emitDecoratorMetadata": true, 6 | "useDefineForClassFields": false, 7 | "module": "ESNext", 8 | "lib": ["ES2022", "DOM", "DOM.Iterable"], 9 | "skipLibCheck": true, 10 | 11 | /* Bundler mode */ 12 | "moduleResolution": "bundler", 13 | "allowImportingTsExtensions": true, 14 | "verbatimModuleSyntax": true, 15 | "moduleDetection": "force", 16 | "noEmit": true, 17 | 18 | /* Linting */ 19 | "strict": true, 20 | "noUnusedLocals": true, 21 | "noUnusedParameters": true, 22 | "erasableSyntaxOnly": true, 23 | "noFallthroughCasesInSwitch": true, 24 | "noUncheckedSideEffectImports": true, 25 | "baseUrl": ".", 26 | "paths": { 27 | "fw": ["src/fw/index.ts"], 28 | "fw/*": ["src/fw/*"] 29 | } 30 | }, 31 | "include": ["src", "src/cta-sdk.md.d.ts"] 32 | } 33 | -------------------------------------------------------------------------------- /public/publish-data/cta.dv360.js: -------------------------------------------------------------------------------- 1 | var headNode = document.getElementsByTagName("head")[0], 2 | script = document.createElement("script"); 3 | 4 | // script attribute 5 | script.setAttribute("type", "text/javascript"); 6 | script.setAttribute("charset", "utf-8"); 7 | script.setAttribute("src", "https://s0.2mdn.net/ads/studio/Enabler.js"); 8 | // inject elements 9 | headNode.appendChild(script); 10 | 11 | var clickTag = "http://my.com"; 12 | 13 | window.onload = function () { 14 | if (Enabler.isInitialized()) { 15 | enablerInitHandler(); 16 | } else { 17 | Enabler.addEventListener(studio.events.StudioEvent.INIT, enablerInitHandler); 18 | } 19 | } 20 | 21 | function enablerInitHandler() { 22 | 23 | } 24 | 25 | function bgExitHandler(e) { 26 | Enabler.exit('Background Exit'); 27 | } 28 | 29 | document.CTA = { 30 | onClick: function () { 31 | bgExitHandler(); 32 | window.console.log("CTA Clicked"); 33 | } 34 | }; -------------------------------------------------------------------------------- /src/pages/publish/publish-page.ts: -------------------------------------------------------------------------------- 1 | import { ComponentBase, customElement, html, route as route } from "fw"; 2 | import "./playable-publisher"; 3 | 4 | @customElement("publish-page") 5 | @route("/publish", { 6 | title: "Publish Playable Ads", 7 | description: "Publish your playable ads to multiple ad networks with ease. This tool streamlines the process of deploying your ads." 8 | }) 9 | export class HomePage extends ComponentBase { 10 | render() { 11 | return html` 12 |
13 |
14 | Important: You must integrate the CTA SDK into your playable ad for successful publishing. See cta-sdk for instructions. 15 |
16 | 17 |
18 | `; 19 | } 20 | } 21 | -------------------------------------------------------------------------------- /src/pages/cta-sdk-page.ts: -------------------------------------------------------------------------------- 1 | import { ComponentBase, customElement, html, route } from "fw"; 2 | import { unsafeHTML } from "lit-html/directives/unsafe-html.js"; 3 | import { marked } from "marked"; 4 | // @ts-ignore 5 | import markdownContent from "../assets/cta-sdk.md?raw"; 6 | 7 | @customElement("cta-sdk-page") 8 | @route("/cta-sdk", { 9 | title: "CTA SDK Documentation", 10 | description: "Documentation for the CTA SDK, providing guidance on how to integrate and use the SDK in your playable ads.", 11 | }) 12 | export class CtaSdkPage extends ComponentBase { 13 | markdownHtml: string = ""; 14 | 15 | connectedCallback() { 16 | super.connectedCallback(); 17 | 18 | const content = marked.parse(markdownContent); 19 | if (typeof content === "string") { 20 | this.markdownHtml = content; 21 | } 22 | 23 | this.requestUpdate(); 24 | } 25 | 26 | render() { 27 | return html` 28 |
29 |
30 | ${unsafeHTML(this.markdownHtml)} 31 |
32 |
33 | `; 34 | } 35 | } 36 | -------------------------------------------------------------------------------- /TestPlayableSources/FacebookMinimalTest/facebook_test.html: -------------------------------------------------------------------------------- 1 | 2 | 3 | 4 | 5 | 6 | Facebook CTA Test 7 | 27 | 28 | 29 |
30 |

Facebook CTA Test

31 |

32 | Click the button below to trigger 33 | FbPlayableAd.onCTAClick(). This page includes a lightweight 34 | mock so it can be used locally. 35 |

36 | 37 | 40 | 41 |
42 |
43 | 44 | 45 | 46 | -------------------------------------------------------------------------------- /src/assets/preview-presets.json: -------------------------------------------------------------------------------- 1 | { 2 | "presets": [ 3 | { 4 | "id": "default", 5 | "name": "Default Preview", 6 | "description": "Standard preview without any ad network validation", 7 | "maxFileSizeMB": 10, 8 | "injectScripts": [], 9 | "replaceTokens": {} 10 | }, 11 | { 12 | "id": "facebook", 13 | "name": "Facebook Validator", 14 | "description": "Facebook ad network validation with content security policies", 15 | "maxFileSizeMB": 5, 16 | "injectScripts": [ 17 | { 18 | "source": "fb_validator.js", 19 | "position": "beforeHeadEnd" 20 | } 21 | ], 22 | "replaceTokens": { 23 | "XMLHttpRequest": "_xrq_" 24 | } 25 | } 26 | , 27 | { 28 | "id": "mraid", 29 | "name": "MRAID Preview", 30 | "description": "Preview configured for MRAID playables. Useful when testing playables targeted to ad networks that use MRAID (examples: Unity, IronSource, AppLovin, Vungle, Mintegral, MoPub). Injects mraid.js where appropriate and preserves MRAID API behavior when available.", 31 | "maxFileSizeMB": 10, 32 | "injectScripts": [ 33 | { 34 | "source": "mraid.js", 35 | "position": "afterBodyStart" 36 | } 37 | ], 38 | "replaceTokens": {} 39 | } 40 | ] 41 | } 42 | -------------------------------------------------------------------------------- /src/services/types.ts: -------------------------------------------------------------------------------- 1 | export interface PlatformConfig { 2 | Name: string; 3 | InjeectScripts?: string[]; 4 | format?: string; 5 | ExtractScripts?: boolean; 6 | OutputIndexHtmlName?: string; 7 | ExtraFiles?: { from: string; to: string }[]; 8 | Sizes?: Record; 9 | replaceTokens?: Record; 10 | } 11 | 12 | export interface PlayableProcessOptions { 13 | name?: string; 14 | title?: string; 15 | googlePlayUrl?: string; 16 | appStoreUrl?: string; 17 | suffix?: string; 18 | outputDirectory?: FileSystemDirectoryHandle; 19 | onProgress?: (progress: number, platform?: string) => void; 20 | // Add more options as needed 21 | } 22 | 23 | export interface PreviewScript { 24 | source: string; 25 | position: 'beforeHeadEnd' | 'afterBodyStart' | 'beforeBodyEnd'; 26 | } 27 | 28 | export interface PreviewPreset { 29 | id: string; 30 | name: string; 31 | description: string; 32 | maxFileSizeMB: number; 33 | injectScripts: PreviewScript[]; 34 | replaceTokens: Record; 35 | } 36 | 37 | export interface PreviewPresetsConfig { 38 | presets: PreviewPreset[]; 39 | } 40 | 41 | export interface PreviewServiceOptions { 42 | preset?: PreviewPreset; 43 | customMaxFileSizeMB?: number; 44 | customInjectScripts?: PreviewScript[]; 45 | customReplaceTokens?: Record; 46 | } 47 | -------------------------------------------------------------------------------- /vite-plugin-yandex-metrika.ts: -------------------------------------------------------------------------------- 1 | import type { Plugin } from 'vite'; 2 | import fs from 'fs'; 3 | import path from 'path'; 4 | 5 | const METRIKA_COMMENT = ''; 6 | const METRIKA_SNIPPET = `\n\n
\"\"
\n`; 7 | 8 | export default function yandexMetrikaPlugin(): Plugin { 9 | return { 10 | name: 'inject-yandex-metrika', 11 | apply: 'build', 12 | enforce: 'post', 13 | closeBundle() { 14 | const outDir = 'dist'; 15 | const indexPath = path.join(outDir, 'index.html'); 16 | if (fs.existsSync(indexPath)) { 17 | let html = fs.readFileSync(indexPath, 'utf-8'); 18 | if (html.includes(METRIKA_COMMENT)) { 19 | html = html.replace(METRIKA_COMMENT, METRIKA_SNIPPET); 20 | fs.writeFileSync(indexPath, html, 'utf-8'); 21 | } 22 | } 23 | }, 24 | }; 25 | } 26 | -------------------------------------------------------------------------------- /src/services/Base64ConverterService.ts: -------------------------------------------------------------------------------- 1 | export interface Base64FileModel { 2 | file: File; 3 | name: string; 4 | mimeType: string; 5 | dataUrl: string; 6 | originalSize: number; // in bytes 7 | base64Size: number; // in bytes 8 | } 9 | 10 | import { injectable, ServiceLifetime } from "fw"; 11 | 12 | @injectable(ServiceLifetime.Singleton) 13 | export class Base64ConverterService { 14 | async convertFilesToBase64(files: File[], onProgress?: (progress: number) => void): Promise { 15 | const results: Base64FileModel[] = []; 16 | for (let i = 0; i < files.length; i++) { 17 | const file = files[i]; 18 | const mimeType = file.type || 'application/octet-stream'; 19 | const dataUrl = await this.fileToDataUrl(file); 20 | // Calculate base64 size (actual base64 string length in bytes) 21 | const base64String = dataUrl.split(',')[1] || ''; 22 | results.push({ 23 | file, 24 | name: file.name, 25 | mimeType, 26 | dataUrl, 27 | originalSize: file.size, 28 | base64Size: base64String.length, 29 | }); 30 | if (onProgress) { 31 | onProgress(Math.round(((i + 1) / files.length) * 100)); 32 | } 33 | } 34 | return results; 35 | } 36 | 37 | private fileToDataUrl(file: File): Promise { 38 | return new Promise((resolve, reject) => { 39 | const reader = new FileReader(); 40 | reader.onload = () => resolve(reader.result as string); 41 | reader.onerror = reject; 42 | reader.readAsDataURL(file); 43 | }); 44 | } 45 | } 46 | -------------------------------------------------------------------------------- /test-version-fetch.js: -------------------------------------------------------------------------------- 1 | // Manual test script for version checking 2 | // Run this in the browser console to test version fetching 3 | 4 | async function testVersionFetching() { 5 | console.log('🧪 Testing version fetching...'); 6 | 7 | // Test different URL patterns that might cause 304 responses 8 | const testUrls = [ 9 | './version.json', 10 | './version.json?t=' + Date.now(), 11 | '/version.json?cb=' + Math.random() 12 | ]; 13 | 14 | for (const url of testUrls) { 15 | try { 16 | console.log(`🔍 Testing URL: ${url}`); 17 | 18 | const response = await fetch(url, { 19 | method: 'GET', 20 | headers: { 21 | 'Cache-Control': 'no-cache, no-store, must-revalidate', 22 | 'Pragma': 'no-cache', 23 | 'Expires': '0' 24 | }, 25 | cache: 'no-store' 26 | }); 27 | 28 | console.log(`Status: ${response.status} ${response.statusText}`); 29 | console.log(`Cache-Control: ${response.headers.get('cache-control')}`); 30 | 31 | if (response.status === 200 || response.status === 304) { 32 | try { 33 | const data = await response.json(); 34 | console.log('✅ Success:', data); 35 | } catch (jsonError) { 36 | console.log('⚠️ Could not parse JSON (possibly 304 with no body)'); 37 | } 38 | } else { 39 | console.log('❌ Failed:', response.status); 40 | } 41 | 42 | console.log('---'); 43 | 44 | } catch (error) { 45 | console.error('❌ Request failed:', error); 46 | } 47 | } 48 | } 49 | 50 | // Run the test 51 | testVersionFetching(); 52 | -------------------------------------------------------------------------------- /src/services/PreviewServiceValidators/GeneralValidator.ts: -------------------------------------------------------------------------------- 1 | import type { Validator, ValidationResult, ValidationCategory } from './types'; 2 | 3 | export class GeneralValidator implements Validator { 4 | validate(content: string, _fileSize: number): ValidationResult { 5 | const categories: ValidationCategory[] = [ 6 | { 7 | name: 'General', 8 | checks: [ 9 | { 10 | name: 'Not using "window.top" access', 11 | passed: !content.includes('window.top'), 12 | details: content.includes('window.top') 13 | ? 'Playable contains window.top access which may cause issues in ad environments' 14 | : undefined 15 | }, 16 | { 17 | name: 'No external script loading (except mraid.js, exitapi.js)', 18 | passed: !/]*src\s*=\s*['"](?!.*(?:mraid\.js|\/\/tpc\.googlesyndication\.com\/pagead\/gadgets\/html5\/api\/exitapi\.js))[^'"]+['"][^>]*>/.test(content), 19 | details: /]*src\s*=\s*['"](?!.*(?:mraid\.js|\/\/tpc\.googlesyndication\.com\/pagead\/gadgets\/html5\/api\/exitapi\.js))[^'"]+['"][^>]*>/.test(content) 20 | ? 'External scripts detected (except mraid.js, exitapi.js) - consider bundling all scripts' 21 | : undefined 22 | }, 23 | { 24 | name: 'Valid HTML structure', 25 | passed: /]*>[\s\S]*<\/html>/i.test(content), 26 | details: !/]*>[\s\S]*<\/html>/i.test(content) 27 | ? 'Missing proper HTML structure' 28 | : undefined 29 | } 30 | ] 31 | } 32 | ]; 33 | 34 | return { categories }; 35 | } 36 | } -------------------------------------------------------------------------------- /src/fw/image-popup.ts: -------------------------------------------------------------------------------- 1 | import { ComponentBase, customElement, html, property } from "fw"; 2 | 3 | @customElement("image-popup") 4 | export class ImagePopup extends ComponentBase { 5 | @property({ type: Boolean }) open = false; 6 | @property({ type: String }) src = ""; 7 | @property({ type: String }) alt = ""; 8 | @property({ type: String }) thumbWidth = "500px"; 9 | 10 | private openPopup = () => { this.open = true; this.requestUpdate(); }; 11 | private closePopup = () => { this.open = false; this.requestUpdate(); }; 12 | 13 | render() { 14 | return html` 15 | ${this.alt} 21 | ${this.open ? html` 22 |
26 | ${this.alt} full sizee.stopPropagation()} 31 | /> 32 | 37 |
38 | ` : ""} 39 | `; 40 | } 41 | } 42 | -------------------------------------------------------------------------------- /public/test-simple-playable.html: -------------------------------------------------------------------------------- 1 | 2 | 3 | 4 | 5 | 6 | Simple Test Playable 7 | 26 | 27 | 28 |
29 |

🎯 Simple Playable

30 |

This is a test playable for validator injection.

31 | 32 |
33 |
34 | 35 | 50 | 51 | 52 | -------------------------------------------------------------------------------- /src/services/PreviewServiceValidators/FacebookValidator.ts: -------------------------------------------------------------------------------- 1 | import type { Validator, ValidationResult, ValidationCategory } from './types'; 2 | 3 | export class FacebookValidator implements Validator { 4 | validate(content: string, fileSize: number): ValidationResult { 5 | const categories: ValidationCategory[] = [ 6 | { 7 | name: 'Facebook', 8 | checks: [ 9 | { 10 | name: 'HTML file size < 5MB', 11 | passed: fileSize <= 5 * 1024 * 1024, 12 | details: fileSize > 5 * 1024 * 1024 13 | ? `File size: ${(fileSize / (1024 * 1024)).toFixed(1)}MB (max: 5MB)` 14 | : `File size: ${(fileSize / (1024 * 1024)).toFixed(1)}MB` 15 | }, 16 | { 17 | name: 'No XMLHttpRequest usage', 18 | passed: !content.includes('XMLHttpRequest') && !content.includes('fetch('), 19 | details: content.includes('XMLHttpRequest') || content.includes('fetch(') 20 | ? 'XMLHttpRequest/fetch usage detected - Facebook blocks these APIs' 21 | : undefined 22 | }, 23 | { 24 | name: 'No localStorage/sessionStorage', 25 | passed: !content.includes('localStorage') && !content.includes('sessionStorage'), 26 | details: content.includes('localStorage') || content.includes('sessionStorage') 27 | ? 'localStorage/sessionStorage usage detected - may not work in Facebook environment' 28 | : undefined 29 | }, 30 | { 31 | name: 'Valid HTML5 doctype', 32 | passed: //i.test(content), 33 | details: !//i.test(content) 34 | ? 'Missing HTML5 doctype declaration' 35 | : undefined 36 | } 37 | ] 38 | } 39 | ]; 40 | 41 | return { categories }; 42 | } 43 | } -------------------------------------------------------------------------------- /src/Layout/gamepad-icon.svg: -------------------------------------------------------------------------------- 1 | 2 | 3 | 19 | 20 | -------------------------------------------------------------------------------- /src/assets/cta-sdk.md: -------------------------------------------------------------------------------- 1 | # Unifying Playable Ads: The CTA SDK Bridge 2 | 3 | > **Note:** The CTA SDK is used by the [Publish Tool](/#publish), enabling seamless deployment and integration of HTML5 playables across multiple ad networks. 4 | 5 | ## 1. Game Events and CTA Calls 6 | 7 | When the playable is finished, the user should click the Call to Action button (such as Install, Play, Next, etc.). When that button is clicked, you should call the following code: 8 | 9 | ```typescript 10 | document["CTA"]?.onClick?.(); // Triggers the app store 11 | ``` 12 | 13 | This method acts as a proxy for the specific APIs required by different ad platforms. It will only call the necessary API to direct the user to the app store. 14 | 15 | ### Optional methods 16 | 17 | Those methods should be used for playable ads publishing to Mintegral ads network. See the [docs](https://www.playturbo.com/review/doc) 18 | 19 | ```typescript 20 | document["CTA"]?.gameEnd?.(); // Signals the end of gameplay 21 | 22 | document["CTA"]?.gameReady?.(); // Signals the ad is loaded and interactive 23 | ``` 24 | 25 | ## 2. MRAID Mute/Unmute Handler 26 | 27 | For MRAID-compatible ad networks, you can integrate custom mute and unmute handlers. This allows the playable to respond to user audio preferences and synchronize your app's audio state: 28 | 29 | ```typescript 30 | const cta = (document as any).CTA as any; // Assumes CTA scripts are already injected and document.CTA exists 31 | 32 | let isStarted = false; 33 | 34 | function startGame() { 35 | // Check if already initialized 36 | if (isStarted) return; 37 | isStarted = true; 38 | 39 | const app = new App(); 40 | // Set custom mute and unmute functions 41 | if (cta) { 42 | cta["mute"] = () => app.mute(); 43 | cta["unmute"] = () => app.unmute(); 44 | } 45 | app.init(); 46 | } 47 | 48 | startGame(); 49 | ``` 50 | 51 | The `mute()` and `unmute()` methods should be implemented in your `App` class to handle audio state changes accordingly. 52 | 53 | -------------------------------------------------------------------------------- /public/gamepad.svg: -------------------------------------------------------------------------------- 1 | 2 | 3 | 5 | 6 | 7 | 23 | 24 | 25 | -------------------------------------------------------------------------------- /test-facebook-validator.html: -------------------------------------------------------------------------------- 1 | 2 | 3 | 4 | 5 | 6 | Test Playable 7 | 30 | 31 | 32 |
33 |

🎮 Test Playable Ad

34 |

Click anywhere to trigger CTA!

35 |

Facebook validator will be injected automatically

36 |
37 | 38 | 61 | 62 | 63 | -------------------------------------------------------------------------------- /public/playable-tools.svg: -------------------------------------------------------------------------------- 1 | 2 | 3 | 4 | 5 | 6 | 7 | 23 | 24 | -------------------------------------------------------------------------------- /src/services/ImbaPackerService.ts: -------------------------------------------------------------------------------- 1 | import { injectable } from "fw"; 2 | import pako from "pako"; 3 | // @ts-ignore 4 | import pakoMin from "../assets/pako_inflate.min.js?raw"; 5 | 6 | @injectable() 7 | export class ImbaPackerService { 8 | // Add your service methods here 9 | constructor() {} 10 | 11 | /** 12 | * Compresses the file with pako, encodes as base64, and generates HTML with loader logic. 13 | * @param file HTML file to pack 14 | * @returns Promise<{fileName: string, html: string}> 15 | */ 16 | async pack(file: File): Promise<{fileName: string, html: string}> { 17 | const originalName = file.name.replace(/\.html?$/i, ""); 18 | const fileContent = await file.text(); 19 | // Compress with pako (deflate) 20 | const compressed = pako.deflate(fileContent); 21 | // Convert to base64 22 | const base64 = this._arrayBufferToBase64(compressed); 23 | // Generate loader HTML 24 | const loaderHtml = 25 | '' + 26 | '' + 27 | originalName + 28 | ' (Packed)' + 29 | '' + 30 | ' blocks (those without a src attribute), 6 | * replaces them with links, and returns the 7 | * processed HTML and a map of generated JS filenames to their contents. 8 | */ 9 | 10 | export type ExtractedAssetsResult = { 11 | html: string; 12 | files: Record; // filename -> content 13 | }; 14 | 15 | export class AsstesExtractor { 16 | /** 17 | * Extracts inline scripts from the provided HTML string. 18 | * - Inline scripts (no src attribute) are combined into a single file 19 | * (order preserved). A single `` is injected 20 | * before `` (or appended at the end if no body tag exists). 21 | * - External scripts (with `src`) are left untouched and stay in place. 22 | * 23 | * Returns an object with the processed HTML and a map of filenames -> contents. 24 | */ 25 | static extractScripts(htmlContent: string): ExtractedAssetsResult { 26 | if (!htmlContent) return { html: htmlContent, files: {} }; 27 | 28 | const files: Record = {}; 29 | 30 | // Collect inline scripts in order and remove them from the HTML. 31 | const inlineScripts: Array<{ content: string; type?: string }> = []; 32 | const scriptRegex = /]*)>([\s\S]*?)<\/script>/gi; 33 | 34 | const strippedHtml = htmlContent.replace(scriptRegex, (fullMatch, attrs, inner) => { 35 | // If script tag has a src attribute, leave it as-is 36 | if (/\bsrc\s*=\s*["'][^"']+["']/i.test(attrs)) { 37 | return fullMatch; 38 | } 39 | 40 | // Inline script: record its content and type (if present), then remove it 41 | const typeMatch = /\btype\s*=\s*(['"])(.*?)\1/i.exec(attrs); 42 | inlineScripts.push({ content: inner, type: typeMatch ? typeMatch[2] : undefined }); 43 | return ""; // remove the inline script from original location 44 | }); 45 | 46 | if (inlineScripts.length === 0) { 47 | return { html: strippedHtml, files }; 48 | } 49 | 50 | // Combine all inline scripts into a single file, preserving order. 51 | const fileName = `script-1.js`; 52 | let combined = ""; 53 | for (const s of inlineScripts) { 54 | combined += s.content + "\n"; 55 | } 56 | files[fileName] = combined; 57 | 58 | // If any inline script used type="module", mark the combined script as module. 59 | const needsModule = inlineScripts.some(s => s.type && s.type.toLowerCase() === "module"); 60 | const typeAttr = needsModule ? ` type="module"` : ""; 61 | const scriptTag = ``; 62 | 63 | // Inject the single combined script before . If no , append at end. 64 | if (/<\/body>/i.test(strippedHtml)) { 65 | const newHtml = strippedHtml.replace(/<\/body>/i, scriptTag + ""); 66 | return { html: newHtml, files }; 67 | } 68 | 69 | return { html: strippedHtml + scriptTag, files }; 70 | } 71 | } 72 | 73 | -------------------------------------------------------------------------------- /public/zip-preview-sw.js: -------------------------------------------------------------------------------- 1 | const sessionStore = new Map(); 2 | 3 | const normalizePath = (path) => { 4 | if (!path) return ""; 5 | return path.replace(/^\/+/, "").replace(/\/+/g, "/"); 6 | }; 7 | 8 | self.addEventListener("install", () => { 9 | self.skipWaiting(); 10 | }); 11 | 12 | self.addEventListener("activate", (event) => { 13 | event.waitUntil(self.clients.claim()); 14 | }); 15 | 16 | self.addEventListener("message", (event) => { 17 | const data = event.data; 18 | if (!data || !data.type) { 19 | return; 20 | } 21 | 22 | switch (data.type) { 23 | case "ZIP_SESSION_REGISTER": { 24 | const { sessionId, assets } = data; 25 | if (!sessionId || !Array.isArray(assets)) { 26 | return; 27 | } 28 | const assetMap = new Map(); 29 | for (const asset of assets) { 30 | if (!asset || !asset.path || !asset.buffer) continue; 31 | assetMap.set(normalizePath(asset.path), { 32 | mime: asset.mime || "application/octet-stream", 33 | buffer: asset.buffer, 34 | }); 35 | } 36 | sessionStore.set(sessionId, assetMap); 37 | break; 38 | } 39 | case "ZIP_SESSION_UPDATE_ENTRY": { 40 | const { sessionId, asset } = data; 41 | if (!sessionId || !asset) return; 42 | const session = sessionStore.get(sessionId); 43 | if (!session) return; 44 | session.set(normalizePath(asset.path), { 45 | mime: asset.mime || "text/html", 46 | buffer: asset.buffer, 47 | }); 48 | break; 49 | } 50 | case "ZIP_SESSION_CLEAR": { 51 | const { sessionId } = data; 52 | if (sessionId) { 53 | sessionStore.delete(sessionId); 54 | } else { 55 | sessionStore.clear(); 56 | } 57 | break; 58 | } 59 | default: 60 | break; 61 | } 62 | }); 63 | 64 | self.addEventListener("fetch", (event) => { 65 | const scopeUrl = new URL(self.registration.scope); 66 | const requestUrl = new URL(event.request.url); 67 | if (!requestUrl.pathname.startsWith(scopeUrl.pathname)) { 68 | return; 69 | } 70 | 71 | const relativePath = requestUrl.pathname.slice(scopeUrl.pathname.length); 72 | const parts = relativePath.split("/").filter(Boolean); 73 | if (parts.length < 2) { 74 | return; 75 | } 76 | 77 | const [sessionId, ...assetParts] = parts; 78 | const assetPath = normalizePath(assetParts.join("/")); 79 | const session = sessionStore.get(sessionId); 80 | if (!session) { 81 | event.respondWith(new Response("ZIP session expired", { status: 410 })); 82 | return; 83 | } 84 | 85 | const asset = session.get(assetPath); 86 | if (!asset) { 87 | event.respondWith(new Response("Asset not found", { status: 404 })); 88 | return; 89 | } 90 | 91 | const body = asset.buffer.slice(0); 92 | event.respondWith( 93 | new Response(body, { 94 | status: 200, 95 | headers: { 96 | "Content-Type": asset.mime || "application/octet-stream", 97 | "Cache-Control": "no-store", 98 | }, 99 | }) 100 | ); 101 | }); 102 | -------------------------------------------------------------------------------- /src/app-root.ts: -------------------------------------------------------------------------------- 1 | import "reflect-metadata"; 2 | 3 | import { ComponentBase, customElement, html, state } from "./fw"; 4 | import "./Layout/nav-menu"; 5 | import { MainLayout } from "./Layout/main-layout"; 6 | import { VersionService } from "./services/VersionService"; 7 | import "./fw/update-notification"; 8 | 9 | import "./theme.css"; 10 | // Eagerly import all files in pages directory 11 | // this will resolve all page components 12 | import.meta.glob("./pages/**/*.ts", { eager: true }); 13 | 14 | @customElement("app-root") 15 | export class AppRoot extends ComponentBase { 16 | private versionService = new VersionService(); 17 | 18 | @state() 19 | private updateAvailable = false; 20 | 21 | async connectedCallback() { 22 | super.connectedCallback(); 23 | 24 | console.log('🔧 Initializing PlayableTools...'); 25 | 26 | // Initialize version checking 27 | await this.initializeVersionService(); 28 | } 29 | 30 | disconnectedCallback() { 31 | super.disconnectedCallback(); 32 | this.versionService.destroy(); 33 | } 34 | 35 | private async initializeVersionService(): Promise { 36 | try { 37 | // Initialize the version service 38 | await this.versionService.initialize(); 39 | 40 | // Log current version to console 41 | const currentVersion = this.versionService.getCurrentVersion(); 42 | if (currentVersion) { 43 | console.log(`🚀 Playable Ads Tools v${currentVersion.version}`); 44 | console.log(`📅 Build time: ${new Date(currentVersion.buildTime).toLocaleString()}`); 45 | console.log(`🔧 Build hash: ${currentVersion.hash}`); 46 | console.log(`${this.versionService.isPWAMode() ? '📱 PWA Mode' : '🌐 Browser Mode'}`); 47 | } 48 | 49 | // Subscribe to update notifications 50 | this.versionService.onUpdateAvailable((hasUpdate) => { 51 | this.updateAvailable = hasUpdate; 52 | if (hasUpdate) { 53 | console.log('🔄 New version available!'); 54 | this.showUpdateNotification(); 55 | } 56 | }); 57 | } catch (error) { 58 | console.warn('Failed to initialize version service:', error); 59 | } 60 | } 61 | 62 | private showUpdateNotification(): void { 63 | this.requestUpdate(); 64 | // Wait for next frame to ensure DOM is updated 65 | requestAnimationFrame(() => { 66 | const notification = this.querySelector('update-notification') as any; 67 | if (notification?.show) { 68 | notification.show(); 69 | } 70 | }); 71 | } 72 | 73 | private async handleReloadRequested(): Promise { 74 | try { 75 | await this.versionService.reloadWithCacheClear(); 76 | } catch (error) { 77 | console.error('Failed to reload app:', error); 78 | // Fallback to regular reload 79 | window.location.reload(); 80 | } 81 | } 82 | 83 | render() { 84 | return html` 85 | 86 | ${this.updateAvailable ? html` 87 | 88 | ` : ''} 89 | `; 90 | } 91 | } -------------------------------------------------------------------------------- /src/theme.css: -------------------------------------------------------------------------------- 1 | .markdown-body { 2 | color: #334155; 3 | } 4 | .markdown-body h1 { 5 | font-size: 2.25rem; 6 | font-weight: 700; 7 | margin-bottom: 1.5rem; 8 | color: #0f172a; 9 | line-height: 1.2; 10 | } 11 | .markdown-body h2 { 12 | font-size: 1.875rem; 13 | font-weight: 600; 14 | margin-top: 2rem; 15 | margin-bottom: 1rem; 16 | color: #0f172a; 17 | line-height: 1.3; 18 | } 19 | .markdown-body h3 { 20 | font-size: 1.5rem; 21 | font-weight: 600; 22 | margin-top: 1.5rem; 23 | margin-bottom: 0.75rem; 24 | color: #0f172a; 25 | line-height: 1.4; 26 | } 27 | .markdown-body p { 28 | margin-bottom: 1rem; 29 | line-height: 1.75; 30 | } 31 | .markdown-body ul { 32 | list-style-type: disc; 33 | padding-left: 1.5rem; 34 | margin-bottom: 1rem; 35 | } 36 | .markdown-body ol { 37 | list-style-type: decimal; 38 | padding-left: 1.5rem; 39 | margin-bottom: 1rem; 40 | } 41 | .markdown-body li { 42 | margin-bottom: 0.5rem; 43 | } 44 | .markdown-body code { 45 | background-color: #f1f5f9; 46 | padding: 0.2rem 0.4rem; 47 | border-radius: 0.25rem; 48 | font-family: ui-monospace, SFMono-Regular, Menlo, Monaco, Consolas, 49 | "Liberation Mono", "Courier New", monospace; 50 | font-size: 0.875em; 51 | color: #0f172a; 52 | } 53 | .markdown-body pre { 54 | background-color: #1e293b; 55 | color: #f8fafc; 56 | padding: 1rem; 57 | border-radius: 0.5rem; 58 | overflow-x: auto; 59 | margin-bottom: 1.5rem; 60 | } 61 | .markdown-body pre code { 62 | background-color: transparent; 63 | padding: 0; 64 | color: inherit; 65 | font-size: 0.875em; 66 | } 67 | .markdown-body a { 68 | color: #2563eb; 69 | text-decoration: underline; 70 | text-underline-offset: 2px; 71 | } 72 | .markdown-body a:hover { 73 | color: #1d4ed8; 74 | } 75 | .markdown-body blockquote { 76 | border-left: 4px solid #cbd5e1; 77 | padding-left: 1rem; 78 | font-style: italic; 79 | color: #64748b; 80 | margin-bottom: 1rem; 81 | } 82 | .markdown-body table { 83 | width: 100%; 84 | border-collapse: collapse; 85 | margin-bottom: 1.5rem; 86 | } 87 | .markdown-body th, 88 | .markdown-body td { 89 | border: 1px solid #e2e8f0; 90 | padding: 0.75rem; 91 | text-align: left; 92 | } 93 | .markdown-body th { 94 | background-color: #f8fafc; 95 | font-weight: 600; 96 | } 97 | 98 | /* Gamepad icon for menu */ 99 | .icon-gamepad { 100 | width: 1.5rem; 101 | height: 1.5rem; 102 | display: inline-block; 103 | fill: currentColor; 104 | } 105 | 106 | @media (prefers-color-scheme: dark) { 107 | .markdown-body { 108 | color: #94a3b8; 109 | } 110 | .markdown-body h1, 111 | .markdown-body h2, 112 | .markdown-body h3 { 113 | color: #f8fafc; 114 | } 115 | .markdown-body code { 116 | background-color: #1e293b; 117 | color: #e2e8f0; 118 | } 119 | .markdown-body pre { 120 | background-color: #0f172a; 121 | } 122 | .markdown-body a { 123 | color: #60a5fa; 124 | } 125 | .markdown-body a:hover { 126 | color: #93c5fd; 127 | } 128 | .markdown-body blockquote { 129 | border-left-color: #475569; 130 | color: #94a3b8; 131 | } 132 | .markdown-body th, 133 | .markdown-body td { 134 | border-color: #334155; 135 | } 136 | .markdown-body th { 137 | background-color: #1e293b; 138 | } 139 | } 140 | -------------------------------------------------------------------------------- /src/Layout/site-logo.ts: -------------------------------------------------------------------------------- 1 | import { html, customElement, ComponentBase, state } from "fw"; 2 | import { UrlUtils } from "../utils/url-utils"; 3 | import { VersionService } from "../services/VersionService"; 4 | 5 | @customElement("site-logo") 6 | export class SiteLogo extends ComponentBase { 7 | private versionService = new VersionService(); 8 | 9 | @state() 10 | private currentVersion?: string; 11 | 12 | async connectedCallback() { 13 | super.connectedCallback(); 14 | await this.loadVersion(); 15 | } 16 | 17 | private async loadVersion(): Promise { 18 | try { 19 | await this.versionService.initialize(); 20 | const version = this.versionService.getCurrentVersion(); 21 | this.currentVersion = version?.version || ''; 22 | } catch (error) { 23 | console.warn('Failed to load version in site logo:', error); 24 | this.currentVersion = ''; 25 | } 26 | } 27 | 28 | render() { 29 | return html` 30 | 31 | 32 | 33 | 34 | 35 | 36 | 37 | 53 | 54 | 55 |
56 |

Playable Ads Tools

57 |

v${this.currentVersion || 'dev'}

58 |
59 |
60 | `; 61 | } 62 | } 63 | -------------------------------------------------------------------------------- /test-playable.html: -------------------------------------------------------------------------------- 1 | 2 | 3 | 4 | 5 | 6 | Test Playable 7 | 66 | 67 | 68 |
69 |

🎮 Test Playable Ad

70 |

This is a sample playable ad for testing the upload functionality!

71 | 72 |
73 | Click to Play! 74 |
75 | 76 | 79 | 80 |

Score: 0

81 |
82 | 83 | 110 | 111 | 112 | -------------------------------------------------------------------------------- /public/publish-data/cta.Unity.js: -------------------------------------------------------------------------------- 1 | var headNode = document.getElementsByTagName("head")[0], 2 | script = document.createElement("script"); 3 | 4 | // script attribute 5 | script.setAttribute("type", "text/javascript"); 6 | script.setAttribute("charset", "utf-8"); 7 | script.setAttribute("src", "mraid.js"); 8 | // inject elements 9 | headNode.appendChild(script); 10 | 11 | 12 | document.CTA = { 13 | platform: "mraid3", 14 | onClick: function (storeOrUrl) { 15 | let finalUrl; 16 | 17 | // Check if storeOrUrl is a direct URL (e.g., starts with http) 18 | if (typeof storeOrUrl === 'string' && (storeOrUrl.startsWith('http://') || storeOrUrl.startsWith('https://'))) { 19 | finalUrl = storeOrUrl; 20 | console.log('document.CTA.onClick() received a direct URL:', finalUrl); 21 | } else { 22 | // Fallback to original logic if it's a store type or undefined 23 | let store = storeOrUrl; 24 | if (store === undefined) { 25 | store = navigator.userAgent.toLowerCase().indexOf("android") > -1 ? "google" : "apple"; 26 | } 27 | const urls = { 28 | "google": "{{google}}", 29 | "apple": "{{apple}}" 30 | }; 31 | finalUrl = urls[store]; 32 | console.log("CTA Clicked store: " + store + " link: " + finalUrl); 33 | } 34 | 35 | if (typeof mraid !== 'undefined' && mraid.open) { 36 | try { 37 | mraid.open(finalUrl); 38 | console.log('MRAID open() called successfully for URL:', finalUrl); 39 | } catch (e) { 40 | console.error('Error calling mraid.open():', e); 41 | // Fallback if mraid.open fails (e.g., not in MRAID environment) 42 | window.open(finalUrl, '_blank'); 43 | } 44 | } else { 45 | console.warn('MRAID object not found or mraid.open not available. Opening URL via window.open().'); 46 | window.open(finalUrl, '_blank'); 47 | } 48 | } 49 | }; 50 | 51 | window.onload = () => { 52 | try { 53 | if (mraid && mraid.getState() === 'loading') { 54 | console.log('MRAID detected. Adding MRAID event listeners.'); 55 | mraid.addEventListener('ready', mraidIsReady); 56 | } 57 | } catch (e) { 58 | console.error('Error during MRAID initialization:', e); 59 | } 60 | } 61 | 62 | const mraidIsReady = () => { 63 | mraid.addEventListener("orientationchange", () => { }); 64 | 65 | if (typeof mraid.isViewable === 'function') { 66 | mraid.addEventListener("viewableChange", viewableChangeCallback); 67 | } 68 | mraid.addEventListener('exposureChange', onMraidExposureChange); 69 | mraid.addEventListener("audioVolumeChange", audioVolumeChangeCallback); 70 | mraid.removeEventListener("ready", mraidIsReady); 71 | 72 | document.CTA.startGame(); 73 | } 74 | 75 | function onMraidExposureChange(exposedPercentage, coveredRectangles, boundingRect) { 76 | const isViewable = exposedPercentage > 0; 77 | viewableChangeCallback(isViewable); 78 | } 79 | 80 | let oldIsViewable = false; 81 | 82 | const viewableChangeCallback = (isViewable) => { 83 | 84 | if (oldIsViewable === isViewable) { 85 | return; 86 | } 87 | 88 | if (isViewable) { 89 | document.CTA.unmute(); 90 | window.isGlobalSound = true; 91 | } else { 92 | document.CTA.mute() 93 | window.isGlobalSound = false; 94 | } 95 | document.CTA.mute() 96 | } 97 | 98 | const audioVolumeChangeCallback = (volume) => { 99 | let isAudioEnabled = !!volume; 100 | if (isAudioEnabled) { 101 | document.CTA.unmute() 102 | window.isGlobalSound = true; 103 | } else { 104 | document.CTA.mute() 105 | window.isGlobalSound = false; 106 | } 107 | } 108 | -------------------------------------------------------------------------------- /public/publish-data/cta.IronSource.js: -------------------------------------------------------------------------------- 1 | var headNode = document.getElementsByTagName("head")[0], 2 | script = document.createElement("script"); 3 | 4 | // script attribute 5 | script.setAttribute("type", "text/javascript"); 6 | script.setAttribute("charset", "utf-8"); 7 | script.setAttribute("src", "mraid.js"); 8 | // inject elements 9 | headNode.appendChild(script); 10 | 11 | 12 | document.CTA = { 13 | platform: "mraid3", 14 | onClick: function (storeOrUrl) { 15 | let finalUrl; 16 | 17 | // Check if storeOrUrl is a direct URL (e.g., starts with http) 18 | if (typeof storeOrUrl === 'string' && (storeOrUrl.startsWith('http://') || storeOrUrl.startsWith('https://'))) { 19 | finalUrl = storeOrUrl; 20 | console.log('document.CTA.onClick() received a direct URL:', finalUrl); 21 | } else { 22 | // Fallback to original logic if it's a store type or undefined 23 | let store = storeOrUrl; 24 | if (store === undefined) { 25 | store = navigator.userAgent.toLowerCase().indexOf("android") > -1 ? "google" : "apple"; 26 | } 27 | const urls = { 28 | "google": "{{google}}", 29 | "apple": "{{apple}}" 30 | }; 31 | finalUrl = urls[store]; 32 | console.log("CTA Clicked store: " + store + " link: " + finalUrl); 33 | } 34 | 35 | if (typeof mraid !== 'undefined' && mraid.open) { 36 | try { 37 | mraid.open(finalUrl); 38 | console.log('MRAID open() called successfully for URL:', finalUrl); 39 | } catch (e) { 40 | console.error('Error calling mraid.open():', e); 41 | // Fallback if mraid.open fails (e.g., not in MRAID environment) 42 | window.open(finalUrl, '_blank'); 43 | } 44 | } else { 45 | console.warn('MRAID object not found or mraid.open not available. Opening URL via window.open().'); 46 | window.open(finalUrl, '_blank'); 47 | } 48 | } 49 | }; 50 | 51 | window.onload = () => { 52 | try { 53 | if (mraid && mraid.getState() === 'loading') { 54 | console.log('MRAID detected. Adding MRAID event listeners.'); 55 | mraid.addEventListener('ready', mraidIsReady); 56 | } 57 | } catch (e) { 58 | console.error('Error during MRAID initialization:', e); 59 | } 60 | } 61 | 62 | const mraidIsReady = () => { 63 | mraid.addEventListener("orientationchange", () => { }); 64 | 65 | if (typeof mraid.isViewable === 'function') { 66 | mraid.addEventListener("viewableChange", viewableChangeCallback); 67 | } 68 | mraid.addEventListener('exposureChange', onMraidExposureChange); 69 | mraid.addEventListener("audioVolumeChange", audioVolumeChangeCallback); 70 | mraid.removeEventListener("ready", mraidIsReady); 71 | 72 | document.CTA.startGame(); 73 | } 74 | 75 | function onMraidExposureChange(exposedPercentage, coveredRectangles, boundingRect) { 76 | const isViewable = exposedPercentage > 0; 77 | viewableChangeCallback(isViewable); 78 | } 79 | 80 | let oldIsViewable = false; 81 | 82 | const viewableChangeCallback = (isViewable) => { 83 | 84 | if (oldIsViewable === isViewable) { 85 | return; 86 | } 87 | 88 | if (isViewable) { 89 | document.CTA.unmute(); 90 | window.isGlobalSound = true; 91 | } else { 92 | document.CTA.mute() 93 | window.isGlobalSound = false; 94 | } 95 | document.CTA.mute() 96 | } 97 | 98 | const audioVolumeChangeCallback = (volume) => { 99 | let isAudioEnabled = !!volume; 100 | if (isAudioEnabled) { 101 | document.CTA.unmute() 102 | window.isGlobalSound = true; 103 | } else { 104 | document.CTA.mute() 105 | window.isGlobalSound = false; 106 | } 107 | } 108 | -------------------------------------------------------------------------------- /src/sw-version-handler.js: -------------------------------------------------------------------------------- 1 | // Service Worker message handler for version checking 2 | // This file should be included in your service worker 3 | 4 | // Listen for messages from the main thread 5 | self.addEventListener('message', (event) => { 6 | const { type } = event.data; 7 | 8 | switch (type) { 9 | case 'SKIP_WAITING': 10 | // Skip waiting and become the active service worker 11 | self.skipWaiting(); 12 | break; 13 | 14 | case 'CLEAR_CACHE': 15 | // Clear all caches 16 | handleClearCache(); 17 | break; 18 | 19 | case 'CHECK_VERSION': 20 | // Force check version (bypass cache) 21 | handleVersionCheck(event); 22 | break; 23 | } 24 | }); 25 | 26 | async function handleClearCache() { 27 | try { 28 | const cacheNames = await caches.keys(); 29 | await Promise.all( 30 | cacheNames.map(cacheName => { 31 | console.log(`Service Worker: Clearing cache ${cacheName}`); 32 | return caches.delete(cacheName); 33 | }) 34 | ); 35 | console.log('Service Worker: All caches cleared'); 36 | } catch (error) { 37 | console.error('Service Worker: Failed to clear caches:', error); 38 | } 39 | } 40 | 41 | async function handleVersionCheck(event) { 42 | try { 43 | // Add cache busting parameters 44 | const cacheBuster = `?t=${Date.now()}&r=${Math.random().toString(36).substring(2)}`; 45 | const versionUrl = `./version.json${cacheBuster}`; 46 | 47 | console.log(`SW: Fetching version from: ${versionUrl}`); 48 | 49 | // Fetch version.json bypassing cache 50 | const response = await fetch(versionUrl, { 51 | cache: 'no-store', 52 | headers: { 53 | 'Cache-Control': 'no-cache, no-store, must-revalidate', 54 | 'Pragma': 'no-cache', 55 | 'Expires': '0', 56 | 'If-Modified-Since': '0', 57 | 'If-None-Match': '*' 58 | } 59 | }); 60 | 61 | if (response.ok) { 62 | const versionInfo = await response.json(); 63 | console.log('SW: Version info fetched:', versionInfo); 64 | console.log('SW: Response cache headers:', response.headers.get('cache-control')); 65 | 66 | // Send version info back to main thread 67 | event.ports[0]?.postMessage({ 68 | type: 'VERSION_INFO', 69 | data: versionInfo 70 | }); 71 | } 72 | } catch (error) { 73 | console.error('Service Worker: Failed to fetch version info:', error); 74 | event.ports[0]?.postMessage({ 75 | type: 'VERSION_ERROR', 76 | error: error.message 77 | }); 78 | } 79 | } 80 | 81 | // Handle service worker activation 82 | self.addEventListener('activate', (event) => { 83 | // Claim all clients immediately 84 | event.waitUntil(self.clients.claim()); 85 | }); 86 | 87 | // Handle fetch events to ensure version.json is never cached 88 | self.addEventListener('fetch', (event) => { 89 | const url = new URL(event.request.url); 90 | 91 | // Never cache version.json - match any request containing version.json 92 | if (url.pathname.includes('/version.json') || url.pathname.endsWith('/version.json')) { 93 | console.log('SW: Intercepting version.json request, bypassing cache'); 94 | 95 | event.respondWith( 96 | fetch(event.request, { 97 | cache: 'no-store', 98 | headers: { 99 | 'Cache-Control': 'no-cache, no-store, must-revalidate', 100 | 'Pragma': 'no-cache', 101 | 'Expires': '0', 102 | 'If-Modified-Since': '0', 103 | 'If-None-Match': '*' 104 | } 105 | }).then(response => { 106 | // Clone the response and add no-cache headers 107 | const newResponse = new Response(response.body, { 108 | status: response.status, 109 | statusText: response.statusText, 110 | headers: { 111 | ...Object.fromEntries(response.headers.entries()), 112 | 'Cache-Control': 'no-cache, no-store, must-revalidate', 113 | 'Pragma': 'no-cache', 114 | 'Expires': '0' 115 | } 116 | }); 117 | return newResponse; 118 | }) 119 | ); 120 | } 121 | }); 122 | -------------------------------------------------------------------------------- /src/fw/di.ts: -------------------------------------------------------------------------------- 1 | // Service lifetime types 2 | export const ServiceLifetime = { 3 | Singleton: 0, 4 | Scoped: 1, 5 | Transient: 2 6 | } as const; 7 | type ServiceLifetime = typeof ServiceLifetime[keyof typeof ServiceLifetime]; 8 | 9 | // Service descriptor interface 10 | interface ServiceDescriptor { 11 | token: symbol; 12 | implementation: any; 13 | lifetime: ServiceLifetime; 14 | } 15 | 16 | // Main container class 17 | export class ServiceContainer { 18 | private static instance: ServiceContainer; 19 | private services = new Map(); 20 | private singletonInstances = new Map(); 21 | private tokenRegistry = new Map(); // New token registry 22 | 23 | static getInstance(): ServiceContainer { 24 | if (!ServiceContainer.instance) { 25 | ServiceContainer.instance = new ServiceContainer(); 26 | } 27 | return ServiceContainer.instance; 28 | } 29 | 30 | // Register a service 31 | addService(token: symbol, implementation: any, lifetime: ServiceLifetime) { 32 | this.services.set(token, { token, implementation, lifetime }); 33 | // Eagerly instantiate singleton 34 | if (lifetime === ServiceLifetime.Singleton) { 35 | if (!this.singletonInstances.has(token)) { 36 | this.singletonInstances.set(token, new implementation()); 37 | } 38 | } 39 | } 40 | 41 | // Retrieve an existing token or create a new one 42 | getOrCreateToken(service: any): symbol { 43 | const serviceName = service.name; 44 | if (!this.tokenRegistry.has(serviceName)) { 45 | this.tokenRegistry.set(serviceName, Symbol(serviceName)); 46 | } 47 | return this.tokenRegistry.get(serviceName)!; 48 | } 49 | 50 | // Get service instance 51 | getService(token: symbol): any { 52 | const descriptor = this.services.get(token); 53 | 54 | if (!descriptor) { 55 | throw new Error(`Service not registered for token: ${token.toString()}`); 56 | } 57 | 58 | switch (descriptor.lifetime) { 59 | case ServiceLifetime.Singleton: 60 | return this.getSingletonInstance(descriptor); 61 | case ServiceLifetime.Transient: 62 | return new descriptor.implementation(); 63 | default: 64 | throw new Error(`Unsupported lifetime: ${descriptor.lifetime}`); 65 | } 66 | } 67 | 68 | private getSingletonInstance(descriptor: ServiceDescriptor): any { 69 | if (!this.singletonInstances.has(descriptor.token)) { 70 | this.singletonInstances.set(descriptor.token, new descriptor.implementation()); 71 | } 72 | return this.singletonInstances.get(descriptor.token); 73 | } 74 | } 75 | 76 | // Service decorator (similar to @Injectable in Blazor) 77 | export function injectable(lifetime: ServiceLifetime = ServiceLifetime.Singleton) { 78 | return function (target: any) { 79 | const container = ServiceContainer.getInstance(); 80 | const token = container.getOrCreateToken(target); 81 | container.addService(token, target, lifetime); 82 | return target; 83 | }; 84 | } 85 | 86 | // Inject decorator (auto-detects type if not provided) 87 | import "reflect-metadata"; 88 | 89 | export function inject(serviceType?: any) { 90 | return function (target: any, propertyKey: string) { 91 | // If no explicit type, use reflect-metadata to get the property type 92 | const type = serviceType || Reflect.getMetadata("design:type", target, propertyKey); 93 | if (!type) { 94 | throw new Error(`Cannot resolve type for property '${propertyKey}'. Make sure emitDecoratorMetadata is enabled.`); 95 | } 96 | const container = ServiceContainer.getInstance(); 97 | const token = container.getOrCreateToken(type); 98 | const descriptor = { 99 | get: function (this: any) { 100 | return container.getService(token); 101 | }, 102 | enumerable: true, 103 | configurable: true 104 | }; 105 | Object.defineProperty(target, propertyKey, descriptor); 106 | }; 107 | } -------------------------------------------------------------------------------- /src/fw/update-notification.ts: -------------------------------------------------------------------------------- 1 | import { ComponentBase, customElement, html, css, state } from "fw"; 2 | 3 | @customElement("update-notification") 4 | export class UpdateNotification extends ComponentBase { 5 | @state() 6 | private visible = false; 7 | 8 | static override useShadowDom = true; 9 | 10 | static styles = css` 11 | :host { 12 | position: fixed; 13 | top: 0; 14 | left: 0; 15 | right: 0; 16 | z-index: 10000; 17 | font-family: -apple-system, BlinkMacSystemFont, 'Segoe UI', Roboto, sans-serif; 18 | } 19 | 20 | .notification { 21 | background: linear-gradient(135deg, #3b82f6 0%, #1d4ed8 100%); 22 | color: white; 23 | padding: 12px 20px; 24 | display: flex; 25 | align-items: center; 26 | justify-content: center; 27 | gap: 16px; 28 | box-shadow: 0 2px 10px rgba(0, 0, 0, 0.1); 29 | transform: translateY(-100%); 30 | transition: transform 0.3s ease-in-out; 31 | } 32 | 33 | .notification.visible { 34 | transform: translateY(0); 35 | } 36 | 37 | .notification-content { 38 | display: flex; 39 | align-items: center; 40 | gap: 12px; 41 | flex: 1; 42 | justify-content: center; 43 | max-width: 600px; 44 | } 45 | 46 | .notification-message { 47 | margin: 0; 48 | font-size: 14px; 49 | font-weight: 500; 50 | } 51 | 52 | .reload-btn { 53 | background: white; 54 | color: #3b82f6; 55 | border: none; 56 | padding: 8px 16px; 57 | border-radius: 6px; 58 | cursor: pointer; 59 | font-size: 13px; 60 | font-weight: 600; 61 | transition: all 0.2s; 62 | text-decoration: none; 63 | display: inline-flex; 64 | align-items: center; 65 | gap: 6px; 66 | } 67 | 68 | .reload-btn:hover { 69 | background: #f8fafc; 70 | transform: translateY(-1px); 71 | box-shadow: 0 2px 8px rgba(0, 0, 0, 0.15); 72 | } 73 | 74 | .icon { 75 | width: 18px; 76 | height: 18px; 77 | fill: currentColor; 78 | } 79 | 80 | .reload-icon { 81 | width: 14px; 82 | height: 14px; 83 | fill: currentColor; 84 | } 85 | 86 | @media (max-width: 768px) { 87 | .notification { 88 | padding: 10px 16px; 89 | text-align: center; 90 | } 91 | 92 | .notification-content { 93 | flex-direction: column; 94 | gap: 8px; 95 | } 96 | 97 | .notification-message { 98 | font-size: 13px; 99 | } 100 | 101 | .reload-btn { 102 | padding: 6px 12px; 103 | font-size: 12px; 104 | } 105 | } 106 | `; 107 | 108 | show(): void { 109 | this.visible = true; 110 | } 111 | 112 | hide(): void { 113 | this.visible = false; 114 | } 115 | 116 | private handleReload(): void { 117 | this.dispatchEvent(new CustomEvent('reload-requested', { 118 | bubbles: true, 119 | composed: true 120 | })); 121 | } 122 | 123 | render() { 124 | return html` 125 |
126 |
127 | 128 | 129 | 130 |
131 | A new version is available with latest features and improvements 132 |
133 | 139 |
140 |
141 | `; 142 | } 143 | } 144 | -------------------------------------------------------------------------------- /src/fw/version-checker.ts: -------------------------------------------------------------------------------- 1 | import { ComponentBase, customElement, html, css, state } from "fw"; 2 | import { VersionService } from "../services/VersionService"; 3 | 4 | @customElement("version-checker") 5 | export class VersionChecker extends ComponentBase { 6 | private versionService = new VersionService(); 7 | 8 | @state() 9 | private isChecking = false; 10 | 11 | @state() 12 | private lastCheckTime?: Date; 13 | 14 | @state() 15 | private currentVersion?: string; 16 | 17 | @state() 18 | private isPWA?: boolean; 19 | 20 | static override useShadowDom = true; 21 | 22 | static styles = css` 23 | :host { 24 | display: inline-block; 25 | } 26 | 27 | .version-checker { 28 | display: flex; 29 | align-items: center; 30 | gap: 8px; 31 | padding: 8px 12px; 32 | background: #f8fafc; 33 | border: 1px solid #e2e8f0; 34 | border-radius: 6px; 35 | font-size: 12px; 36 | color: #64748b; 37 | } 38 | 39 | .check-btn { 40 | background: #3b82f6; 41 | color: white; 42 | border: none; 43 | padding: 4px 8px; 44 | border-radius: 4px; 45 | cursor: pointer; 46 | font-size: 11px; 47 | font-weight: 500; 48 | transition: background-color 0.2s; 49 | } 50 | 51 | .check-btn:hover:not(:disabled) { 52 | background: #2563eb; 53 | } 54 | 55 | .check-btn:disabled { 56 | opacity: 0.6; 57 | cursor: not-allowed; 58 | } 59 | 60 | .version-info { 61 | font-family: monospace; 62 | font-size: 11px; 63 | } 64 | 65 | .status { 66 | font-weight: 500; 67 | } 68 | 69 | .status.checking { 70 | color: #f59e0b; 71 | } 72 | 73 | .status.up-to-date { 74 | color: #059669; 75 | } 76 | 77 | .status.update-available { 78 | color: #dc2626; 79 | } 80 | `; 81 | 82 | async connectedCallback() { 83 | super.connectedCallback(); 84 | await this.loadCurrentVersion(); 85 | } 86 | 87 | private async loadCurrentVersion(): Promise { 88 | try { 89 | await this.versionService.initialize(); 90 | const version = this.versionService.getCurrentVersion(); 91 | this.currentVersion = version?.version || 'unknown'; 92 | this.isPWA = this.versionService.isPWAMode(); 93 | } catch (error) { 94 | console.warn('Failed to load current version:', error); 95 | } 96 | } 97 | 98 | private async handleCheckVersion(): Promise { 99 | this.isChecking = true; 100 | 101 | try { 102 | const hasUpdate = await this.versionService.checkForUpdates(); 103 | this.lastCheckTime = new Date(); 104 | 105 | if (hasUpdate) { 106 | // Dispatch event to notify parent components 107 | this.dispatchEvent(new CustomEvent('update-available', { 108 | bubbles: true, 109 | composed: true, 110 | detail: { hasUpdate: true } 111 | })); 112 | } 113 | } catch (error) { 114 | console.error('Failed to check for updates:', error); 115 | } finally { 116 | this.isChecking = false; 117 | } 118 | } 119 | 120 | private async handleForceReload(): Promise { 121 | try { 122 | await this.versionService.reloadWithCacheClear(); 123 | } catch (error) { 124 | console.error('Failed to reload:', error); 125 | window.location.reload(); 126 | } 127 | } 128 | 129 | render() { 130 | const formatTime = (date: Date) => { 131 | return date.toLocaleTimeString([], { hour: '2-digit', minute: '2-digit' }); 132 | }; 133 | 134 | return html` 135 |
136 |
137 | v${this.currentVersion || '?'}${this.isPWA ? ' (PWA)' : ''} 138 |
139 | 140 | 148 | 149 | 156 | 157 | ${this.lastCheckTime ? html` 158 |
159 | Last check: ${formatTime(this.lastCheckTime)} 160 |
161 | ` : ''} 162 |
163 | `; 164 | } 165 | } 166 | -------------------------------------------------------------------------------- /src/pages/home-page.ts: -------------------------------------------------------------------------------- 1 | import { ComponentBase, customElement, html, route } from "fw"; 2 | 3 | @customElement("home-page") 4 | @route("/", { 5 | title: "Playable Tools for HTML5 Ads", 6 | description: 7 | "A collection of open-source tools for HTML5 playable ads developers, including publishing, asset compression, and validation.", 8 | }) 9 | export class HomePage extends ComponentBase { 10 | render() { 11 | return html` 12 |
13 |
14 |

Playable Ads Tools

15 |

16 | This app provides a collection of open-source, useful tools for HTML5 17 | playable ads developers. 18 |

19 |
20 | 21 |

Available Tools:

22 | 23 |
24 | ${this.renderCard( 25 | "📤 Publish to Ad Networks", 26 | "Publishing workflow automation - Streamline the process of deploying your playable ads to various advertising networks.", 27 | "#publish", 28 | "Start Publishing" 29 | )} 30 | 31 | ${this.renderCard( 32 | "🔄 Base64 Converter", 33 | "File to Base64 encoding - Convert files and images to Base64 encoding for embedding in your HTML5 playable ads.", 34 | "#base64", 35 | "Convert Files" 36 | )} 37 | 38 | ${this.renderCard( 39 | "🎬 Video to Sprite", 40 | "MP4 to PNG sprite sequences - Transform MP4 videos into PNG sprite sequences for game development and animations.", 41 | "#video2sprite", 42 | "Convert Videos" 43 | )} 44 | 45 | ${this.renderCard( 46 | "📊 Folder Size Map", 47 | "Interactive folder analysis - Analyze and visualize the size structure of local folders using sunburst charts, treemaps, and tree views.", 48 | "#folder-size-visualizer", 49 | "Visualize Folders" 50 | )} 51 | 52 | ${this.renderCard( 53 | "🗜️ Imba Packer", 54 | "Experimental HTML compression - Maximizing file size reduction while preserving functionality. Great for size-constrained ads.", 55 | "#imba-packer", 56 | "Compress HTML" 57 | )} 58 | 59 | ${this.renderCard( 60 | "🖼️ Assets Compression", 61 | "PNG optimization tools - Optimize your PNG images and other assets to reduce file size and improve loading times using PngChpocker.", 62 | "#compress-assets", 63 | "Compress Assets" 64 | )} 65 | 66 | ${this.renderCard( 67 | "📖 CTA SDK Documentation", 68 | "Integration guides - Complete guide for integrating the Call-to-Action SDK in your playable ads. Essential for successful publishing.", 69 | "#cta-sdk", 70 | "View Documentation" 71 | )} 72 | 73 | ${this.renderCard( 74 | "✅ Ad Network Requirements", 75 | "Technical specifications - Stay up-to-date with the specific technical requirements and specifications for different advertising networks.", 76 | "#validate", 77 | "Check Requirements" 78 | )} 79 | 80 | ${this.renderCard( 81 | "📱 Playable Preview", 82 | "Multi-device testing - Preview and share your playable ad creations from GitHub on different devices and orientations.", 83 | "#preview", 84 | "Preview Ads" 85 | )} 86 | 87 | ${this.renderCard( 88 | "📂 Portfolio Management", 89 | "GitHub integration - Manage and view your portfolio of playable ads from GitHub repositories. Organize and showcase your work.", 90 | "#portfolio", 91 | "Manage Portfolio" 92 | )} 93 |
94 |
95 | `; 96 | } 97 | 98 | private renderCard(title: string, description: string, link: string, linkText: string) { 99 | return html` 100 |
101 |

${title}

102 |

${description}

103 | 107 | ${linkText} 108 | 109 |
110 | `; 111 | } 112 | } 113 | -------------------------------------------------------------------------------- /src/Layout/nav-menu.ts: -------------------------------------------------------------------------------- 1 | import { ComponentBase, customElement, html } from "fw"; 2 | import { ifDefined } from "lit/directives/if-defined.js"; 3 | import { unsafeHTML } from "lit/directives/unsafe-html.js"; 4 | import gamepadIconSvg from "./gamepad-icon.svg?raw"; 5 | 6 | @customElement("nav-menu") 7 | export class NavMenu extends ComponentBase { 8 | private menuItems = [ 9 | { 10 | category: "", 11 | items: [ 12 | { label: "My playables", icon: "folder_special", path: "/portfolio", disabled: false }, 13 | ] 14 | }, 15 | { 16 | category: "Main tools", 17 | items: [ 18 | { label: "Preview", icon: "visibility", path: "/preview", disabled: false }, 19 | { label: "Publish", icon: "publish", path: "/publish", disabled: false }, 20 | ] 21 | }, 22 | { 23 | category: "Manuals", 24 | items: [ 25 | { label: "CTA SDK", icon: "gamepad", path: "/cta-sdk", disabled: false }, 26 | { label: "Guides", icon: "check_circle", path: "/validate", disabled: false }, 27 | ] 28 | }, 29 | { 30 | category: "Extra tools", 31 | items: [ 32 | { label: "Compress assets", icon: "compress", path: "/compress-assets", disabled: false }, 33 | { label: "Base64 Converter", icon: "code", path: "/base64", disabled: false }, 34 | { label: "Imba Packer", icon: "inventory_2", path: "/imba-packer", disabled: false }, 35 | { label: "Folder Size Map", icon: "folder", path: "/folder-size-visualizer", disabled: false }, 36 | { label: "Video to Sprite", icon: "movie_filter", path: "/video2sprite", disabled: false }, 37 | { label: "Spritesheet Maker", icon: "auto_awesome_motion", path: "/spritesheet-maker", disabled: false }, 38 | ] 39 | } 40 | ]; 41 | 42 | private get currentPath() { 43 | // Get current hash path 44 | let hash = window.location.hash ? window.location.hash.substring(1) : ''; 45 | if (!hash.startsWith('/')) hash = '/' + hash; 46 | 47 | // Return null for root path (home page) so no items are selected 48 | if (hash === '/' || hash === '') return null; 49 | 50 | // Remove query params 51 | const queryIndex = hash.indexOf('?'); 52 | if (queryIndex !== -1) { 53 | return hash.substring(0, queryIndex); 54 | } 55 | 56 | return hash; 57 | } 58 | 59 | connectedCallback() { 60 | super.connectedCallback(); 61 | // Listen for hash changes to update active state 62 | window.addEventListener('hashchange', this.handleHashChange); 63 | } 64 | 65 | disconnectedCallback() { 66 | super.disconnectedCallback(); 67 | window.removeEventListener('hashchange', this.handleHashChange); 68 | } 69 | 70 | private handleHashChange = () => { 71 | this.requestUpdate(); 72 | }; 73 | 74 | render() { 75 | return html` 76 | 114 | `; 115 | } 116 | } 117 | -------------------------------------------------------------------------------- /index.html: -------------------------------------------------------------------------------- 1 | 2 | 3 | 4 | 5 | 6 | 7 | 8 | 9 | 10 | Playable Ads Tools - tools.gritsenko.biz 11 | 12 | 13 | 14 | 15 | 16 | 17 | 18 | 19 | 20 | 21 | 22 | 23 | 24 | 25 | 26 | 27 | 28 | 48 | 53 | 54 | 55 | 56 | 57 | 58 | 59 | 60 | 61 | 62 | 63 |

Playable Ads Tools

64 | 65 |

66 | This app provides a collection of open-source, useful tools for HTML5 67 | playable ads developers. 68 |

69 | 70 |

Core Features:

71 |
    72 |
  • 73 | Publish to Ad Networks: Streamline the process of 74 | deploying your playable ads to various advertising networks. 75 |
  • 76 |
  • 77 | Assets Compression: Optimize your images, scripts, 78 | and other assets to reduce file size and improve loading times. 79 |
  • 80 |
  • 81 | Base64 Converter: Convert files and images to Base64 82 | encoding for embedding in your HTML5 playable ads. 83 |
  • 84 |
  • 85 | Video to Sprite Converter: Transform MP4 videos into 86 | PNG sprite sequences for game development and animations. 87 |
  • 88 |
  • 89 | Folder Size Visualizer: Analyze and visualize the 90 | size structure of local folders using interactive charts. 91 |
  • 92 |
  • 93 | Imba Packer: Experimental HTML compression tool for 94 | maximizing file size reduction while preserving functionality. 95 |
  • 96 |
  • 97 | CTA SDK Documentation: Complete guide for integrating 98 | the Call-to-Action SDK in your playable ads. 99 |
  • 100 |
  • 101 | Ad Network Requirements: Stay up-to-date with the 102 | specific requirements and specifications for different ad networks. 103 |
  • 104 |
  • 105 | Playable Ads Preview: Preview and share your playable ad 106 | creations from GitHub on different devices and orientations. 107 |
  • 108 |
  • 109 | Portfolio Management: Manage and view your portfolio 110 | of playable ads from GitHub repositories. 111 |
  • 112 |
113 |
114 | 120 | 121 | 122 | -------------------------------------------------------------------------------- /public/playable-screenshot.js: -------------------------------------------------------------------------------- 1 | (function() { 2 | 'use strict'; 3 | 4 | // Expose API to parent window 5 | window.__ptScreenshot = { 6 | captureBlob: async (callback) => { 7 | try { 8 | // Wait a bit for WebGL to be ready 9 | await new Promise(resolve => setTimeout(resolve, 500)); 10 | 11 | // Find any canvas elements (common for games/WebGL content) 12 | const canvases = document.querySelectorAll('canvas'); 13 | console.log(`[Screenshot] Found ${canvases.length} canvas elements`); 14 | 15 | let targetCanvas = null; 16 | 17 | // Try to find the largest visible canvas (likely the game canvas) 18 | if (canvases.length > 0) { 19 | let maxArea = 0; 20 | for (const canvas of canvases) { 21 | const rect = canvas.getBoundingClientRect(); 22 | const isVisible = rect.width > 0 && rect.height > 0; 23 | const area = canvas.width * canvas.height; 24 | console.log(`[Screenshot] Canvas: ${canvas.width}x${canvas.height} (visible: ${isVisible}, area: ${area})`); 25 | 26 | if (isVisible && area > maxArea) { 27 | maxArea = area; 28 | targetCanvas = canvas; 29 | console.log(`[Screenshot] Selected canvas as target`); 30 | } 31 | } 32 | } 33 | 34 | if (targetCanvas && targetCanvas.width > 0 && targetCanvas.height > 0) { 35 | console.log(`[Screenshot] Capturing from canvas: ${targetCanvas.width}x${targetCanvas.height}`); 36 | 37 | // Wait for the next animation frame to ensure the canvas is rendered 38 | await new Promise(resolve => { 39 | requestAnimationFrame(() => { 40 | requestAnimationFrame(resolve); 41 | }); 42 | }); 43 | 44 | // Try to get WebGL context and check for preserveDrawingBuffer 45 | let webglContext = null; 46 | try { 47 | webglContext = targetCanvas.getContext('webgl2') || targetCanvas.getContext('webgl'); 48 | if (webglContext) { 49 | console.log('[Screenshot] Found WebGL context'); 50 | // Try to get the parameter to see if preserveDrawingBuffer is set 51 | const params = webglContext.getParameter(webglContext.UNPACK_COLORSPACE_CONVERSION_WEBGL); 52 | console.log('[Screenshot] WebGL context acquired'); 53 | } 54 | } catch (e) { 55 | console.warn('[Screenshot] Could not access WebGL context:', e); 56 | } 57 | 58 | // Try multiple methods to capture 59 | try { 60 | // Method 1: Try toDataURL (most reliable for WebGL) 61 | console.log('[Screenshot] Attempting toDataURL...'); 62 | const dataUrl = targetCanvas.toDataURL('image/png'); 63 | 64 | if (dataUrl && dataUrl !== 'data:,') { 65 | console.log(`[Screenshot] Got data URL: ${dataUrl.length} chars`); 66 | 67 | // Convert data URL to blob 68 | const response = await fetch(dataUrl); 69 | const blob = await response.blob(); 70 | 71 | if (blob && blob.size > 100) { // Check if it's not just a tiny blank image 72 | console.log(`[Screenshot] Successfully captured blob: ${blob.size} bytes`); 73 | callback(null, blob); 74 | return; 75 | } else { 76 | console.warn('[Screenshot] Data URL blob too small:', blob.size); 77 | } 78 | } 79 | } catch (err) { 80 | console.warn('[Screenshot] toDataURL failed:', err); 81 | } 82 | 83 | // Method 2: Try toBlob as fallback 84 | try { 85 | console.log('[Screenshot] Attempting toBlob as fallback...'); 86 | targetCanvas.toBlob((blob) => { 87 | if (blob && blob.size > 100) { 88 | console.log(`[Screenshot] toBlob successful: ${blob.size} bytes`); 89 | callback(null, blob); 90 | } else { 91 | console.warn('[Screenshot] toBlob blob too small:', blob?.size); 92 | callback(new Error('Canvas appears to be blank (WebGL preserveDrawingBuffer may be false)'), null); 93 | } 94 | }, 'image/png'); 95 | } catch (err) { 96 | console.error('[Screenshot] toBlob failed:', err); 97 | callback(err, null); 98 | } 99 | } else { 100 | console.log('[Screenshot] No valid canvas found, attempting fallback to html2canvas'); 101 | 102 | // Fallback: try html2canvas for HTML-only content 103 | if (!window.html2canvas) { 104 | await new Promise((resolve, reject) => { 105 | const script = document.createElement('script'); 106 | script.src = 'https://cdnjs.cloudflare.com/ajax/libs/html2canvas/1.4.1/html2canvas.min.js'; 107 | script.onload = resolve; 108 | script.onerror = () => reject(new Error('Failed to load html2canvas')); 109 | document.head.appendChild(script); 110 | }); 111 | } 112 | 113 | const canvas = await window.html2canvas(document.body, { 114 | backgroundColor: '#ffffff', 115 | scale: 2, 116 | logging: false, 117 | useCORS: true, 118 | allowTaint: true, 119 | windowHeight: document.documentElement.scrollHeight, 120 | windowWidth: document.documentElement.scrollWidth 121 | }); 122 | 123 | canvas.toBlob((blob) => { 124 | if (blob) { 125 | console.log(`[Screenshot] html2canvas fallback successful: ${blob.size} bytes`); 126 | callback(null, blob); 127 | } else { 128 | callback(new Error('Failed to create blob from html2canvas'), null); 129 | } 130 | }, 'image/jpeg', 0.95); 131 | } 132 | } catch (error) { 133 | console.error('[Screenshot] Capture failed:', error); 134 | callback(error, null); 135 | } 136 | } 137 | }; 138 | 139 | console.log('PlayableTools screenshot capture API ready'); 140 | })(); 141 | -------------------------------------------------------------------------------- /src/fw/router.ts: -------------------------------------------------------------------------------- 1 | import { LitElement, html } from 'lit'; 2 | import type { TemplateResult } from 'lit-html'; 3 | import { customElement, property, state } from 'lit/decorators.js'; 4 | import { metadataService, type PageMetadata } from '../services/MetadataService'; 5 | 6 | @customElement('router-outlet') 7 | export class RouterOutlet extends LitElement { 8 | 9 | @property({ attribute: false }) defaultLayout?: typeof LitElement; 10 | 11 | @state() private currentPath = ''; 12 | 13 | private handleNavigation = () => { 14 | // Use hash-based routing for static hosting 15 | const hash = window.location.hash; 16 | console.log(`🔀 Router: hashchange event, hash='${hash}'`); 17 | // Remove leading '#' and ensure leading '/' 18 | let path = hash ? hash.substring(1) : ''; 19 | // Separate path and query string 20 | const [routePath] = path.split('?'); 21 | let normalizedPath = routePath; 22 | if (!normalizedPath.startsWith('/')) normalizedPath = '/' + normalizedPath; 23 | console.log(`🔀 Router: normalized path='${normalizedPath}'`); 24 | this.currentPath = normalizedPath; 25 | this.requestUpdate(); 26 | }; 27 | 28 | 29 | connectedCallback() { 30 | super.connectedCallback(); 31 | console.log(`🔀 Router: connectedCallback, current hash='${window.location.hash}'`); 32 | window.addEventListener('hashchange', this.handleNavigation); 33 | this.handleNavigation(); 34 | } 35 | 36 | disconnectedCallback() { 37 | super.disconnectedCallback(); 38 | window.removeEventListener('hashchange', this.handleNavigation); 39 | } 40 | 41 | createRenderRoot() { 42 | return this; 43 | } 44 | render() { 45 | console.log(`🔀 Router: rendering, currentPath='${this.currentPath}', registered routes=${routeRegistry.size}`); 46 | console.log(`🔀 Router: registered route paths: ${Array.from(routeRegistry.keys()).join(', ')}`); 47 | 48 | // Find matching route 49 | for (const [path, routeInfo] of routeRegistry.entries()) { 50 | const routeParts = path.split('/').filter(Boolean); // e.g. ['preview', ':playableId?'] 51 | const currentParts = this.currentPath.split('/').filter(Boolean); // e.g. ['preview', '123'] 52 | 53 | console.log(`🔀 Router: checking path='${path}' -> routeParts=${JSON.stringify(routeParts)} currentParts=${JSON.stringify(currentParts)}`); 54 | 55 | const params: string[] = []; 56 | let rpIdx = 0; // index for routeParts 57 | let cpIdx = 0; // index for currentParts 58 | let matched = true; 59 | 60 | while (rpIdx < routeParts.length) { 61 | const rp = routeParts[rpIdx]; 62 | 63 | // parameter segment 64 | if (rp.startsWith(':')) { 65 | const isOptional = rp.endsWith('?'); 66 | // paramName available if needed: rp.replace(/^:|\?$/g, '') 67 | 68 | if (cpIdx < currentParts.length) { 69 | // consume current part as parameter value 70 | params.push(currentParts[cpIdx]); 71 | cpIdx++; 72 | } else if (isOptional) { 73 | // optional param not provided -> push empty string placeholder 74 | params.push(''); 75 | } else { 76 | // required param missing -> no match 77 | matched = false; 78 | break; 79 | } 80 | } else { 81 | // static segment - must equal current segment 82 | if (cpIdx >= currentParts.length || currentParts[cpIdx] !== rp) { 83 | matched = false; 84 | break; 85 | } 86 | cpIdx++; 87 | } 88 | 89 | rpIdx++; 90 | } 91 | 92 | // If there are remaining current parts that weren't matched, this route doesn't match 93 | if (matched && cpIdx < currentParts.length) matched = false; 94 | 95 | console.log(`🔀 Router: checking path='${path}' -> matched=${matched} params=${JSON.stringify(params)}`); 96 | 97 | if (matched) { 98 | console.log(`✅ Router: matched path='${path}', params=${JSON.stringify(params)}`); 99 | if (routeInfo.metadata) { 100 | metadataService.update(routeInfo.metadata); 101 | } else { 102 | metadataService.update({ title: "PlayableTools" }); 103 | } 104 | 105 | // Normalize params (strip leading slashes if any and remove empty placeholders) 106 | const normalizedParams = params.map(p => p ? p.replace(/^\//, '') : '').filter(p => p !== ''); 107 | const instance = new routeInfo.component() as any; 108 | instance.routeParams = normalizedParams; 109 | return this.renderContentWithLayout(() => html`
${instance}
`); 110 | } 111 | } 112 | 113 | console.log(`❌ Router: no route matched for path='${this.currentPath}'`); 114 | metadataService.update({ title: "Page Not Found" }); 115 | return this.renderContentWithLayout(() => html`

404 Not Found

`); 116 | } 117 | 118 | renderContentWithLayout(content: () => TemplateResult) { 119 | if (!this.defaultLayout) 120 | return content(); 121 | 122 | const layout = new this.defaultLayout() as any; 123 | layout.body = content(); 124 | return html` 125 |
${layout}
126 | `; 127 | } 128 | } 129 | 130 | export const routeRegistry = new Map(); 131 | export function route(path: string, metadata?: PageMetadata) { 132 | return function (constructor: T) { 133 | routeRegistry.set(path, { component: constructor, metadata }); 134 | return constructor; 135 | }; 136 | } -------------------------------------------------------------------------------- /src/pages/folder-size/folder-tree-view.ts: -------------------------------------------------------------------------------- 1 | import { ComponentBase, customElement, html, property } from "fw"; 2 | 3 | 4 | interface FileNode { 5 | name: string; 6 | size: number; 7 | isDirectory: boolean; 8 | children?: FileNode[]; 9 | handle?: FileSystemHandle; 10 | } 11 | 12 | @customElement("folder-tree-view") 13 | export class FolderTreeView extends ComponentBase { 14 | @property({ type: Array }) 15 | fileTree: FileNode[] = []; 16 | 17 | // track which directories are expanded; store paths (unique per node) 18 | private _expanded = new Set(); 19 | 20 | private _formatSize(bytes: number): string { 21 | if (bytes === 0) return '0 B'; 22 | const k = 1024; 23 | const sizes = ['B', 'KB', 'MB', 'GB']; 24 | const i = Math.floor(Math.log(bytes) / Math.log(k)); 25 | return parseFloat((bytes / Math.pow(k, i)).toFixed(2)) + ' ' + sizes[i]; 26 | } 27 | 28 | // pathPrefix is used to create a stable id/path for each node (e.g. "root/folder/sub") 29 | private _renderTree(nodes: FileNode[], level = 0, pathPrefix = '', maxSize = 0): any { 30 | return nodes.map(node => { 31 | const nodePath = pathPrefix ? `${pathPrefix}/${node.name}` : node.name; 32 | const isExpanded = this._expanded.has(nodePath); 33 | const nodeTotal = this._nodeTotalSize(node); 34 | // protect against division by zero 35 | const pct = maxSize > 0 ? Math.max(0.5, (nodeTotal / maxSize) * 100) : 0.5; 36 | 37 | return html` 38 |
39 |
40 | 41 | 42 |
43 | ${node.isDirectory ? html` 44 | 51 | ` : html``} 52 | 53 | node.isDirectory && this._toggle(nodePath)} 56 | title="${node.name}" 57 | > 58 | 59 | ${node.isDirectory ? (isExpanded ? 'folder_open' : 'folder') : 'description'} 60 | 61 | ${node.name} 62 | 63 | 64 | ${this._formatSize(node.size)} 65 | 66 |
67 |
68 | 69 | ${node.isDirectory && isExpanded ? html` 70 |
71 | ${node.children && node.children.length > 0 72 | ? this._renderTree(node.children, level + 1, nodePath, maxSize) 73 | : html`
Empty directory
` 74 | } 75 |
76 | ` : ''} 77 |
78 | `; 79 | }); 80 | } 81 | 82 | // compute maximum size in the provided tree (recursively) 83 | private _getMaxSize(nodes: FileNode[] = []): number { 84 | let max = 0; 85 | const walk = (n: FileNode) => { 86 | const size = this._nodeTotalSize(n); 87 | if (size > max) max = size; 88 | if (n.children) n.children.forEach(c => walk(c)); 89 | }; 90 | nodes.forEach(n => walk(n)); 91 | return max; 92 | } 93 | 94 | // return the total size for a node: prefer node.size if present, otherwise sum children 95 | private _nodeTotalSize(node: FileNode): number { 96 | if (!node) return 0; 97 | if (!node.isDirectory) return node.size || 0; 98 | if (typeof node.size === 'number' && node.size > 0) return node.size; 99 | if (!node.children || node.children.length === 0) return node.size || 0; 100 | return node.children.reduce((sum, c) => sum + this._nodeTotalSize(c), 0); 101 | } 102 | 103 | private _toggle(path: string) { 104 | if (this._expanded.has(path)) { 105 | this._expanded.delete(path); 106 | } else { 107 | this._expanded.add(path); 108 | } 109 | // trigger re-render 110 | this.requestUpdate(); 111 | } 112 | 113 | // Return a new array where nodes and their children are sorted by size (descending) 114 | private _sortNodesDescending(nodes: FileNode[] = []): FileNode[] { 115 | // Create shallow copies so we don't mutate original data 116 | const copied = nodes.map(n => ({ ...n })); 117 | 118 | // Recursively sort children first 119 | copied.forEach(n => { 120 | if (n.children && n.children.length > 0) { 121 | n.children = this._sortNodesDescending(n.children); 122 | } 123 | }); 124 | 125 | // Sort by size descending (largest first) 126 | copied.sort((a, b) => b.size - a.size); 127 | return copied; 128 | } 129 | 130 | render() { 131 | if (this.fileTree.length === 0) { 132 | return html` 133 |
134 | data_usage 135 |

No data to display

136 |
137 | `; 138 | } 139 | 140 | const maxSize = this._getMaxSize(this.fileTree); 141 | return html` 142 |
143 | ${this._renderTree(this._sortNodesDescending(this.fileTree), 0, '', maxSize)} 144 |
145 | `; 146 | } 147 | } -------------------------------------------------------------------------------- /public/fb_validator.js: -------------------------------------------------------------------------------- 1 | console.warn("🤦‍♂️ Facebook validator loaded successfully!"); 2 | if ( 3 | !Boolean(navigator.userAgent.match(/android/i)) && 4 | Boolean(navigator.userAgent.match(/Chrome/) || 5 | navigator.userAgent.match(/Firefox/) || 6 | navigator.userAgent.match(/Safari/) || 7 | navigator.userAgent.match(/MSIE|Trident|Edge/))) { 8 | window.FbPlayableAd = { 9 | onCTAClick() { 10 | window.parent.postMessage("CTAClick", "*"); 11 | alert("[Facebook validator] FbPlayableAd.onCTAClick() called"); 12 | }, 13 | initializeLogging(endpoint_url) {}, 14 | logGameLoad() {}, 15 | logButtonClick(name, x, y) {}, 16 | logLevelComplete(level_name) {}, 17 | logEndCardShowUp() {}, 18 | }; 19 | FbPlayableAd = window.FbPlayableAd; 20 | }; function getProtocol(val) { 21 | var parser = document.createElement('a'); 22 | parser.href = val; 23 | return parser.protocol; 24 | }; 25 | 26 | function hasValidProtocolForPlayable(val) { 27 | var protocol = getProtocol(val); 28 | return 'data:' === protocol || 'blob:' === protocol; 29 | }; 30 | 31 | function needsToBeBlacklisted(src) { 32 | if (src == "https://code.jquery.com/jquery-1.7.1.min.js") { 33 | return false; 34 | } 35 | return true; 36 | }; // block standard (new Image).src = attack by proxy the real Image 37 | var NativeImage = window.Image; 38 | const oldSrcDescriptor = Object.getOwnPropertyDescriptor(window.Image.prototype, 'src'); 39 | createImage = function (arguments) { 40 | var image = new NativeImage(arguments); 41 | Object.defineProperty(image, 'src', { 42 | set: function (srcAttr) { 43 | // whatever else you want to put in here 44 | if (hasValidProtocolForPlayable(srcAttr)) { 45 | oldSrcDescriptor.set.call(image, srcAttr); 46 | } 47 | }, 48 | get: function () { 49 | return oldSrcDescriptor.get.call(image); 50 | } 51 | }); 52 | 53 | image.setAttribute = function(name, value) { 54 | image[name] = value; 55 | }; 56 | return image; 57 | }; 58 | 59 | if (typeof window.Image !== 'object') { 60 | window.Image = createImage; 61 | } 62 | 63 | // block XMLHttpRequest approach 64 | XMLHttpRequest.prototype.send = function() { 65 | return false; 66 | }; 67 | 68 | // block fetch approach 69 | var origFetch = window.fetch; 70 | window.fetch = function(url){ 71 | if (hasValidProtocolForPlayable(url)) { 72 | return origFetch(url); 73 | } 74 | }; // block static remove asset loading by removing them from DOM 75 | const observer = new MutationObserver(mutations => { 76 | mutations.forEach(({addedNodes}) => { 77 | addedNodes.forEach(node => { 78 | if ( 79 | node.tagName === 'IMG' || 80 | node.tagName === 'VIDEO' || 81 | node.tagName === 'AUDIO' 82 | ) { 83 | if(node.src && !hasValidProtocolForPlayable(node.src)) { 84 | // strip out of the DOM tree completely for risk management 85 | node.parentElement.removeChild(node); 86 | } 87 | } 88 | }) 89 | }) 90 | }) 91 | 92 | // Starts the monitoring 93 | observer.observe(document.documentElement, { 94 | childList: true, 95 | subtree: true 96 | }); // block JSONP approach & remote src setter 97 | function handleElement(proto, element) { 98 | const originalDescriptors = { 99 | src: Object.getOwnPropertyDescriptor(proto, 'src'), 100 | type: Object.getOwnPropertyDescriptor(proto, 'type') 101 | }; 102 | 103 | Object.defineProperties(element, { 104 | 'src': { 105 | get() { 106 | return originalDescriptors.src.get.call(element) 107 | }, 108 | set(value) { 109 | if (proto === HTMLImageElement.prototype || proto === HTMLMediaElement.prototype) { 110 | if (hasValidProtocolForPlayable(value)) { 111 | return originalDescriptors.src.set.call(element, value) 112 | } 113 | else { 114 | // If it's not a valid protocol then just set an empty 115 | // string as a src to avoid unnecessary observer calls 116 | return originalDescriptors.src.set.call(element, '') 117 | } 118 | } else if (proto === HTMLScriptElement.prototype) { 119 | if (needsToBeBlacklisted(value, element.type)) { 120 | element.type = 'javascript/blocked' 121 | } 122 | return originalDescriptors.src.set.call(element, value) 123 | } 124 | } 125 | }, 126 | 'type': { 127 | set(value) { 128 | if (proto === HTMLScriptElement.prototype) { 129 | return originalDescriptors.type.set.call( 130 | element, 131 | // If a third-party code tries to set the type, but the source is blacklisted then prevent. 132 | needsToBeBlacklisted(element.src, element.type) ? 133 | 'javascript/blocked' : 134 | value 135 | ) 136 | } else { 137 | return originalDescriptors.src.set.call(element, value) 138 | } 139 | } 140 | } 141 | }); 142 | 143 | element.setAttribute = function(name, value) { 144 | var attr = document.createAttribute(name); 145 | attr.value = value; 146 | element.attributes.setNamedItem(attr); 147 | }; 148 | return element; 149 | }; 150 | 151 | const createElementBackup = document.createElement; 152 | document.createElement = function(...args) { 153 | // If this is not a script tag, bypass 154 | const tagName = args[0].toLowerCase(); 155 | if (tagName !== 'script' && tagName !== 'img' && tagName !== 'video' && tagName !=='audio') { 156 | // Binding to document is essential 157 | return createElementBackup.bind(document)(...args) 158 | } 159 | let element = createElementBackup.bind(document)(...args) 160 | if (tagName === 'img') { 161 | //HTMLImageElement.prototype 162 | return handleElement(HTMLImageElement.prototype, element); 163 | } 164 | if (tagName === 'video' || tagName === 'audio') { 165 | //HTMLMediaElement.prototype 166 | return handleElement(HTMLMediaElement.prototype, element); 167 | } 168 | if (tagName === 'script') { 169 | //HTMLScriptElement.prototype 170 | return handleElement(HTMLScriptElement.prototype, element); 171 | } 172 | }; -------------------------------------------------------------------------------- /README.md: -------------------------------------------------------------------------------- 1 | # PlayableTools 2 | 3 |

4 | Playable Previewer Screenshot 5 |

6 | 7 |

8 | Base64 Converter Screenshot 9 |

10 | 11 |

12 | PWA Badge 13 |

14 | 15 | PlayableTools is a comprehensive web-based toolkit for preparing, publishing, and managing HTML5 playable ads across multiple advertising platforms. Built with modern web technologies and packaged as a Progressive Web App for seamless local development and offline use. 16 | 17 | ## 🚀 Main Features 18 | 19 | ### 📤 **Multi-Platform Publishing** 20 | - Publish HTML5 playable ads to 10+ major ad networks 21 | - Automated platform-specific transformations and optimizations 22 | - Support for both single HTML and ZIP package outputs 23 | - Real-time progress tracking and detailed logging 24 | 25 | ### 🔄 **Base64 Converter** 26 | - Convert any file type to Base64 encoding 27 | - Drag-and-drop interface for easy file processing 28 | - Optimized for embedding assets in HTML5 playables 29 | 30 | ### 🎬 **Video to Sprite Converter** 31 | - Transform MP4 videos into PNG sprite sequences 32 | - Perfect for game development and animations 33 | - Configurable frame rates and output formats 34 | 35 | ### 📊 **Folder Size Visualizer** 36 | - Interactive folder analysis and visualization 37 | - Multiple view types: sunburst charts, treemaps, and tree views 38 | - Built with D3.js for smooth, interactive experiences 39 | 40 | ### 🗜️ **HTML Compression (Imba Packer)** 41 | - Advanced HTML compression using Pako library 42 | - Maximizes file size reduction while preserving functionality 43 | - Ideal for size-constrained advertising requirements 44 | 45 | ### 🖼️ **Asset Compression** 46 | - PNG optimization and compression tools 47 | - Integrates with PngChpocker for high-quality compression 48 | - Reduces file sizes without quality loss 49 | 50 | ### 🎨 **Sprite Sheet Maker** 51 | - Create optimized sprite sheets from individual images 52 | - Supports multiple output formats and configurations 53 | - Perfect for game development and animation workflows 54 | 55 | ### 📖 **CTA SDK Integration** 56 | - Complete documentation for Call-to-Action SDK 57 | - Integration guides and best practices 58 | - Platform-specific implementation examples 59 | 60 | ### ✅ **Ad Network Validation** 61 | - Technical requirement specifications for different platforms 62 | - Automated validation tools for Facebook and other networks 63 | - Stay compliant with latest platform requirements 64 | 65 | ### 📱 **Playable Preview** 66 | - Multi-device testing and preview capabilities 67 | - ZIP file support with virtual URLs for accurate testing 68 | - GitHub integration for easy portfolio management 69 | 70 | ### 📂 **Portfolio Management** 71 | - GitHub repository integration 72 | - Organize and showcase your playable ad portfolio 73 | - Quick preview and publishing from your existing projects 74 | 75 | ## 🛠️ Tech Stack 76 | 77 | - **Build Tool**: Vite 7.x with TypeScript 5.8 78 | - **Frontend Framework**: Lit 3.x web components 79 | - **CSS Framework**: Pico CSS 2.x with custom theme 80 | - **PWA**: Vite PWA plugin with service worker 81 | - **Dependencies**: JSZip, Pako, Marked, D3.js 82 | - **Routing**: Custom hash-based router with metadata support 83 | 84 | ## 🌐 Supported Ad Platforms 85 | 86 | - **Facebook** (Single HTML + ZIP variants) 87 | - **Google** (ZIP with multi-size variants) 88 | - **Moloco** 89 | - **Mintegral** 90 | - **IronSource** 91 | - **AdColony** 92 | - **Unity Ads** 93 | - **AppLovin** 94 | - **Vungle** 95 | - **TikTok** 96 | 97 | *Note: Each platform has specific requirements and optimizations built-in. Check the platform-specific options in the publish page for details.* 98 | 99 | ## 🚀 Quick Start 100 | 101 | ### 1. Install Dependencies 102 | 103 | ```powershell 104 | npm install 105 | ``` 106 | 107 | ### 2. Start Development Server 108 | 109 | ```powershell 110 | npm run dev 111 | ``` 112 | 113 | ### 3. Open Your Browser 114 | 115 | Navigate to `http://localhost:5173/` (or the URL shown by Vite). The app supports PWA installation and will show update prompts when new versions are available. 116 | 117 | ### 4. Build for Production 118 | 119 | ```powershell 120 | npm run build 121 | ``` 122 | 123 | ## 📁 Project Structure 124 | 125 | ``` 126 | src/ 127 | ├── fw/ # Custom lightweight framework 128 | ├── Layout/ # Layout components (main layout, navigation) 129 | ├── pages/ # Page components 130 | │ ├── publish/ # Publishing tools 131 | │ ├── preview/ # Preview and testing tools 132 | │ ├── portfolio/ # GitHub portfolio integration 133 | │ ├── folder-size/ # Folder analysis visualizations 134 | │ ├── spritesheet-maker/ # Sprite sheet creation 135 | │ └── video2sprite/ # Video to sprite conversion 136 | ├── services/ # Business logic and services 137 | ├── utils/ # Utility functions 138 | └── assets/ # Static assets and configurations 139 | ``` 140 | 141 | ## 🧪 Test Files 142 | 143 | Test your publishing workflow with included examples: 144 | 145 | - `public/test-playable.html` — Complete playable example 146 | - `public/test-simple-playable.html` — Minimal working example 147 | - `test-facebook-validator.html` — Facebook-specific validation helper 148 | 149 | ## 🔄 ZIP Preview System 150 | 151 | The preview system supports ZIP packages with complete asset extraction: 152 | 153 | - Upload ZIP files containing HTML entry points and relative assets 154 | - Assets are served via dedicated service worker at virtual URLs 155 | - All relative path references work exactly as in the exported ZIP 156 | - Perfect for testing multi-file playables with complex asset structures 157 | 158 | ## 📚 Developer Resources 159 | 160 | - **Framework Documentation**: See `AGENTS.md` for detailed architecture and patterns 161 | - **Service Worker**: Version checking and caching handled via `src/sw-version-handler.js` 162 | - **Platform Adapters**: Add new platforms by following existing patterns in `src/services/PlayablePublishService.ts` 163 | 164 | ## 🤝 Contributing 165 | 166 | Contributions are welcome! Please: 167 | 168 | 1. Open an issue describing the proposed feature or bug fix 169 | 2. Include test cases or sample assets where applicable 170 | 3. Follow the existing code patterns and TypeScript conventions 171 | 172 | ## 📄 License 173 | 174 | MIT License - see LICENSE file for details 175 | 176 |

177 | PWA Badge 178 |

179 | -------------------------------------------------------------------------------- /src/Layout/main-layout.ts: -------------------------------------------------------------------------------- 1 | import type { HTMLTemplateResult } from "lit"; 2 | import { property } from "lit/decorators.js"; 3 | import { customElement, html, LayoutComponentBase } from "fw"; 4 | import "./site-logo"; 5 | 6 | type BeforeInstallPromptEvent = Event & { 7 | prompt: () => Promise; 8 | userChoice: Promise<{ outcome: 'accepted' | 'dismissed' }>; 9 | }; 10 | 11 | @customElement("main-layout") 12 | export class MainLayout extends LayoutComponentBase { 13 | @property({ attribute: false, type: Object }) 14 | body?: HTMLTemplateResult; 15 | 16 | @property({ type: Boolean }) 17 | sidebarOpen = false; 18 | 19 | private deferredPrompt: BeforeInstallPromptEvent | null = null; 20 | 21 | private toggleSidebar() { 22 | this.sidebarOpen = !this.sidebarOpen; 23 | } 24 | 25 | private closeSidebar() { 26 | this.sidebarOpen = false; 27 | } 28 | 29 | connectedCallback() { 30 | super.connectedCallback(); 31 | window.addEventListener('beforeinstallprompt', (event) => { 32 | const beforeInstallEvent = event as BeforeInstallPromptEvent; // Explicitly cast the event 33 | // DO NOT prevent default - let browser show its banner too 34 | // beforeInstallEvent.preventDefault(); // Prevent the default browser prompt 35 | this.deferredPrompt = beforeInstallEvent; // Save the event for later use 36 | console.log('📱 beforeinstallprompt event captured, ready for manual prompt'); 37 | }); 38 | } 39 | 40 | private suggestPWAInstall() { 41 | if (this.deferredPrompt) { 42 | console.log('📱 Showing PWA install prompt...'); 43 | this.deferredPrompt.prompt(); // Show the install prompt 44 | this.deferredPrompt.userChoice.then((choiceResult: { outcome: 'accepted' | 'dismissed' }) => { 45 | if (choiceResult.outcome === 'accepted') { 46 | console.log('✅ User accepted the install prompt'); 47 | } else { 48 | console.log('❌ User dismissed the install prompt'); 49 | } 50 | this.deferredPrompt = null; // Reset the prompt 51 | }); 52 | } else { 53 | console.warn('⚠️ Install prompt not available - PWA may not meet installation criteria or user already has app installed'); 54 | alert('The install prompt is not available. Please use the browser menu to install the app, or ensure this is served over HTTPS.'); 55 | } 56 | } 57 | 58 | render() { 59 | return html` 60 |
61 | 62 |
63 | 64 | 67 |
68 | 69 | 70 | 109 | 110 | 111 | ${this.sidebarOpen ? html`
` : ''} 112 | 113 |
114 | ${this.body} 115 |
116 |
117 | `; 118 | } 119 | } -------------------------------------------------------------------------------- /public/mraid.js: -------------------------------------------------------------------------------- 1 | /* Simple MRAID shim for preview/testing purposes 2 | - Implements minimal MRAID API used by playables in this project 3 | - Logs all calls to console with [mraid] prefix 4 | - Emits `ready` after a short delay (initial state = 'loading') 5 | - Supports addEventListener/removeEventListener, getState, isViewable, open 6 | - Fires viewableChange/exposureChange on document visibility changes 7 | - Exposes `__mraidSimulate` helpers on window for manual event simulation 8 | */ 9 | (function () { 10 | if (window.mraid) { 11 | console.warn('[mraid] mraid already defined, skipping shim'); 12 | return; 13 | } 14 | 15 | const PREFIX = '[mraid]'; 16 | const log = (...args) => console.log(PREFIX, ...args); 17 | const error = (...args) => console.error(PREFIX, ...args); 18 | 19 | let state = 'loading'; // 'loading' initially, then 'default' 20 | const listeners = Object.create(null); 21 | 22 | function addEventListener(name, cb) { 23 | if (typeof cb !== 'function') return; 24 | listeners[name] = listeners[name] || []; 25 | listeners[name].push(cb); 26 | log('addEventListener', name, cb.name || ''); 27 | // If already past loading and they asked for ready, call immediately 28 | if (name === 'ready' && state !== 'loading') { 29 | setTimeout(() => { 30 | try { cb(); } catch (e) { error('listener error', e); } 31 | }, 0); 32 | } 33 | } 34 | 35 | function removeEventListener(name, cb) { 36 | if (!listeners[name]) return; 37 | if (!cb) { 38 | listeners[name] = []; 39 | return; 40 | } 41 | listeners[name] = listeners[name].filter(f => f !== cb); 42 | log('removeEventListener', name, cb.name || ''); 43 | } 44 | 45 | function _fire(name, ...args) { 46 | log('fireEvent', name, ...args); 47 | const arr = listeners[name] || []; 48 | arr.slice().forEach(fn => { 49 | try { fn(...args); } catch (e) { error('listener error', e); } 50 | }); 51 | } 52 | 53 | const mraid = { 54 | getState() { 55 | log('getState ->', state); 56 | return state; 57 | }, 58 | addEventListener, 59 | removeEventListener, 60 | isViewable() { 61 | log('isViewable ->', true); 62 | return true; 63 | }, 64 | open(url) { 65 | log('open called with', url); 66 | try { 67 | // In preview we should not open external tabs. Instead show an alert so user 68 | // sees that the CTA was triggered and what URL was requested. 69 | if (typeof url === 'string') { 70 | alert('[mraid] CTA clicked. URL: ' + url); 71 | } else { 72 | alert('[mraid] CTA clicked.'); 73 | } 74 | } catch (e) { 75 | error('open handler failed', e); 76 | } 77 | _fire('open', url); 78 | }, 79 | // small helper for internal/manual use 80 | _internal: { 81 | setState(s) { state = s; log('internal setState', s); }, 82 | } 83 | }; 84 | 85 | // attach to window 86 | window.mraid = mraid; 87 | 88 | // After a short delay, transition from 'loading' to 'default' and fire ready 89 | setTimeout(() => { 90 | try { 91 | state = 'default'; 92 | log('state ->', state); 93 | // Provide safe stubs for common CTA hooks so mraid listeners (from CTAs) 94 | // that call document.CTA.startGame()/mute()/unmute() won't throw in preview. 95 | try { 96 | if (typeof document !== 'undefined') { 97 | if (!document.CTA) document.CTA = {}; 98 | if (typeof document.CTA.startGame !== 'function') { 99 | document.CTA.startGame = function () { log('document.CTA.startGame() stubbed'); }; 100 | } 101 | if (typeof document.CTA.mute !== 'function') { 102 | document.CTA.mute = function () { log('document.CTA.mute() stubbed'); }; 103 | } 104 | if (typeof document.CTA.unmute !== 'function') { 105 | document.CTA.unmute = function () { log('document.CTA.unmute() stubbed'); }; 106 | } 107 | } 108 | } catch (e) { 109 | error('failed to create document.CTA stubs', e); 110 | } 111 | 112 | _fire('ready', {}); 113 | // Fire initial viewableChange after ready (ad is initially viewable in preview) 114 | _fire('viewableChange', true); 115 | } catch (e) { 116 | error('ready transition error', e); 117 | } 118 | }, 50); 119 | 120 | // Visibility/exposure handling 121 | document.addEventListener('visibilitychange', () => { 122 | try { 123 | const isViewable = document.visibilityState === 'visible'; 124 | log('visibilitychange ->', document.visibilityState); 125 | _fire('viewableChange', isViewable); 126 | // exposureChange signature in some CTAs: (exposedPercentage, coveredRectangles, boundingRect) 127 | const exposedPercentage = isViewable ? 100 : 0; 128 | _fire('exposureChange', exposedPercentage, [], { x: 0, y: 0, width: 0, height: 0 }); 129 | } catch (e) { error('visibility handler error', e); } 130 | }); 131 | 132 | // Orientation / resize proxy 133 | window.addEventListener('resize', () => { 134 | try { log('window resize/orientationchange'); _fire('orientationchange'); } catch (e) { error(e); } 135 | }); 136 | 137 | // Expose dev helpers to simulate events from console 138 | window.__mraidSimulate = { 139 | viewableChange(v) { mraid.addEventListener && _fire('viewableChange', !!v); }, 140 | exposureChange(p) { _fire('exposureChange', p || 0, [], { x: 0, y: 0, width: 0, height: 0 }); }, 141 | audioVolumeChange(v) { _fire('audioVolumeChange', v); }, 142 | orientationchange() { _fire('orientationchange'); }, 143 | ready() { _fire('ready'); } 144 | }; 145 | 146 | // Listen for generic playable screen lock events and translate them to mraid viewableChange 147 | try { 148 | window.addEventListener('playable-screen-lock', (e) => { 149 | try { 150 | const ev = e; // expect CustomEvent with detail { locked } 151 | const locked = !!(ev && ev.detail && ev.detail.locked); 152 | const isViewable = !locked; 153 | log('received playable-screen-lock ->', locked, 'translating to viewableChange', isViewable); 154 | _fire('viewableChange', isViewable); 155 | } catch (err) { error('playable-screen-lock handler error', err); } 156 | }); 157 | } catch (e) { error('failed to register playable-screen-lock listener', e); } 158 | 159 | // Listen for playable audio mute events and translate them to mraid audioVolumeChange 160 | try { 161 | window.addEventListener('playable-audio-mute', (e) => { 162 | try { 163 | const ev = e; // expect CustomEvent with detail { muted } 164 | const muted = !!(ev && ev.detail && ev.detail.muted); 165 | const volume = muted ? 0 : 100; 166 | log('received playable-audio-mute ->', muted, 'translating to audioVolumeChange', volume); 167 | _fire('audioVolumeChange', volume); 168 | } catch (err) { error('playable-audio-mute handler error', err); } 169 | }); 170 | } catch (e) { error('failed to register playable-audio-mute listener', e); } 171 | 172 | log('shim installed (debug)'); 173 | 174 | })(); 175 | -------------------------------------------------------------------------------- /src/pages/base64-converter.ts: -------------------------------------------------------------------------------- 1 | import { ComponentBase, customElement, html, route, inject } from "fw"; 2 | import { Base64ConverterService } from "../services/Base64ConverterService"; 3 | import type { Base64FileModel } from "../services/Base64ConverterService"; 4 | 5 | @customElement("base64-page") 6 | @route("/base64", { 7 | title: "Base64 Converter", 8 | description: "A simple tool to convert text to Base64 and vice versa.", 9 | }) 10 | export class HomePage extends ComponentBase { 11 | _downloadDataUrl(e: Event, dataUrl: string, name: string) { 12 | e.preventDefault(); 13 | const win = window.open(); 14 | if (win) { 15 | win.document.write("
" + this._escapeHtml(dataUrl) + "
"); 16 | win.document.title = name + " (Base64)"; 17 | } 18 | } 19 | 20 | _escapeHtml(str: string) { 21 | return str.replace(/[&<>"']/g, function (c) { 22 | return ( 23 | { 24 | "&": "&", 25 | "<": "<", 26 | ">": ">", 27 | '"': """, 28 | "'": "'", 29 | } as any 30 | )[c]; 31 | }); 32 | } 33 | @inject(Base64ConverterService) base64Service!: Base64ConverterService; 34 | 35 | dragActive = false; 36 | files: File[] = []; 37 | progress = 0; 38 | results: Base64FileModel[] = []; 39 | processing = false; 40 | copiedIndex: number | null = null; 41 | 42 | _onDragOver = (e: DragEvent) => { 43 | e.preventDefault(); 44 | this.dragActive = true; 45 | this.requestUpdate(); 46 | }; 47 | 48 | _onDragLeave = (e: DragEvent) => { 49 | e.preventDefault(); 50 | this.dragActive = false; 51 | this.requestUpdate(); 52 | }; 53 | 54 | _onDrop = (e: DragEvent) => { 55 | e.preventDefault(); 56 | this.dragActive = false; 57 | if (e.dataTransfer?.files?.length) { 58 | this._handleFiles(e.dataTransfer.files); 59 | } 60 | }; 61 | 62 | _onFileChange = (e: Event) => { 63 | const input = e.target as HTMLInputElement; 64 | if (input.files?.length) { 65 | this._handleFiles(input.files); 66 | } 67 | }; 68 | 69 | async _handleFiles(fileList: FileList) { 70 | this.files = Array.from(fileList); 71 | this.progress = 0; 72 | this.processing = true; 73 | this.results = []; 74 | this.requestUpdate(); 75 | this.results = await this.base64Service.convertFilesToBase64( 76 | this.files, 77 | (p) => { 78 | this.progress = p; 79 | this.requestUpdate(); 80 | } 81 | ); 82 | this.processing = false; 83 | this.requestUpdate(); 84 | } 85 | 86 | async _copyToClipboard(data: string, idx: number) { 87 | try { 88 | await navigator.clipboard.writeText(data); 89 | this.copiedIndex = idx; 90 | this.requestUpdate(); 91 | setTimeout(() => { 92 | this.copiedIndex = null; 93 | this.requestUpdate(); 94 | }, 3000); 95 | } catch {} 96 | } 97 | 98 | _selectDataUrl(e: Event) { 99 | const el = e.currentTarget as HTMLElement; 100 | if (!el) return; 101 | const range = document.createRange(); 102 | range.selectNodeContents(el); 103 | const sel = window.getSelection(); 104 | sel?.removeAllRanges(); 105 | sel?.addRange(range); 106 | } 107 | 108 | render() { 109 | return html` 110 |
111 |
112 |

Convert assets to base64 format

113 |

114 | This app provides a simple tool to convert media files to Base64 to use 115 | in playable ads. 116 |

117 |
118 | 119 |
129 |

Drop your files here or

130 | 134 |
135 | 136 | ${this.processing 137 | ? html` 138 |
139 |
140 |
144 |
145 |
146 | Processing... ${this.progress}% 147 |
148 |
149 | ` 150 | : null} 151 | ${this.results.length > 0 152 | ? html` 153 |
154 | ${this.results.map( 155 | (r, idx) => html` 156 |
157 |
158 | ${r.name} 159 |
160 | ${(r.originalSize / 1024).toFixed(2)} KB → 161 | ${(r.base64Size / 1024).toFixed(2)} KB 162 |
163 |
164 | ${r.dataUrl.length > 2048 165 | ? html`content too long to display 167 | 170 | this._downloadDataUrl(e, r.dataUrl, r.name)} 171 | class="text-primary hover:underline ml-1" 172 | >Open in new tab` 175 | : html` this._selectDataUrl(e)} 179 | @focus=${(e: Event) => this._selectDataUrl(e)} 180 | >${r.dataUrl}`} 182 | 192 |
193 | ` 194 | )} 195 |
196 | ` 197 | : null} 198 |
199 | `; 200 | } 201 | } 202 | -------------------------------------------------------------------------------- /VERSION_CHECKING.md: -------------------------------------------------------------------------------- 1 | # Version Checking & Auto-Update System 2 | 3 | This implementation provides automatic version checking and app reload functionality with cache clearing, **fully compatible with PWA (Progressive Web App) installed mode**. 4 | 5 | ## Features 6 | 7 | - **Automatic Version Checking**: Checks for new versions on app start and every hour 8 | - **Full-Width Update Banner**: Shows a prominent top banner when updates are available 9 | - **Cache Clearing**: Clears all caches (Browser Cache API, Service Worker, localStorage, sessionStorage) before reload 10 | - **PWA Compatibility**: Full support for PWA installed mode with proper service worker handling 11 | - **Simple UX**: No manual buttons - automatic checking with clear reload action 12 | 13 | ## How It Works 14 | 15 | 1. **App Start**: When the app loads, `VersionService` fetches current version from `version.json` 16 | 2. **Hourly Checks**: Every hour, the service checks for updates by fetching the latest `version.json` 17 | 3. **Version Comparison**: Compares versions using content hash, build timestamp, and version string 18 | 4. **Update Banner**: If an update is detected, shows a full-width blue banner at the top 19 | 5. **One-Click Reload**: User clicks "Reload App" button to get the latest version with cache clearing 20 | 21 | ## Cache Prevention Measures 22 | 23 | ### Client-Side Cache Busting: 24 | - **Timestamp Parameters**: Adds `?t=timestamp&r=random` to version.json requests 25 | - **Aggressive Headers**: Multiple no-cache headers in fetch requests 26 | - **Service Worker Bypass**: Service worker intercepts and bypasses cache for version.json 27 | - **Browser Cache API**: Excluded from all cache storage mechanisms 28 | 29 | ### Server-Side Configuration: 30 | For optimal cache prevention, configure your server: 31 | 32 | **Apache (.htaccess):** 33 | ```apache 34 | 35 | Header set Cache-Control "no-cache, no-store, must-revalidate" 36 | Header set Pragma "no-cache" 37 | Header set Expires "0" 38 | 39 | ``` 40 | 41 | **Nginx:** 42 | ```nginx 43 | location ~* /version\.json$ { 44 | add_header Cache-Control "no-cache, no-store, must-revalidate" always; 45 | add_header Pragma "no-cache" always; 46 | add_header Expires "0" always; 47 | } 48 | ``` 49 | 50 | ## Common Issues & Solutions 51 | 52 | ### 304 Not Modified Responses: 53 | **Problem**: Server returns 304 status causing fetch to fail 54 | **Solution**: 55 | - Handle 304 responses as successful (they indicate content hasn't changed) 56 | - Use multiple fetch strategies (fetch API, XMLHttpRequest, fallback) 57 | - Enhanced cache-busting with multiple parameters 58 | 59 | ### Cache Prevention Measures: 60 | - **Multiple URL Parameters**: `?v=timestamp&cb=random&nc=performance` 61 | - **Multiple Fetch Methods**: fetch API → XMLHttpRequest → fallback 62 | - **Status Code Handling**: Treats both 200 and 304 as success 63 | - **Graceful JSON Parsing**: Handles cases where 304 returns no body 64 | 65 | ### Debugging Commands: 66 | ```javascript 67 | // Test version fetching in console 68 | await window.versionService?.testCacheBusting(); 69 | 70 | // Manual version check 71 | await window.versionService?.checkForUpdates(); 72 | ``` 73 | 74 | ## Components 75 | 76 | ### 1. VersionService (`src/services/VersionService.ts`) 77 | - Core service that handles version checking logic 78 | - Fetches version information from `/version.json` 79 | - Compares versions by hash, build time, and version string 80 | - Manages update notifications and cache clearing 81 | 82 | ### 2. UpdateNotification (`src/fw/update-notification.ts`) 83 | - User-friendly notification component 84 | - Appears when updates are detected 85 | - Provides "Reload" and "Later" options 86 | - Styled as a floating notification in the top-right corner 87 | 88 | ### 3. VersionChecker (`src/fw/version-checker.ts`) 89 | - Manual version check component in the sidebar 90 | - Shows current version and last check time 91 | - Provides manual check and force reload buttons 92 | - Useful for development and debugging 93 | 94 | ### 4. Vite Version Plugin (`vite-plugin-version.ts`) 95 | - Generates `version.json` file during build 96 | - Creates unique hash based on bundle content 97 | - Supports both development and production modes 98 | - Updates version file on changes in development 99 | 100 | ## How It Works 101 | 102 | 1. **Initialization**: When the app starts, `VersionService` is initialized and fetches the current version info 103 | 2. **Periodic Checks**: Every 5 minutes, the service checks for updates by fetching the latest `version.json` 104 | 3. **Version Comparison**: Compares the current and latest versions using: 105 | - Content hash (primary) 106 | - Build timestamp (secondary) 107 | - Version string (fallback) 108 | 4. **Notification**: If an update is detected, shows the update notification 109 | 5. **Reload Process**: When user chooses to reload: 110 | - Clears all browser caches 111 | - Unregisters service workers 112 | - Clears localStorage and sessionStorage 113 | - Forces a hard reload 114 | 115 | ## Usage 116 | 117 | ### For Users 118 | - When an update notification appears, click "Reload" to get the latest version 119 | - Use the manual check button in the sidebar to check for updates immediately 120 | - The "Later" option dismisses the notification but keeps checking in the background 121 | 122 | ### For Developers 123 | - The version checker in the sidebar shows the current version and allows manual testing 124 | - Use the force reload button (⟳) to test cache clearing functionality 125 | - Version information is automatically generated during build process 126 | 127 | ## Configuration 128 | 129 | ### VersionService Options 130 | ```typescript 131 | new VersionService( 132 | checkIntervalMs: number = 5 * 60 * 1000, // Check interval (default: 5 minutes) 133 | versionEndpoint: string = './version.json' // Version endpoint URL 134 | ) 135 | ``` 136 | 137 | ### Version File Format 138 | ```json 139 | { 140 | "version": "1.0.0", 141 | "buildTime": "2025-07-22T12:00:00.000Z", 142 | "hash": "abc12345" 143 | } 144 | ``` 145 | 146 | ## Files Modified/Created 147 | 148 | ### New Files: 149 | - `src/services/VersionService.ts` - Version checking service 150 | - `src/fw/update-notification.ts` - Update notification component 151 | - `src/fw/version-checker.ts` - Manual version checker component 152 | - `vite-plugin-version.ts` - Vite plugin for version file generation 153 | - `public/version.json` - Version information file 154 | 155 | ### Modified Files: 156 | - `src/app-root.ts` - Integrated version service and notification 157 | - `src/Layout/main-layout.ts` - Added version checker to sidebar 158 | - `src/fw/index.ts` - Exported new components 159 | - `vite.config.ts` - Added version plugin with PWA cache exclusions 160 | - `package.json` - Version used by the plugin 161 | 162 | ## PWA Installation & Testing 163 | 164 | ### Testing PWA Mode: 165 | 1. Build and serve the app: `npm run build && npm run preview` 166 | 2. Open Chrome DevTools → Application → Manifest 167 | 3. Click "Install" to install the PWA 168 | 4. Test version checking in the installed PWA 169 | 170 | ### PWA Cache Behavior: 171 | - `version.json` is excluded from service worker caching 172 | - Version checks use NetworkOnly strategy 173 | - Manual cache clearing affects both browser and service worker caches 174 | - Service worker updates are handled properly during app updates 175 | 176 | ## Troubleshooting PWA Issues 177 | 178 | ### Common PWA Problems: 179 | 1. **Cached Version File**: Ensure `version.json` is not cached by checking Network tab 180 | 2. **Service Worker Updates**: Check Application → Service Workers for update status 181 | 3. **Cache Persistence**: Some caches may persist - use force reload (⟳) button 182 | 4. **URL Resolution**: PWA apps may have different base URLs - handled automatically 183 | 184 | ### Debug Information: 185 | - Version checker shows "(PWA)" indicator when running in installed mode 186 | - Console logs indicate PWA detection and service worker communication 187 | - Network tab shows version.json requests with proper cache headers 188 | 189 | ## Testing 190 | 191 | 1. Start the development server: `npm run dev` 192 | 2. Check the sidebar for the version checker component 193 | 3. Use the manual check button to test functionality 194 | 4. Modify the `public/version.json` file to simulate a new version 195 | 5. Test the update notification and reload functionality 196 | 197 | ## Production Deployment 198 | 199 | 1. Build the app: `npm run build` 200 | 2. The version plugin automatically generates `version.json` with current build information 201 | 3. Deploy the built files to your server 202 | 4. The app will automatically check for updates when deployed 203 | 204 | ## Browser Compatibility 205 | 206 | - Supports modern browsers with Cache API and Service Worker support 207 | - Falls back gracefully for older browsers 208 | - Uses standard browser APIs for maximum compatibility 209 | -------------------------------------------------------------------------------- /src/pages/folder-size/folder-treemap-view.ts: -------------------------------------------------------------------------------- 1 | import { ComponentBase, customElement, html, property } from "fw"; 2 | import * as d3 from "d3"; 3 | 4 | 5 | interface FileNode { 6 | name: string; 7 | size: number; 8 | isDirectory: boolean; 9 | children?: FileNode[]; 10 | handle?: FileSystemHandle; 11 | } 12 | 13 | @customElement("folder-treemap-view") 14 | export class FolderTreemapView extends ComponentBase { 15 | @property({ type: Array }) 16 | fileTree: FileNode[] = []; 17 | @property({ type: Number }) 18 | height: number = 400; 19 | 20 | private _resizeHandler?: () => void; 21 | 22 | private _formatSize(bytes: number): string { 23 | if (bytes === 0) return '0 B'; 24 | const k = 1024; 25 | const sizes = ['B', 'KB', 'MB', 'GB']; 26 | const i = Math.floor(Math.log(bytes) / Math.log(k)); 27 | return parseFloat((bytes / Math.pow(k, i)).toFixed(2)) + ' ' + sizes[i]; 28 | } 29 | 30 | private _convertToHierarchy(nodes: FileNode[]): any { 31 | const root: any = { name: "root", children: [] }; 32 | 33 | const processNode = (node: FileNode): any => { 34 | const d3Node: any = { 35 | name: node.name, 36 | value: node.size, 37 | isDirectory: node.isDirectory 38 | }; 39 | 40 | if (node.children && node.children.length > 0) { 41 | d3Node.children = node.children.map(processNode); 42 | } 43 | 44 | return d3Node; 45 | }; 46 | 47 | root.children = nodes.map(processNode); 48 | return root; 49 | } 50 | 51 | private _createTreemap() { 52 | if (this.fileTree.length === 0) return; 53 | 54 | const svg = d3.select("#treemap-svg"); 55 | svg.selectAll("*").remove(); // Clear previous content 56 | 57 | // Determine current width from the SVG bounding box (responsive) 58 | const svgEl = svg.node() as SVGSVGElement | null; 59 | const bbox = svgEl?.getBoundingClientRect(); 60 | const width = Math.max(320, Math.floor((bbox?.width ?? 800))); 61 | const height = this.height || 400; // use provided height for modal fit 62 | 63 | // Make SVG responsive via viewBox 64 | svg.attr("viewBox", `0 0 ${width} ${height}`); 65 | 66 | // Convert data to hierarchy 67 | const hierarchyData = this._convertToHierarchy(this.fileTree); 68 | const root = d3.hierarchy(hierarchyData) 69 | .sum((d: any) => d.value) 70 | .sort((a, b) => b.value! - a.value!); 71 | 72 | // Create treemap layout 73 | const treemap = d3.treemap() 74 | .size([width, height]) 75 | .paddingInner(1) 76 | .paddingOuter(2) 77 | .round(true); 78 | 79 | treemap(root); 80 | 81 | // Draw directory boundaries (internal nodes, excluding the root) 82 | const directories = root.descendants().filter((d: any) => d.children && d.depth > 0); 83 | 84 | const dirGroup = svg 85 | .append("g") 86 | .attr("class", "treemap-directories") 87 | .style("mix-blend-mode", "multiply"); 88 | 89 | const dir = dirGroup 90 | .selectAll("g.dir") 91 | .data(directories) 92 | .enter() 93 | .append("g") 94 | .attr("class", (d: any) => `dir depth-${d.depth}`) 95 | .attr("transform", (d: any) => `translate(${d.x0},${d.y0})`); 96 | 97 | dir.append("rect") 98 | .attr("width", (d: any) => Math.max(0, d.x1 - d.x0)) 99 | .attr("height", (d: any) => Math.max(0, d.y1 - d.y0)) 100 | .attr("fill", "none") 101 | .attr("stroke", "#4a90e2") 102 | .attr("stroke-width", (d: any) => d.depth === 1 ? 2 : 1) 103 | .style("pointer-events", "none"); // let file cells capture events 104 | 105 | // Native tooltip for directories 106 | dir.append("title") 107 | .text((d: any) => `Folder: ${d.data.name}\nSize: ${this._formatSize(d.value ?? 0)}`); 108 | 109 | // Directory labels (name + size) if there's room 110 | dir.append("text") 111 | .attr("x", 4) 112 | .attr("y", 14) 113 | .attr("font-size", "11px") 114 | .attr("fill", "#2b6cb0") 115 | .attr("font-weight", "600") 116 | .text((d: any) => { 117 | const w = d.x1 - d.x0; 118 | const h = d.y1 - d.y0; 119 | if (w < 80 || h < 20) return ""; 120 | const name = d.data.name; 121 | const size = this._formatSize(d.value ?? 0); 122 | const maxLen = Math.floor(w / 7); 123 | const label = `${name} (${size})`; 124 | return label.length > maxLen ? label.slice(0, Math.max(0, maxLen - 1)) + "…" : label; 125 | }); 126 | 127 | // Create groups for file nodes (leaves) - HIDDEN 128 | // const files = root.leaves(); 129 | // const cell = svg 130 | // .append("g") 131 | // .attr("class", "treemap-files") 132 | // .selectAll("g.cell") 133 | // .data(files) 134 | // .enter() 135 | // .append("g") 136 | // .attr("class", "cell") 137 | // .attr("transform", (d: any) => `translate(${d.x0},${d.y0})`); 138 | 139 | // // Add rectangles for files - HIDDEN 140 | // cell.append("rect") 141 | // .attr("width", (d: any) => Math.max(0, d.x1 - d.x0)) 142 | // .attr("height", (d: any) => Math.max(0, d.y1 - d.y0)) 143 | // .attr("fill", "#7ed321") 144 | // .attr("stroke", "#fff") 145 | // .attr("stroke-width", 1) 146 | // .style("cursor", "pointer") 147 | // .on("mouseover", (event: MouseEvent, d: any) => { 148 | // d3.select(event.target as SVGRectElement).attr("stroke", "#000").attr("stroke-width", 2); 149 | 150 | // // Show tooltip 151 | // const tooltip = d3.select("body") 152 | // .append("div") 153 | // .attr("class", "treemap-tooltip") 154 | // .style("position", "absolute") 155 | // .style("background", "rgba(0, 0, 0, 0.8)") 156 | // .style("color", "white") 157 | // .style("padding", "8px") 158 | // .style("border-radius", "4px") 159 | // .style("font-size", "12px") 160 | // .style("pointer-events", "none") 161 | // .style("z-index", "1000") 162 | // .html(` 163 | // ${d.data.name}
164 | // Size: ${this._formatSize(d.data.value)}
165 | // Type: File 166 | // `); 167 | 168 | // tooltip 169 | // .style("left", (event.pageX + 10) + "px") 170 | // .style("top", (event.pageY - 10) + "px"); 171 | // }) 172 | // .on("mouseout", (event: MouseEvent) => { 173 | // d3.select(event.target as SVGRectElement).attr("stroke", "#fff").attr("stroke-width", 1); 174 | // d3.selectAll(".treemap-tooltip").remove(); 175 | // }); 176 | 177 | // // Add text labels for larger rectangles - HIDDEN 178 | // cell.append("text") 179 | // .selectAll("tspan") 180 | // .data((d: any) => { 181 | // const width = d.x1 - d.x0; 182 | // const height = d.y1 - d.y0; 183 | // if (width < 60 || height < 20) return []; // Don't show text if too small 184 | 185 | // const name = d.data.name; 186 | // const maxLength = Math.floor(width / 6); // Approximate character width 187 | // return name.length > maxLength ? [name.substring(0, maxLength) + "..."] : [name]; 188 | // }) 189 | // .enter() 190 | // .append("tspan") 191 | // .attr("x", 4) 192 | // .attr("y", (_: any, i: number) => 13 + i * 12) 193 | // .attr("font-size", "11px") 194 | // .attr("fill", "white") 195 | // .attr("font-weight", "bold") 196 | // .text((d: any) => d); 197 | } 198 | 199 | connectedCallback(): void { 200 | super.connectedCallback(); 201 | // Re-render on resize to keep responsiveness 202 | this._resizeHandler = () => this._createTreemap(); 203 | window.addEventListener("resize", this._resizeHandler); 204 | } 205 | 206 | disconnectedCallback(): void { 207 | super.disconnectedCallback(); 208 | if (this._resizeHandler) { 209 | window.removeEventListener("resize", this._resizeHandler); 210 | this._resizeHandler = undefined; 211 | } 212 | } 213 | 214 | updated(changedProperties: Map) { 215 | super.updated(changedProperties); 216 | if (changedProperties.has('fileTree') && this.fileTree.length > 0) { 217 | // Wait for the DOM to update before creating the treemap 218 | setTimeout(() => this._createTreemap(), 100); 219 | } 220 | } 221 | 222 | render() { 223 | if (this.fileTree.length === 0) { 224 | return html` 225 |
226 | data_usage 227 |

No data to display

228 |
229 | `; 230 | } 231 | 232 | return html` 233 |
234 |
235 | 236 |
237 |
238 |
239 |
240 | Folders 241 |
242 |
243 |
244 | `; 245 | } 246 | } -------------------------------------------------------------------------------- /src/pages/imba-packer-page.ts: -------------------------------------------------------------------------------- 1 | import { ComponentBase, customElement, html, route, inject } from "fw"; 2 | import { ImbaPackerService } from "../services/ImbaPackerService"; 3 | 4 | /** 5 | * Imba Packer is a tool designed to optimize and compress HTML files for playable ads and similar use cases. 6 | * 7 | * Principle of operation: 8 | * - The user uploads or drops an HTML file. 9 | * - The file is processed by the ImbaPackerService, which applies various packing and minification techniques to reduce file size. 10 | * - The packed HTML is generated and made available for download, along with statistics about the compression achieved. 11 | * - The tool is experimental and aims to maximize compression while preserving the original file's functionality. 12 | */ 13 | 14 | @customElement("imba-packer-page") 15 | @route("/imba-packer", { 16 | title: "Imba Packer", 17 | description: "Optimize and compress HTML files for playable ads.", 18 | }) 19 | export class ImbaPackerPage extends ComponentBase { 20 | @inject(ImbaPackerService) imbaPackerService!: ImbaPackerService; 21 | dragActive = false; 22 | loadedFile: File | null = null; 23 | packedFileName: string | null = null; 24 | packedHtml: string | null = null; 25 | packedSize: number | null = null; 26 | compressionInfo: { diff: number; percent: number } | null = null; 27 | 28 | render() { 29 | return html` 30 |
31 |
32 |

Imba Packer (Experimental)

33 |
34 | Imba Packer optimizes and compresses HTML files for playable ads and similar use cases. 35 |
    36 |
  • Upload or drop an HTML file below.
  • 37 |
  • The file will be processed and minified to reduce its size.
  • 38 |
  • Download the packed HTML and view compression statistics.
  • 39 |
  • All original functionality is preserved as much as possible.
  • 40 |
41 | 42 | warning 43 | Experimental: results may vary depending on input file. 44 | 45 |
46 |
47 | 48 | ${!this.loadedFile 49 | ? html` 50 |
60 |

Drop your file here or

61 | 69 |
70 | ` 71 | : html` 72 |
73 |
74 | description 75 |
76 |
${this.loadedFile.name}
77 |
${(this.loadedFile.size / 1024).toFixed(2)} KB
78 |
79 |
80 | 86 |
87 | 88 | ${this.packedFileName && this.packedHtml && this.packedSize && this.compressionInfo ? html` 89 |
90 |

91 | check_circle 92 | Compression Complete 93 |

94 | 95 |
96 |
97 |
Original Size
98 |
${(this.loadedFile.size / 1024).toFixed(2)} KB
99 |
100 |
101 |
Packed Size
102 |
${(this.packedSize / 1024).toFixed(2)} KB
103 |
104 |
105 |
Size Reduction
106 |
${(this.compressionInfo.diff / 1024).toFixed(2)} KB
107 |
108 |
109 |
Compression Rate
110 |
${this.compressionInfo.percent.toFixed(1)}%
111 |
112 |
113 | 114 |
115 | 122 |
123 |
124 | ` : html` 125 |
126 |
127 |

Processing and compressing file...

128 |
129 | `} 130 | `} 131 |
132 | `; 133 | } 134 | 135 | _onDragOver(e: DragEvent) { 136 | e.preventDefault(); 137 | this.dragActive = true; 138 | this.requestUpdate(); 139 | } 140 | 141 | _onDragLeave(e: DragEvent) { 142 | e.preventDefault(); 143 | this.dragActive = false; 144 | this.requestUpdate(); 145 | } 146 | 147 | _onDrop(e: DragEvent) { 148 | e.preventDefault(); 149 | this.dragActive = false; 150 | this.requestUpdate(); 151 | const files = e.dataTransfer?.files; 152 | if (files && files.length) { 153 | this._processFile(files[0]); 154 | } 155 | } 156 | 157 | _onFileChange(e: Event) { 158 | const input = e.target as HTMLInputElement; 159 | const file = input.files?.[0]; 160 | if (file) { 161 | this._processFile(file); 162 | } 163 | } 164 | 165 | 166 | async _processFile(file: File) { 167 | if (!file.name.match(/\.html?$/i)) { 168 | alert('Please select a valid .html file.'); 169 | return; 170 | } 171 | this.loadedFile = file; 172 | this.packedFileName = null; 173 | this.packedHtml = null; 174 | this.packedSize = null; 175 | this.compressionInfo = null; 176 | this.requestUpdate(); 177 | // Call service to pack and generate output 178 | try { 179 | const { fileName, html } = await this.imbaPackerService.pack(file); 180 | this.packedFileName = fileName; 181 | this.packedHtml = html; 182 | this.packedSize = new Blob([html], { type: 'text/html' }).size; 183 | const diff = this.loadedFile.size - this.packedSize; 184 | const percent = (diff / this.loadedFile.size) * 100; 185 | this.compressionInfo = { diff, percent }; 186 | this.requestUpdate(); 187 | } catch (err) { 188 | alert('Packing failed: ' + (err instanceof Error ? err.message : err)); 189 | } 190 | const event = new CustomEvent("file-selected", { detail: file }); 191 | this.dispatchEvent(event); 192 | } 193 | _downloadPacked = () => { 194 | if (!this.packedHtml || !this.packedFileName) return; 195 | const blob = new Blob([this.packedHtml], { type: 'text/html' }); 196 | const url = URL.createObjectURL(blob); 197 | const a = document.createElement('a'); 198 | a.href = url; 199 | a.download = this.packedFileName; 200 | document.body.appendChild(a); 201 | a.click(); 202 | setTimeout(() => { 203 | document.body.removeChild(a); 204 | URL.revokeObjectURL(url); 205 | }, 100); 206 | } 207 | 208 | _resetFile() { 209 | this.loadedFile = null; 210 | this.packedFileName = null; 211 | this.packedHtml = null; 212 | this.packedSize = null; 213 | this.compressionInfo = null; 214 | this.requestUpdate(); 215 | } 216 | } 217 | --------------------------------------------------------------------------------