Spaces:
Configuration error
Configuration error
| <html lang="km"> | |
| <head> | |
| <meta charset="UTF-8"> | |
| <meta name="viewport" content="width=device-width, initial-scale=1.0"> | |
| <title>α§αααααααααΌαααΌαααΆα AI - AI Image Cloner</title> | |
| <!-- Tailwind CSS --> | |
| <script src="https://cdn.tailwindcss.com"></script> | |
| <!-- FontAwesome for Icons --> | |
| <link rel="stylesheet" href="https://cdnjs.cloudflare.com/ajax/libs/font-awesome/6.4.0/css/all.min.css"> | |
| <!-- Google Fonts (Kantumruy & Battambang for Khmer) --> | |
| <link href="https://fonts.googleapis.com/css2?family=Kantumruy+Pro:wght@300;400;600;700&family=Battambang:wght@400;700&display=swap" rel="stylesheet"> | |
| <style> | |
| body { | |
| font-family: 'Kantumruy Pro', 'Battambang', sans-serif; | |
| background-color: #0f172a; | |
| } | |
| .glass { | |
| background: rgba(30, 41, 59, 0.7); | |
| backdrop-filter: blur(12px); | |
| border: 1px solid rgba(255, 255, 255, 0.1); | |
| } | |
| .btn-pop-3d, | |
| .choice-pop-3d, | |
| .icon-pop-3d { | |
| transform: translateY(0); | |
| transition: transform 160ms ease, box-shadow 160ms ease, border-color 160ms ease, background 160ms ease; | |
| } | |
| .btn-pop-3d { | |
| box-shadow: 0 7px 0 rgba(15, 23, 42, 0.95), 0 16px 32px rgba(99, 102, 241, 0.22); | |
| } | |
| .btn-pop-3d:hover:not(:disabled) { | |
| transform: translateY(-2px); | |
| box-shadow: 0 9px 0 rgba(15, 23, 42, 0.95), 0 22px 40px rgba(99, 102, 241, 0.28); | |
| } | |
| .btn-pop-3d:active:not(:disabled) { | |
| transform: translateY(4px); | |
| box-shadow: 0 3px 0 rgba(15, 23, 42, 0.95), 0 10px 20px rgba(99, 102, 241, 0.18); | |
| } | |
| .btn-pop-3d:disabled { | |
| transform: none; | |
| box-shadow: none; | |
| } | |
| .choice-pop-3d { | |
| box-shadow: inset 0 1px 0 rgba(255, 255, 255, 0.06), 0 5px 0 rgba(15, 23, 42, 0.9); | |
| } | |
| .choice-pop-3d:hover { | |
| transform: translateY(-2px); | |
| box-shadow: inset 0 1px 0 rgba(255, 255, 255, 0.08), 0 7px 0 rgba(15, 23, 42, 0.9), 0 14px 26px rgba(99, 102, 241, 0.18); | |
| } | |
| .choice-pop-3d.active-style { | |
| background: linear-gradient(180deg, rgba(79, 70, 229, 0.24), rgba(15, 23, 42, 0.72)); | |
| box-shadow: inset 0 1px 0 rgba(255, 255, 255, 0.1), 0 6px 0 rgba(49, 46, 129, 0.85), 0 16px 30px rgba(99, 102, 241, 0.22); | |
| } | |
| .icon-pop-3d { | |
| box-shadow: 0 3px 0 rgba(15, 23, 42, 0.85); | |
| } | |
| .icon-pop-3d:hover { | |
| transform: translateY(-1px); | |
| box-shadow: 0 4px 0 rgba(15, 23, 42, 0.85), 0 10px 18px rgba(99, 102, 241, 0.14); | |
| } | |
| .icon-pop-3d:active { | |
| transform: translateY(2px); | |
| box-shadow: 0 1px 0 rgba(15, 23, 42, 0.85); | |
| } | |
| </style> | |
| </head> | |
| <body class="text-slate-100 min-h-screen flex flex-col justify-between"> | |
| <!-- Header --> | |
| <header class="border-b border-slate-800 bg-slate-900/80 backdrop-blur sticky top-0 z-50"> | |
| <div class="max-w-7xl mx-auto px-4 py-3 flex flex-col gap-3 lg:flex-row lg:justify-between lg:items-center"> | |
| <div class="flex items-center space-x-3"> | |
| <div class="p-2 bg-gradient-to-tr from-indigo-500 to-purple-500 rounded-lg shadow-lg shadow-indigo-500/30"> | |
| <i class="fa-solid fa-clone text-lg text-white"></i> | |
| </div> | |
| <div> | |
| <h1 class="text-lg font-bold bg-gradient-to-r from-indigo-400 via-purple-400 to-pink-400 bg-clip-text text-transparent"> | |
| α§αααααααααΌαααΌαααΆα AI | |
| </h1> | |
| <p class="hidden sm:block text-[11px] text-slate-400">ααααΆααααααΌαα ααΆαααα ααΏα αα·αααΆααααα½αααααΎααααΆαα</p> | |
| </div> | |
| </div> | |
| <div class="w-full lg:w-auto flex flex-col gap-1.5 lg:items-end"> | |
| <div class="w-full lg:w-auto flex items-center gap-1.5 bg-slate-950/60 border border-slate-800 rounded-lg px-2 py-1.5"> | |
| <i class="fa-solid fa-key text-xs text-indigo-400 shrink-0"></i> | |
| <input id="apiKeyInput" type="password" autocomplete="off" spellcheck="false" placeholder="API Key" class="min-w-0 flex-1 w-0 sm:w-52 lg:w-56 bg-transparent text-xs text-slate-200 placeholder-slate-500 focus:outline-none"> | |
| <button id="toggleApiKeyBtn" type="button" class="icon-pop-3d shrink-0 w-7 h-7 flex items-center justify-center text-slate-400 hover:text-slate-200 p-1" title="αααα αΆα/ααΆαα API Key"> | |
| <i id="toggleApiKeyIcon" class="fa-solid fa-eye"></i> | |
| </button> | |
| <button id="refreshBalanceBtn" type="button" class="icon-pop-3d shrink-0 w-7 h-7 flex items-center justify-center text-slate-400 hover:text-slate-200 p-1" title="αα·αα·ααα balance"> | |
| <i class="fa-solid fa-rotate"></i> | |
| </button> | |
| </div> | |
| <div class="flex flex-wrap items-center gap-1.5 text-[10px]"> | |
| <span class="flex h-2 w-2 relative shrink-0"> | |
| <span class="animate-ping absolute inline-flex h-full w-full rounded-full bg-emerald-400 opacity-75"></span> | |
| <span class="relative inline-flex rounded-full h-2 w-2 bg-emerald-500"></span> | |
| </span> | |
| <span class="leading-none text-slate-300 font-medium bg-slate-800/80 px-2 py-1 rounded-full border border-slate-700">R-Tool-AI</span> | |
| <span id="customerStatus" class="max-w-[142px] sm:max-w-none truncate leading-none text-slate-300 font-medium bg-slate-800/80 px-2 py-1 rounded-full border border-slate-700 whitespace-nowrap">Customer: --</span> | |
| <span id="keyExpiresStatus" class="leading-none text-slate-300 font-medium bg-slate-800/80 px-2 py-1 rounded-full border border-slate-700 whitespace-nowrap">Expiration: --</span> | |
| <span id="creditLimitStatus" class="leading-none text-slate-300 font-medium bg-slate-800/80 px-2 py-1 rounded-full border border-slate-700 whitespace-nowrap">Credit limit: --</span> | |
| </div> | |
| </div> | |
| </div> | |
| </header> | |
| <!-- Main Content --> | |
| <main class="max-w-7xl mx-auto px-4 py-8 w-full flex-grow"> | |
| <div class="grid grid-cols-1 lg:grid-cols-12 gap-8"> | |
| <!-- Left Panel: Upload and Settings --> | |
| <section class="lg:col-span-5 space-y-6"> | |
| <!-- Dropzone for Image --> | |
| <div class="glass rounded-2xl p-6 shadow-xl"> | |
| <h2 class="text-lg font-semibold mb-4 flex items-center gap-2 text-indigo-400"> | |
| <i class="fa-solid fa-image"></i> α‘. αααα αΌαααΌαααΆαα αααΆααααΎα | |
| </h2> | |
| <div id="dropzone" class="border-2 border-dashed border-slate-700 hover:border-indigo-500 rounded-xl p-8 text-center cursor-pointer transition-all bg-slate-950/40 relative overflow-hidden group"> | |
| <input type="file" id="imageInput" accept="image/png,image/jpeg,image/webp,image/gif" multiple class="hidden"> | |
| <div id="uploadPlaceholder" class="space-y-4"> | |
| <div class="mx-auto w-16 h-16 rounded-full bg-slate-800/80 flex items-center justify-center border border-slate-700 group-hover:scale-110 transition-transform"> | |
| <i class="fa-solid fa-cloud-arrow-up text-2xl text-indigo-400"></i> | |
| </div> | |
| <div> | |
| <p class="font-medium text-slate-200">α’αΌαααΌαααΆαααααΈααα α¬ α α»α ααΎααααΈααααΎαααΎαααααααΆαα αααΎα</p> | |
| <p class="text-xs text-slate-500 mt-1">ααΆαααααααααα PNG, JPG, JPEG, WEBP, GIF (ααΌαααΈαα½ααααααα 5MB)</p> | |
| </div> | |
| </div> | |
| <div id="imagePreviewContainer" class="hidden relative w-full rounded-lg border border-slate-700 bg-slate-900 p-3"> | |
| <div class="flex items-center justify-between gap-3 mb-3"> | |
| <span id="selectedImagesCount" class="text-xs text-indigo-300 font-semibold bg-indigo-500/10 border border-indigo-500/20 rounded-full px-3 py-1">0 ααΌαααΎα</span> | |
| <button id="removeImageBtn" type="button" class="btn-pop-3d bg-red-500/80 hover:bg-red-600 text-white px-3 py-1.5 rounded-lg transition-colors shadow-lg backdrop-blur-sm text-xs flex items-center gap-1.5" title="αα»αααΆααα’αα"> | |
| <i class="fa-solid fa-trash-can"></i> αα»αααΆααα’αα | |
| </button> | |
| </div> | |
| <div id="imagePreviewGrid" class="grid grid-cols-2 sm:grid-cols-3 gap-3 max-h-72 overflow-y-auto pr-1"> | |
| <!-- Source image previews will be injected dynamically here --> | |
| </div> | |
| </div> | |
| </div> | |
| </div> | |
| <!-- Clone Configurations --> | |
| <div class="glass rounded-2xl p-6 shadow-xl space-y-5"> | |
| <h2 class="text-lg font-semibold flex items-center gap-2 text-indigo-400"> | |
| <i class="fa-solid fa-sliders"></i> α’. ααΆααααααααΆαααααΌα (Clone Settings) | |
| </h2> | |
| <!-- Clone Quantity --> | |
| <div> | |
| <div class="flex justify-between items-center mb-2"> | |
| <label class="text-sm font-medium text-slate-300">α ααα½αααααΌααααα»ααα½αααΌαααΎα</label> | |
| <span id="quantityVal" class="text-indigo-400 font-bold bg-indigo-500/10 px-2.5 py-0.5 rounded-md border border-indigo-500/20">1 ααΌαααΆα</span> | |
| </div> | |
| <input type="range" id="cloneQuantity" min="1" max="8" value="1" class="w-full h-2 bg-slate-800 rounded-lg appearance-none cursor-pointer accent-indigo-500 focus:outline-none"> | |
| <div class="flex justify-between text-[10px] text-slate-500 mt-1 px-1"> | |
| <span>1 ααΌαααΆα</span> | |
| <span>4 ααΌαααΆα</span> | |
| <span>8 ααΌαααΆα</span> | |
| </div> | |
| </div> | |
| <!-- Similarity Options --> | |
| <div> | |
| <label class="text-sm font-medium text-slate-300 block mb-2">ααααα·αααααααααΉαα αααΆααααΎα</label> | |
| <div class="grid grid-cols-3 gap-2"> | |
| <button type="button" data-similarity="high" class="similarity-btn choice-pop-3d border border-slate-700 bg-slate-950/40 rounded-xl p-3 text-center transition-all hover:bg-slate-800 active-style border-indigo-500 text-indigo-400"> | |
| <i class="fa-solid fa-crosshairs block text-lg mb-1"></i> | |
| <span class="text-xs font-semibold">ααααααααααΆαα</span> | |
| </button> | |
| <button type="button" data-similarity="medium" class="similarity-btn choice-pop-3d border border-slate-700 bg-slate-950/40 rounded-xl p-3 text-center transition-all hover:bg-slate-800"> | |
| <i class="fa-solid fa-shuffle block text-lg mb-1"></i> | |
| <span class="text-xs font-semibold">αααααααααα</span> | |
| </button> | |
| <button type="button" data-similarity="creative" class="similarity-btn choice-pop-3d border border-slate-700 bg-slate-950/40 rounded-xl p-3 text-center transition-all hover:bg-slate-800"> | |
| <i class="fa-solid fa-wand-magic-sparkles block text-lg mb-1"></i> | |
| <span class="text-xs font-semibold">α αααααααα·αααααΈ</span> | |
| </button> | |
| </div> | |
| </div> | |
| <!-- AI Model Selector --> | |
| <div> | |
| <label class="text-sm font-medium text-slate-300 block mb-2 flex items-center justify-between"> | |
| <span>ααααΎαααΎα AI Model</span> | |
| <button id="reloadModelsBtn" type="button" class="icon-pop-3d text-[10px] text-indigo-300 hover:text-indigo-200 font-semibold flex items-center gap-1 px-2 py-1 border border-slate-800 bg-slate-950/40"> | |
| <i class="fa-solid fa-rotate"></i> Load Models | |
| </button> | |
| </label> | |
| <select id="modelSelect" class="w-full bg-slate-950/60 border border-slate-800 rounded-xl px-4 py-3 text-sm text-slate-200 focus:outline-none focus:border-indigo-500 focus:ring-1 focus:ring-indigo-500 transition-colors"> | |
| <option value="google/gemini-2.5-flash-image">Google Gemini 2.5 Flash Image</option> | |
| </select> | |
| <p id="modelHelpText" class="text-[10px] text-slate-500 mt-2">αααααΈααααααα αΆα model αααααΆαααα image input αα·α image outputα</p> | |
| </div> | |
| <!-- Extra Prompt Modifiers --> | |
| <div> | |
| <label class="text-sm font-medium text-slate-300 block mb-2 flex items-center justify-between"> | |
| <span>ααααααααΆαααααΆααα·ααα (αααα·ααααααα)</span> | |
| <span class="text-[10px] text-slate-500 font-normal">ααααα α α·ααα</span> | |
| </label> | |
| <textarea id="promptModifier" rows="2" placeholder="α§ααΆα αααα ααααΌαααααΈαααΆααααΌαααΈαα α (Anime), αααααααααααΊαααααα·α , ααααΌαααααααΆαααααα..." class="w-full bg-slate-950/60 border border-slate-800 rounded-xl px-4 py-3 text-sm text-slate-200 placeholder-slate-500 focus:outline-none focus:border-indigo-500 focus:ring-1 focus:ring-indigo-500 transition-colors resize-none"></textarea> | |
| </div> | |
| <!-- Action Button --> | |
| <button id="generateBtn" disabled class="btn-pop-3d w-full py-4 px-6 rounded-xl font-bold bg-gradient-to-r from-indigo-500 via-purple-500 to-pink-500 text-white shadow-lg shadow-indigo-500/20 hover:shadow-indigo-500/40 hover:opacity-95 transition-all disabled:opacity-50 disabled:cursor-not-allowed disabled:shadow-none flex items-center justify-center gap-2"> | |
| <i class="fa-solid fa-bolt"></i> α αΆααααααΎαααααΌαααΌαααΆα | |
| </button> | |
| </div> | |
| </section> | |
| <!-- Right Panel: Results & Gallery --> | |
| <section class="lg:col-span-7 flex flex-col space-y-6"> | |
| <div class="glass rounded-2xl p-6 shadow-xl flex-grow flex flex-col"> | |
| <div class="flex justify-between items-center mb-6"> | |
| <h2 class="text-lg font-semibold flex items-center gap-2 text-indigo-400"> | |
| <i class="fa-solid fa-images"></i> ααααααααααΆαααααΌα (Cloned Gallery) | |
| </h2> | |
| <button id="downloadAllBtn" class="btn-pop-3d hidden text-xs bg-slate-800 hover:bg-slate-700 text-slate-300 border border-slate-700 px-3 py-1.5 rounded-lg transition-colors flex items-center gap-1.5"> | |
| <i class="fa-solid fa-download"></i> ααΆαααααΌαααΆααα’αα | |
| </button> | |
| </div> | |
| <!-- Gallery Content Area --> | |
| <div id="galleryContainer" class="flex-grow flex flex-col justify-center items-center text-center p-8 border border-dashed border-slate-800 rounded-xl bg-slate-950/20"> | |
| <div id="galleryEmptyState" class="space-y-4 max-w-sm"> | |
| <div class="w-20 h-20 mx-auto rounded-full bg-slate-900 flex items-center justify-center border border-slate-800 text-slate-600"> | |
| <i class="fa-solid fa-shapes text-3xl"></i> | |
| </div> | |
| <div> | |
| <h3 class="font-semibold text-slate-300">αα·αααΆααααΆαααΌαααΆαααααΌααα α‘αΎααα</h3> | |
| <p class="text-sm text-slate-500 mt-1">ααΌααααα αΌαααΌαααΆα αα½α α α»α "α αΆααααααΎαααααΌαααΌαααΆα" ααΎααααΈαααα αΆααααααα!</p> | |
| </div> | |
| </div> | |
| <!-- Grid View for cloned results --> | |
| <div id="clonesGrid" class="hidden w-full grid grid-cols-1 sm:grid-cols-2 gap-4"> | |
| <!-- Cloned cards will be injected dynamically here --> | |
| </div> | |
| </div> | |
| </div> | |
| </section> | |
| </div> | |
| </main> | |
| <!-- Unified Popup for iPhone & Desktop Preview --> | |
| <div id="previewModal" class="fixed inset-0 bg-slate-950/95 z-50 hidden flex-col items-center justify-center p-4 backdrop-blur-md"> | |
| <div class="relative max-w-md w-full flex flex-col items-center"> | |
| <!-- Close Button --> | |
| <button id="closeModalBtn" class="icon-pop-3d absolute -top-12 right-0 bg-slate-800 hover:bg-slate-700 text-white p-2.5 rounded-full transition-all border border-slate-700"> | |
| <i class="fa-solid fa-xmark text-lg"></i> | |
| </button> | |
| <!-- iOS Instructions Alert Box --> | |
| <div id="iosInstruction" class="w-full bg-indigo-600/20 border border-indigo-500/40 rounded-xl p-4 mb-4 text-center"> | |
| <p class="text-sm font-semibold text-indigo-300 flex items-center justify-center gap-2"> | |
| <i class="fa-solid fa-hand-pointer animate-bounce"></i> αααααΆααααΌααααα iPhone | |
| </p> | |
| <p class="text-xs text-slate-300 mt-1">ααΌαα α»α αααααα (Long Press) ααΎααΌαααΆαααΆαααααα αα½α ααααΎαααΎα <strong>"αααααΆαα»ααααα»αααΌαααΆα" (Save to Photos)</strong> ααΎααααΈααΆαααα</p> | |
| </div> | |
| <!-- Image Container --> | |
| <div class="w-full bg-slate-900 rounded-xl overflow-hidden border border-slate-800 p-2 shadow-2xl"> | |
| <img id="modalTargetImg" src="" alt="Preview" class="w-full max-h-[60vh] object-contain rounded-lg"> | |
| </div> | |
| <!-- Standard Download Button (Hidden on iOS) --> | |
| <div id="standardDownloadBtnContainer" class="mt-4 w-full"> | |
| <a id="modalDownloadBtn" href="#" download="cloned_image.png" class="btn-pop-3d w-full justify-center bg-indigo-600 hover:bg-indigo-700 text-white px-6 py-3 rounded-xl font-bold transition-colors flex items-center gap-2"> | |
| <i class="fa-solid fa-download"></i> ααΆαααααΌαααα | |
| </a> | |
| </div> | |
| </div> | |
| </div> | |
| <!-- Admin Contact Notice Modal --> | |
| <div id="adminNoticeModal" class="fixed inset-0 bg-slate-950/80 z-50 hidden items-center justify-center p-4 backdrop-blur-sm"> | |
| <div class="max-w-md w-full bg-slate-900 border border-red-500/30 rounded-2xl p-6 shadow-2xl"> | |
| <div class="flex items-start gap-3"> | |
| <div class="w-10 h-10 rounded-full bg-red-500/15 border border-red-500/30 flex items-center justify-center text-red-300 shrink-0"> | |
| <i class="fa-solid fa-triangle-exclamation"></i> | |
| </div> | |
| <div class="min-w-0"> | |
| <h3 id="adminNoticeTitle" class="text-lg font-bold text-white">ααΌαααΆαααα Admin</h3> | |
| <p id="adminNoticeMessage" class="text-sm text-slate-300 mt-1 leading-relaxed">ααΌαααΆαααα Admin ααΎααααΈαα·α α¬αααα API Keyα</p> | |
| </div> | |
| </div> | |
| <div class="mt-5 grid gap-2 text-sm"> | |
| <a href="mailto:chanraphone4@gmail.com" class="flex items-center gap-2 text-indigo-200 hover:text-indigo-100 bg-slate-950/60 border border-slate-800 rounded-lg px-3 py-2"> | |
| <i class="fa-solid fa-envelope text-indigo-300"></i> | |
| <span>chanraphone4@gmail.com</span> | |
| </a> | |
| <a href="tel:077857878" class="flex items-center gap-2 text-indigo-200 hover:text-indigo-100 bg-slate-950/60 border border-slate-800 rounded-lg px-3 py-2"> | |
| <i class="fa-solid fa-phone text-indigo-300"></i> | |
| <span>077857878</span> | |
| </a> | |
| <a href="tel:086680000" class="flex items-center gap-2 text-indigo-200 hover:text-indigo-100 bg-slate-950/60 border border-slate-800 rounded-lg px-3 py-2"> | |
| <i class="fa-solid fa-phone text-indigo-300"></i> | |
| <span>086680000</span> | |
| </a> | |
| </div> | |
| <button id="adminNoticeCloseBtn" type="button" class="btn-pop-3d mt-5 w-full bg-indigo-600 hover:bg-indigo-700 text-white px-4 py-2.5 rounded-xl font-semibold transition-colors"> | |
| OK | |
| </button> | |
| </div> | |
| </div> | |
| <!-- Error/Notification Toast --> | |
| <div id="toast" class="fixed bottom-6 right-6 z-50 transform translate-y-20 opacity-0 transition-all duration-300 max-w-md w-full"> | |
| <div class="bg-red-500/90 text-white p-4 rounded-xl shadow-2xl backdrop-blur flex items-center gap-3 border border-red-400/20"> | |
| <i id="toastIcon" class="fa-solid fa-circle-exclamation text-xl"></i> | |
| <span id="toastMessage" class="text-sm font-medium">ααΆαααα α»ααα½αααΆαααΎαα‘αΎα!</span> | |
| </div> | |
| </div> | |
| <!-- Footer --> | |
| <footer class="border-t border-slate-800/80 bg-slate-950/60 py-4 mt-12 text-center text-xs text-slate-500"> | |
| <div class="max-w-7xl mx-auto px-4"> | |
| <p>Β© 2026 R-Tool-AI α α’αααααα·αααΌαα 077857878 / 086680000</p> | |
| </div> | |
| </footer> | |
| <!-- Scripts --> | |
| <script> | |
| // OpenRouter API configuration | |
| const openRouterEndpoint = "https://openrouter.ai/api/v1/chat/completions"; | |
| const openRouterKeyEndpoint = "https://openrouter.ai/api/v1/key"; | |
| const openRouterModelsEndpoint = "https://openrouter.ai/api/v1/models"; | |
| const defaultOpenRouterModel = "google/gemini-2.5-flash-image"; | |
| const workspaceCreditLimit = 1; | |
| const workspaceCreditLimitResetInterval = "weekly"; | |
| const openRouterAppTitle = "AI Image Cloner"; | |
| const adminContactEmail = "chanraphone4@gmail.com"; | |
| const adminContactPhones = "077857878 / 086680000"; | |
| const apiKeyStorageKey = "openrouter_api_key"; | |
| const selectedModelStorageKey = "openrouter_selected_model"; | |
| const requestCountStorageKey = "openrouter_request_count"; | |
| const supportedImageTypes = ["image/png", "image/jpeg", "image/webp", "image/gif"]; | |
| let uploadedImages = []; | |
| let selectedSimilarity = "high"; | |
| let generatedImagesArray = []; | |
| let balanceRefreshTimer; | |
| let latestKeyData = null; | |
| const savedOpenRouterModel = localStorage.getItem(selectedModelStorageKey); | |
| let selectedOpenRouterModel = savedOpenRouterModel && !["undefined", "null", ""].includes(savedOpenRouterModel) | |
| ? savedOpenRouterModel | |
| : defaultOpenRouterModel; | |
| let requestCount = parseInt(localStorage.getItem(requestCountStorageKey) || "0", 10); | |
| // Detect device | |
| const isIOS = /iPad|iPhone|iPod/.test(navigator.userAgent) || (navigator.platform === 'MacIntel' && navigator.maxTouchPoints > 1); | |
| // DOM elements | |
| const imageInput = document.getElementById('imageInput'); | |
| const dropzone = document.getElementById('dropzone'); | |
| const uploadPlaceholder = document.getElementById('uploadPlaceholder'); | |
| const imagePreviewContainer = document.getElementById('imagePreviewContainer'); | |
| const imagePreviewGrid = document.getElementById('imagePreviewGrid'); | |
| const selectedImagesCount = document.getElementById('selectedImagesCount'); | |
| const removeImageBtn = document.getElementById('removeImageBtn'); | |
| const cloneQuantity = document.getElementById('cloneQuantity'); | |
| const quantityVal = document.getElementById('quantityVal'); | |
| const apiKeyInput = document.getElementById('apiKeyInput'); | |
| const toggleApiKeyBtn = document.getElementById('toggleApiKeyBtn'); | |
| const toggleApiKeyIcon = document.getElementById('toggleApiKeyIcon'); | |
| const refreshBalanceBtn = document.getElementById('refreshBalanceBtn'); | |
| const customerStatus = document.getElementById('customerStatus'); | |
| const creditLimitStatus = document.getElementById('creditLimitStatus'); | |
| const keyExpiresStatus = document.getElementById('keyExpiresStatus'); | |
| const modelSelect = document.getElementById('modelSelect'); | |
| const reloadModelsBtn = document.getElementById('reloadModelsBtn'); | |
| const modelHelpText = document.getElementById('modelHelpText'); | |
| const promptModifier = document.getElementById('promptModifier'); | |
| const generateBtn = document.getElementById('generateBtn'); | |
| const galleryEmptyState = document.getElementById('galleryEmptyState'); | |
| const clonesGrid = document.getElementById('clonesGrid'); | |
| const galleryContainer = document.getElementById('galleryContainer'); | |
| const downloadAllBtn = document.getElementById('downloadAllBtn'); | |
| const similarityBtns = document.querySelectorAll('.similarity-btn'); | |
| const toast = document.getElementById('toast'); | |
| const toastMessage = document.getElementById('toastMessage'); | |
| const toastIcon = document.getElementById('toastIcon'); | |
| const adminNoticeModal = document.getElementById('adminNoticeModal'); | |
| const adminNoticeTitle = document.getElementById('adminNoticeTitle'); | |
| const adminNoticeMessage = document.getElementById('adminNoticeMessage'); | |
| const adminNoticeCloseBtn = document.getElementById('adminNoticeCloseBtn'); | |
| // Modal elements | |
| const previewModal = document.getElementById('previewModal'); | |
| const closeModalBtn = document.getElementById('closeModalBtn'); | |
| const iosInstruction = document.getElementById('iosInstruction'); | |
| const modalTargetImg = document.getElementById('modalTargetImg'); | |
| const standardDownloadBtnContainer = document.getElementById('standardDownloadBtnContainer'); | |
| const modalDownloadBtn = document.getElementById('modalDownloadBtn'); | |
| apiKeyInput.value = localStorage.getItem(apiKeyStorageKey) || ""; | |
| modelSelect.value = selectedOpenRouterModel; | |
| updateRequestCountStatus(); | |
| function getOpenRouterApiKey() { | |
| return apiKeyInput.value.trim(); | |
| } | |
| function getSelectedOpenRouterModel() { | |
| const model = modelSelect.value || selectedOpenRouterModel || defaultOpenRouterModel; | |
| return ["undefined", "null", ""].includes(model) ? defaultOpenRouterModel : model; | |
| } | |
| function updateRequestCountStatus() { | |
| } | |
| function getCustomerName(keyData) { | |
| return keyData?.name || keyData?.label || ""; | |
| } | |
| function updateCustomerStatus(keyData) { | |
| const hasApiKey = Boolean(getOpenRouterApiKey()); | |
| const customerName = getCustomerName(keyData); | |
| customerStatus.textContent = customerName | |
| ? `Customer: ${customerName}` | |
| : hasApiKey ? "Customer: checking" : "Customer: --"; | |
| if (customerName) { | |
| customerStatus.title = customerName; | |
| } else { | |
| customerStatus.removeAttribute("title"); | |
| } | |
| } | |
| function formatLimitResetLabel(value) { | |
| if (!value) return ""; | |
| const labels = { | |
| daily: "day", | |
| weekly: "week", | |
| monthly: "month" | |
| }; | |
| return ` / ${labels[value] || String(value).replaceAll("_", " ")}`; | |
| } | |
| function updateCreditLimitStatus(value, resetInterval = "") { | |
| if (value === undefined) { | |
| creditLimitStatus.textContent = "Credit limit: --"; | |
| creditLimitStatus.removeAttribute("title"); | |
| return false; | |
| } | |
| if (value === null || value === "") { | |
| creditLimitStatus.textContent = "Credit limit: Unlimited"; | |
| creditLimitStatus.removeAttribute("title"); | |
| return false; | |
| } | |
| const limit = getFiniteNumber(value); | |
| if (limit === null) { | |
| creditLimitStatus.textContent = `Credit limit: ${String(value)}`; | |
| creditLimitStatus.title = String(value); | |
| return true; | |
| } | |
| const resetLabel = formatLimitResetLabel(resetInterval); | |
| creditLimitStatus.textContent = `Credit limit: $${formatCreditAmount(limit)}${resetLabel}`; | |
| creditLimitStatus.title = resetInterval ? `Resets: ${String(resetInterval)}` : "Credit limit"; | |
| return true; | |
| } | |
| function updateWorkspaceCreditLimitStatus() { | |
| const hasWorkspaceLimit = updateCreditLimitStatus(workspaceCreditLimit, workspaceCreditLimitResetInterval); | |
| creditLimitStatus.title = "Workspace guardrail budget configured in OpenRouter."; | |
| return hasWorkspaceLimit; | |
| } | |
| function getCreditRemaining(keyData) { | |
| const directRemaining = getFiniteNumber(keyData?.limit_remaining); | |
| if (directRemaining !== null) return directRemaining; | |
| const keyLimit = getFiniteNumber(keyData?.limit); | |
| const keyUsage = getFiniteNumber(keyData?.usage); | |
| if (keyLimit !== null && keyUsage !== null) { | |
| return Math.max(keyLimit - keyUsage, 0); | |
| } | |
| return null; | |
| } | |
| function isKeyExpired(value) { | |
| if (value === null || value === undefined || value === "") return false; | |
| const expiresDate = new Date(value); | |
| return Number.isFinite(expiresDate.getTime()) && expiresDate.getTime() <= Date.now(); | |
| } | |
| function showAdminContactNotice(reason) { | |
| const messages = { | |
| expired: "API Key ααααα’αααααΆααα»ααααααα ααΌαααΆαααα Admin ααΎααααΈαα·α α¬αααα keyα", | |
| credit: "Credit αααα API Key α’ααα αΎαα ααΌαααΆαααα Admin ααΎααααΈαα·α credit α¬αααα keyα" | |
| }; | |
| adminNoticeTitle.textContent = reason === "expired" ? "API Key αα»αααααα" : "Credit α’αα"; | |
| adminNoticeMessage.textContent = `${messages[reason]} Email: ${adminContactEmail}. Phone: ${adminContactPhones}.`; | |
| adminNoticeModal.classList.remove('hidden'); | |
| adminNoticeModal.classList.add('flex'); | |
| } | |
| function hideAdminContactNotice() { | |
| adminNoticeModal.classList.add('hidden'); | |
| adminNoticeModal.classList.remove('flex'); | |
| } | |
| function checkKeyAccessNotice(keyData) { | |
| if (isKeyExpired(keyData?.expires_at)) { | |
| showAdminContactNotice("expired"); | |
| return false; | |
| } | |
| const creditRemaining = getCreditRemaining(keyData); | |
| if (creditRemaining !== null && creditRemaining <= 0) { | |
| showAdminContactNotice("credit"); | |
| return false; | |
| } | |
| hideAdminContactNotice(); | |
| return true; | |
| } | |
| function isCreditError(error) { | |
| const message = String(error?.message || "").toLowerCase(); | |
| return error?.status === 402 | |
| || message.includes("credit") | |
| || message.includes("quota") | |
| || message.includes("insufficient") | |
| || message.includes("limit exceeded"); | |
| } | |
| function formatTimeRemaining(targetDate) { | |
| const remainingMs = targetDate.getTime() - Date.now(); | |
| if (remainingMs <= 0) return "Expired"; | |
| const totalMinutes = Math.ceil(remainingMs / 60000); | |
| const days = Math.floor(totalMinutes / 1440); | |
| const hours = Math.floor((totalMinutes % 1440) / 60); | |
| const minutes = totalMinutes % 60; | |
| if (days >= 1) { | |
| return hours > 0 ? `${days}d ${hours}h left` : `${days}d left`; | |
| } | |
| if (hours >= 1) { | |
| return minutes > 0 ? `${hours}h ${minutes}m left` : `${hours}h left`; | |
| } | |
| return `${minutes}m left`; | |
| } | |
| function updateKeyExpiresStatus(value) { | |
| if (value === undefined) { | |
| keyExpiresStatus.textContent = "Expiration: --"; | |
| keyExpiresStatus.removeAttribute("title"); | |
| return; | |
| } | |
| if (value === null || value === "") { | |
| keyExpiresStatus.textContent = "Expiration: Never"; | |
| keyExpiresStatus.removeAttribute("title"); | |
| return; | |
| } | |
| const expiresDate = new Date(value); | |
| if (Number.isNaN(expiresDate.getTime())) { | |
| keyExpiresStatus.textContent = `Expiration: ${String(value)}`; | |
| keyExpiresStatus.title = String(value); | |
| return; | |
| } | |
| keyExpiresStatus.textContent = `Expiration: ${formatTimeRemaining(expiresDate)}`; | |
| keyExpiresStatus.title = expiresDate.toLocaleString(); | |
| } | |
| function incrementRequestCount() { | |
| requestCount++; | |
| localStorage.setItem(requestCountStorageKey, String(requestCount)); | |
| updateRequestCountStatus(); | |
| } | |
| function setModelHelp(message, type = "neutral") { | |
| modelHelpText.textContent = message; | |
| modelHelpText.classList.remove('text-slate-500', 'text-emerald-300', 'text-red-300'); | |
| if (type === "success") { | |
| modelHelpText.classList.add('text-emerald-300'); | |
| } else if (type === "error") { | |
| modelHelpText.classList.add('text-red-300'); | |
| } else { | |
| modelHelpText.classList.add('text-slate-500'); | |
| } | |
| } | |
| function applyModelOptions(models) { | |
| let currentModel = selectedOpenRouterModel || defaultOpenRouterModel; | |
| const modelOptions = models.length ? models : [{ | |
| id: defaultOpenRouterModel, | |
| name: "Google Gemini 2.5 Flash Image" | |
| }]; | |
| let hasCurrentModel = modelOptions.some(model => model.id === currentModel); | |
| if (models.length && !hasCurrentModel) { | |
| currentModel = defaultOpenRouterModel; | |
| selectedOpenRouterModel = defaultOpenRouterModel; | |
| localStorage.setItem(selectedModelStorageKey, defaultOpenRouterModel); | |
| hasCurrentModel = modelOptions.some(model => model.id === currentModel); | |
| } | |
| modelSelect.innerHTML = modelOptions.map(model => ` | |
| <option value="${escapeHtml(model.id)}">${escapeHtml(model.name || model.id)}</option> | |
| `).join(''); | |
| if (!hasCurrentModel) { | |
| modelSelect.insertAdjacentHTML('afterbegin', `<option value="${escapeHtml(currentModel)}">${escapeHtml(currentModel)}</option>`); | |
| } | |
| modelSelect.value = currentModel; | |
| } | |
| function isImageToImageModel(model) { | |
| const inputModalities = model?.architecture?.input_modalities || []; | |
| const outputModalities = model?.architecture?.output_modalities || []; | |
| return inputModalities.includes("image") && outputModalities.includes("image"); | |
| } | |
| async function loadImageModels(showToastOnSuccess = false) { | |
| setModelHelp("αααα»α load image models..."); | |
| try { | |
| const data = await fetchOpenRouterJson(openRouterModelsEndpoint, getOpenRouterApiKey()); | |
| const models = (data?.data || []) | |
| .filter(isImageToImageModel) | |
| .sort((first, second) => (first.name || first.id).localeCompare(second.name || second.id)); | |
| applyModelOptions(models); | |
| setModelHelp(`ααΆα load image-capable models α ααα½α ${models.length}α`, "success"); | |
| if (showToastOnSuccess) showToast("ααΆα load AI Models αα½α ααΆαα!", "success"); | |
| } catch (error) { | |
| console.error(error); | |
| applyModelOptions([]); | |
| setModelHelp("αα·αα’αΆα load models ααΆαα ααααΎ default model αα·αα", "error"); | |
| } | |
| } | |
| apiKeyInput.addEventListener('input', () => { | |
| localStorage.setItem(apiKeyStorageKey, getOpenRouterApiKey()); | |
| clearTimeout(balanceRefreshTimer); | |
| const openRouterApiKey = getOpenRouterApiKey(); | |
| latestKeyData = null; | |
| customerStatus.textContent = openRouterApiKey ? "Customer: checking" : "Customer: --"; | |
| if (!openRouterApiKey) hideAdminContactNotice(); | |
| updateCreditLimitStatus(undefined); | |
| updateKeyExpiresStatus(undefined); | |
| if (openRouterApiKey.length > 20) { | |
| balanceRefreshTimer = setTimeout(() => refreshOpenRouterBalance(false), 900); | |
| } | |
| }); | |
| apiKeyInput.addEventListener('blur', () => { | |
| if (getOpenRouterApiKey()) refreshOpenRouterBalance(false); | |
| }); | |
| apiKeyInput.addEventListener('keydown', (event) => { | |
| if (event.key === 'Enter') { | |
| apiKeyInput.blur(); | |
| } | |
| }); | |
| toggleApiKeyBtn.addEventListener('click', () => { | |
| const shouldShow = apiKeyInput.type === 'password'; | |
| apiKeyInput.type = shouldShow ? 'text' : 'password'; | |
| toggleApiKeyIcon.className = shouldShow ? 'fa-solid fa-eye-slash' : 'fa-solid fa-eye'; | |
| }); | |
| refreshBalanceBtn.addEventListener('click', () => { | |
| refreshOpenRouterBalance(true); | |
| }); | |
| modelSelect.addEventListener('change', () => { | |
| selectedOpenRouterModel = getSelectedOpenRouterModel(); | |
| localStorage.setItem(selectedModelStorageKey, selectedOpenRouterModel); | |
| setModelHelp(`αααα»αααααΎ model: ${selectedOpenRouterModel}`); | |
| }); | |
| reloadModelsBtn.addEventListener('click', () => { | |
| loadImageModels(true); | |
| }); | |
| function formatCreditAmount(value) { | |
| return Number(value).toLocaleString(undefined, { | |
| minimumFractionDigits: 2, | |
| maximumFractionDigits: 2 | |
| }); | |
| } | |
| function getFiniteNumber(value) { | |
| if (value === null || value === undefined || value === "") return null; | |
| const numberValue = Number(value); | |
| return Number.isFinite(numberValue) ? numberValue : null; | |
| } | |
| async function fetchOpenRouterJson(url, openRouterApiKey) { | |
| const headers = { | |
| 'HTTP-Referer': window.location.origin === 'null' ? 'http://localhost' : window.location.origin, | |
| 'X-OpenRouter-Title': openRouterAppTitle | |
| }; | |
| if (openRouterApiKey) { | |
| headers.Authorization = `Bearer ${openRouterApiKey}`; | |
| } | |
| const response = await fetch(url, { | |
| method: 'GET', | |
| headers | |
| }); | |
| const responseText = await response.text(); | |
| const data = responseText ? JSON.parse(responseText) : {}; | |
| if (!response.ok) { | |
| const message = data?.error?.message || data?.message || responseText || `HTTP error! status: ${response.status}`; | |
| throw new Error(message); | |
| } | |
| return data; | |
| } | |
| async function refreshOpenRouterBalance(showToastOnSuccess = false) { | |
| const openRouterApiKey = getOpenRouterApiKey(); | |
| if (!openRouterApiKey) { | |
| latestKeyData = null; | |
| hideAdminContactNotice(); | |
| updateCustomerStatus(null); | |
| updateCreditLimitStatus(undefined); | |
| updateKeyExpiresStatus(undefined); | |
| return; | |
| } | |
| customerStatus.textContent = "Customer: checking"; | |
| creditLimitStatus.textContent = "Credit limit: checking"; | |
| creditLimitStatus.removeAttribute("title"); | |
| keyExpiresStatus.textContent = "Expiration: checking"; | |
| try { | |
| const keyInfo = await fetchOpenRouterJson(openRouterKeyEndpoint, openRouterApiKey); | |
| const keyData = keyInfo?.data || {}; | |
| latestKeyData = keyData; | |
| updateCustomerStatus(keyData); | |
| const hasKeyCreditLimit = updateCreditLimitStatus(keyData.limit, keyData.limit_reset); | |
| if (!hasKeyCreditLimit) { | |
| updateWorkspaceCreditLimitStatus(); | |
| } | |
| updateKeyExpiresStatus(keyData.expires_at); | |
| checkKeyAccessNotice(keyData); | |
| if (showToastOnSuccess) showToast("ααΆααα·αα·ααα customer info αα½α ααΆαα!", "success"); | |
| } catch (error) { | |
| console.error(error); | |
| latestKeyData = null; | |
| customerStatus.textContent = "Customer: key error"; | |
| updateCreditLimitStatus(undefined); | |
| updateKeyExpiresStatus(undefined); | |
| showToast("αα·αα’αΆα αα·αα·ααα OpenRouter balance ααΆαααα ααΌααα·αα·ααα API Keyα", "error"); | |
| } | |
| } | |
| if (getOpenRouterApiKey()) { | |
| refreshOpenRouterBalance(false); | |
| } | |
| // Manage similarity configurations | |
| similarityBtns.forEach(btn => { | |
| btn.addEventListener('click', () => { | |
| similarityBtns.forEach(b => { | |
| b.classList.remove('active-style', 'border-indigo-500', 'text-indigo-400'); | |
| b.classList.add('border-slate-700'); | |
| }); | |
| btn.classList.remove('border-slate-700'); | |
| btn.classList.add('active-style', 'border-indigo-500', 'text-indigo-400'); | |
| selectedSimilarity = btn.dataset.similarity; | |
| }); | |
| }); | |
| // Clone counter update | |
| cloneQuantity.addEventListener('input', (e) => { | |
| quantityVal.textContent = `${e.target.value} ααΌαααΆα`; | |
| }); | |
| // Drag and drop setup | |
| dropzone.addEventListener('click', (e) => { | |
| if (e.target.closest('button')) return; | |
| imageInput.click(); | |
| }); | |
| dropzone.addEventListener('dragover', (e) => { | |
| e.preventDefault(); | |
| dropzone.classList.add('border-indigo-500', 'bg-indigo-500/5'); | |
| }); | |
| dropzone.addEventListener('dragleave', () => { | |
| dropzone.classList.remove('border-indigo-500', 'bg-indigo-500/5'); | |
| }); | |
| dropzone.addEventListener('drop', (e) => { | |
| e.preventDefault(); | |
| dropzone.classList.remove('border-indigo-500', 'bg-indigo-500/5'); | |
| if (e.dataTransfer.files.length) { | |
| handleImageFiles(Array.from(e.dataTransfer.files)); | |
| } | |
| }); | |
| imageInput.addEventListener('change', (e) => { | |
| if (e.target.files.length) { | |
| handleImageFiles(Array.from(e.target.files)); | |
| } | |
| }); | |
| removeImageBtn.addEventListener('click', (e) => { | |
| e.stopPropagation(); | |
| resetUpload(); | |
| }); | |
| async function handleImageFiles(files) { | |
| const validFiles = []; | |
| files.forEach(file => { | |
| if (!supportedImageTypes.includes(file.type)) { | |
| showToast("ααΆαα―αααΆααααααα·αααα PNG, JPG, WEBP α¬ GIFα ααΆαααααα―αααΆαααΆαααααα", "error"); | |
| return; | |
| } | |
| if (file.size > 5 * 1024 * 1024) { | |
| showToast("ααΆαααΌαααΆααααααααααα ααΌαααααΎααΌαααΈαα½ααααααα 5MBα", "error"); | |
| return; | |
| } | |
| validFiles.push(file); | |
| }); | |
| if (!validFiles.length) return; | |
| try { | |
| const images = await Promise.all(validFiles.map(readImageFile)); | |
| uploadedImages = uploadedImages.concat(images); | |
| imageInput.value = ""; | |
| renderUploadedImages(); | |
| showToast(`ααΆααααα αΌαααΌαααΎα ${images.length} ααΌαα ααα»α ${uploadedImages.length} ααΌαα`, "success"); | |
| } catch (error) { | |
| console.error(error); | |
| showToast("ααΆαα’αΆαα―αααΆαααΌαααΆαααΆααααα αΆ!", "error"); | |
| } | |
| } | |
| function readImageFile(file) { | |
| return new Promise((resolve, reject) => { | |
| const reader = new FileReader(); | |
| reader.readAsDataURL(file); | |
| reader.onload = () => { | |
| const dataUrl = reader.result; | |
| resolve({ | |
| id: `${Date.now()}-${Math.random().toString(16).slice(2)}`, | |
| name: file.name, | |
| type: file.type, | |
| dataUrl, | |
| base64: dataUrl.split(',')[1] | |
| }); | |
| }; | |
| reader.onerror = reject; | |
| }); | |
| } | |
| function escapeHtml(value) { | |
| return value.replace(/[&<>"']/g, char => ({ | |
| '&': '&', | |
| '<': '<', | |
| '>': '>', | |
| '"': '"', | |
| "'": ''' | |
| }[char])); | |
| } | |
| applyModelOptions([]); | |
| loadImageModels(false); | |
| function renderUploadedImages() { | |
| if (!uploadedImages.length) { | |
| imagePreviewGrid.innerHTML = ""; | |
| selectedImagesCount.textContent = "0 ααΌαααΎα"; | |
| uploadPlaceholder.classList.remove('hidden'); | |
| imagePreviewContainer.classList.add('hidden'); | |
| generateBtn.setAttribute('disabled', 'true'); | |
| return; | |
| } | |
| selectedImagesCount.textContent = `${uploadedImages.length} ααΌαααΎα`; | |
| imagePreviewGrid.innerHTML = uploadedImages.map((image, index) => ` | |
| <div class="relative rounded-lg overflow-hidden border border-slate-700 bg-slate-950/60 h-28 group/source"> | |
| <img src="${image.dataUrl}" alt="${escapeHtml(image.name)}" class="w-full h-full object-cover"> | |
| <button type="button" onclick="event.stopPropagation(); removeUploadedImage('${image.id}')" class="icon-pop-3d absolute top-2 right-2 bg-red-500/85 hover:bg-red-600 text-white w-7 h-7 rounded-lg transition-colors flex items-center justify-center opacity-100 sm:opacity-0 sm:group-hover/source:opacity-100" title="αα»αααΌαααα"> | |
| <i class="fa-solid fa-xmark text-xs"></i> | |
| </button> | |
| <div class="absolute left-2 bottom-2 max-w-[calc(100%-1rem)] text-[10px] text-white bg-slate-950/70 px-2 py-1 rounded-md truncate">#${index + 1} ${escapeHtml(image.name)}</div> | |
| </div> | |
| `).join(''); | |
| uploadPlaceholder.classList.add('hidden'); | |
| imagePreviewContainer.classList.remove('hidden'); | |
| generateBtn.removeAttribute('disabled'); | |
| } | |
| window.removeUploadedImage = function(imageId) { | |
| uploadedImages = uploadedImages.filter(image => image.id !== imageId); | |
| renderUploadedImages(); | |
| }; | |
| function resetUpload() { | |
| uploadedImages = []; | |
| imageInput.value = ""; | |
| renderUploadedImages(); | |
| } | |
| // Beautiful toast handler | |
| function showToast(message, type = "error") { | |
| toastMessage.textContent = message; | |
| if (type === "success") { | |
| toast.firstElementChild.className = "bg-emerald-500/90 text-white p-4 rounded-xl shadow-2xl backdrop-blur flex items-center gap-3 border border-emerald-400/20"; | |
| toastIcon.className = "fa-solid fa-circle-check text-xl"; | |
| } else { | |
| toast.firstElementChild.className = "bg-red-500/90 text-white p-4 rounded-xl shadow-2xl backdrop-blur flex items-center gap-3 border border-red-400/20"; | |
| toastIcon.className = "fa-solid fa-circle-exclamation text-xl"; | |
| } | |
| toast.classList.remove('translate-y-20', 'opacity-0'); | |
| setTimeout(() => { | |
| toast.classList.add('translate-y-20', 'opacity-0'); | |
| }, 4000); | |
| } | |
| // Exponential backoff mechanism | |
| async function fetchWithRetry(url, options, retries = 3, delay = 1200) { | |
| try { | |
| incrementRequestCount(); | |
| const response = await fetch(url, options); | |
| const responseText = await response.text(); | |
| let data = {}; | |
| try { | |
| data = responseText ? JSON.parse(responseText) : {}; | |
| } catch (parseError) { | |
| if (response.ok) throw parseError; | |
| } | |
| if (!response.ok) { | |
| const message = data?.error?.message || data?.message || responseText || `HTTP error! status: ${response.status}`; | |
| const error = new Error(`OpenRouter API error (${response.status}): ${message}`); | |
| error.status = response.status; | |
| throw error; | |
| } | |
| return data; | |
| } catch (error) { | |
| const canRetry = !error.status || error.status === 429 || error.status >= 500; | |
| if (retries > 0 && canRetry) { | |
| await new Promise(resolve => setTimeout(resolve, delay)); | |
| return fetchWithRetry(url, options, retries - 1, delay * 2); | |
| } | |
| throw error; | |
| } | |
| } | |
| // Open optimized preview modal | |
| window.openPreviewModal = function(imageSrc, index = 1) { | |
| modalTargetImg.src = imageSrc; | |
| modalDownloadBtn.href = imageSrc; | |
| modalDownloadBtn.download = `cloned_image_${index}.png`; | |
| if (isIOS) { | |
| iosInstruction.classList.remove('hidden'); | |
| standardDownloadBtnContainer.classList.add('hidden'); | |
| } else { | |
| iosInstruction.classList.add('hidden'); | |
| standardDownloadBtnContainer.classList.remove('hidden'); | |
| } | |
| previewModal.classList.remove('hidden'); | |
| previewModal.classList.add('flex'); | |
| }; | |
| closeModalBtn.addEventListener('click', () => { | |
| previewModal.classList.add('hidden'); | |
| previewModal.classList.remove('flex'); | |
| }); | |
| adminNoticeCloseBtn.addEventListener('click', hideAdminContactNotice); | |
| // Smart downloader | |
| window.downloadImage = function(src, index) { | |
| if (isIOS) { | |
| openPreviewModal(src, index); | |
| } else { | |
| const link = document.createElement('a'); | |
| link.href = src; | |
| link.download = `cloned_image_${index}.png`; | |
| document.body.appendChild(link); | |
| link.click(); | |
| link.remove(); | |
| } | |
| }; | |
| // Main Image Clone Process | |
| generateBtn.addEventListener('click', async () => { | |
| if (!uploadedImages.length) return; | |
| const openRouterApiKey = getOpenRouterApiKey(); | |
| if (!openRouterApiKey) { | |
| showToast("ααΌααααα αΌα OpenRouter API Key αα»ααααααααΌαααΌαααΆα!", "error"); | |
| return; | |
| } | |
| try { | |
| const keyInfo = await fetchOpenRouterJson(openRouterKeyEndpoint, openRouterApiKey); | |
| const keyData = keyInfo?.data || {}; | |
| latestKeyData = keyData; | |
| updateCustomerStatus(keyData); | |
| const hasKeyCreditLimit = updateCreditLimitStatus(keyData.limit, keyData.limit_reset); | |
| if (!hasKeyCreditLimit) { | |
| updateWorkspaceCreditLimitStatus(); | |
| } | |
| updateKeyExpiresStatus(keyData.expires_at); | |
| if (!checkKeyAccessNotice(keyData)) return; | |
| } catch (error) { | |
| console.error(error); | |
| showToast("αα·αα’αΆα αα·αα·αααααααΆαααΆα API Key ααΆαααα ααΌααα·αα·ααα Key αααααααα", "error"); | |
| return; | |
| } | |
| const countPerSource = parseInt(cloneQuantity.value); | |
| const totalCloneCount = uploadedImages.length * countPerSource; | |
| const extraPrompt = promptModifier.value.trim(); | |
| // Set up gallery layout | |
| galleryEmptyState.classList.add('hidden'); | |
| clonesGrid.classList.remove('hidden'); | |
| clonesGrid.innerHTML = ''; | |
| downloadAllBtn.classList.add('hidden'); | |
| galleryContainer.className = "flex-grow p-4 border border-dashed border-slate-800 rounded-xl bg-slate-950/20"; | |
| generatedImagesArray = []; | |
| // Add skeletons | |
| for (let i = 1; i <= totalCloneCount; i++) { | |
| clonesGrid.innerHTML += ` | |
| <div id="clone-card-${i}" class="bg-slate-900 border border-slate-800 rounded-xl overflow-hidden shadow-lg p-4 flex flex-col justify-between min-h-[320px]"> | |
| <div class="relative flex-grow flex items-center justify-center bg-slate-950/40 rounded-lg overflow-hidden h-52"> | |
| <div class="text-center space-y-3"> | |
| <i class="fa-solid fa-spinner animate-spin text-3xl text-indigo-400"></i> | |
| <p class="text-xs text-slate-400 tracking-wide">αααα»αααααΌαααΌαααΆαααΈ ${i}...</p> | |
| </div> | |
| </div> | |
| <div class="mt-4 flex justify-between items-center"> | |
| <span class="text-xs text-indigo-400 bg-indigo-500/10 px-2.5 py-1 rounded-full font-semibold border border-indigo-500/10">ααΌαααΆαααααΌα #${i}</span> | |
| <button disabled class="btn-pop-3d opacity-50 text-slate-400 bg-slate-800 px-3 py-1.5 rounded-lg text-xs flex items-center gap-1.5 cursor-not-allowed"> | |
| <i class="fa-solid fa-spinner animate-spin"></i> αααα αΆα | |
| </button> | |
| </div> | |
| </div> | |
| `; | |
| } | |
| generateBtn.setAttribute('disabled', 'true'); | |
| generateBtn.innerHTML = `<i class="fa-solid fa-spinner animate-spin"></i> αααα»αααααΎαααΆαααααΌα...`; | |
| try { | |
| let basePrompt = "Generate a visually similar, high-quality variation of this image. Preserve the central subject, composition, color palette, and general details to make it look like an authentic duplicate clone."; | |
| if (selectedSimilarity === "medium") { | |
| basePrompt = "Create a modern, creative alternative clone of this image. Keep the main core subject but alter small elements, lighting, or the surrounding background slightly for a unique variation."; | |
| } else if (selectedSimilarity === "creative") { | |
| basePrompt = "Use this image as a structural inspiration to create a brand new, artistically distinct clone. Keep only the basic idea or composition while applying a fresh visual style, dynamic range, and imaginative background changes."; | |
| } | |
| if (extraPrompt) { | |
| basePrompt += ` Additionally, incorporate the following creative direction requested by the user: ${extraPrompt}.`; | |
| } | |
| // Execute clones sequentially for mobile stability | |
| let cloneIndex = 1; | |
| for (let sourceIndex = 0; sourceIndex < uploadedImages.length; sourceIndex++) { | |
| const sourceImage = uploadedImages[sourceIndex]; | |
| for (let sourceCloneIndex = 1; sourceCloneIndex <= countPerSource; sourceCloneIndex++) { | |
| await generateSingleClone(cloneIndex, basePrompt, sourceImage, openRouterApiKey, sourceIndex + 1, sourceCloneIndex); | |
| cloneIndex++; | |
| } | |
| } | |
| if (generatedImagesArray.length > 0) { | |
| downloadAllBtn.classList.remove('hidden'); | |
| if (isIOS) { | |
| showToast("ααααΌαααΆααααααα! α α»α ααΎααΌαααΈαα½ααααΎααααΈ 'αααααΆαα»α' αα iPhoneα", "success"); | |
| } else { | |
| showToast(`ααΆαααααΌαααΆαααααααα ααα½α ${generatedImagesArray.length} ααΌαααΆα!`, "success"); | |
| } | |
| } else { | |
| showToast("ααΆαααααΌαααΌαααΆαααΆααααα αΆ! ααΌααα·αα·ααα Model α¬ API Keyα", "error"); | |
| } | |
| } catch (error) { | |
| console.error(error); | |
| if (isCreditError(error)) { | |
| showAdminContactNotice("credit"); | |
| } | |
| showToast(error.message || "ααΆαααααΌαααΆααααα αΆα ααΌααα·αα·ααα Model α¬ API Keyα", "error"); | |
| } finally { | |
| generateBtn.removeAttribute('disabled'); | |
| generateBtn.innerHTML = `<i class="fa-solid fa-bolt"></i> α αΆααααααΎαααααΌαααΌαααΆα`; | |
| refreshOpenRouterBalance(false); | |
| } | |
| }); | |
| // Singular Clone Processor | |
| async function generateSingleClone(index, prompt, sourceImage, openRouterApiKey, sourceNumber, sourceCloneNumber) { | |
| const card = document.getElementById(`clone-card-${index}`); | |
| try { | |
| const uniquePrompt = prompt + ` [Source image: ${sourceNumber}. Clone variation: ${sourceCloneNumber}. Make this clone variation distinct and unique to index: ${index + Math.random()}]`; | |
| const imageDataUrl = `data:${sourceImage.type};base64,${sourceImage.base64}`; | |
| const payload = { | |
| model: getSelectedOpenRouterModel(), | |
| messages: [{ | |
| role: "user", | |
| content: [ | |
| { | |
| type: "text", | |
| text: uniquePrompt | |
| }, | |
| { | |
| type: "image_url", | |
| image_url: { | |
| url: imageDataUrl | |
| } | |
| } | |
| ] | |
| }], | |
| modalities: ["image", "text"], | |
| image_config: { | |
| aspect_ratio: "1:1" | |
| }, | |
| stream: false | |
| }; | |
| const data = await fetchWithRetry(openRouterEndpoint, { | |
| method: 'POST', | |
| headers: { | |
| 'Authorization': `Bearer ${openRouterApiKey}`, | |
| 'Content-Type': 'application/json', | |
| 'HTTP-Referer': window.location.origin === 'null' ? 'http://localhost' : window.location.origin, | |
| 'X-OpenRouter-Title': openRouterAppTitle | |
| }, | |
| body: JSON.stringify(payload) | |
| }); | |
| const generatedImage = data.choices?.[0]?.message?.images?.[0]; | |
| const imageUrl = generatedImage?.image_url?.url || generatedImage?.imageUrl?.url; | |
| if (!imageUrl) { | |
| throw new Error("OpenRouter returned a response without image data."); | |
| } | |
| const finalImageSrc = imageUrl.startsWith('data:') ? imageUrl : `data:image/png;base64,${imageUrl}`; | |
| generatedImagesArray.push(finalImageSrc); | |
| card.innerHTML = ` | |
| <div onclick="openPreviewModal('${finalImageSrc}', ${index})" class="relative flex-grow flex items-center justify-center bg-slate-950/40 rounded-lg overflow-hidden h-52 group/item cursor-pointer"> | |
| <img src="${finalImageSrc}" alt="Cloned Image ${index}" class="cloned-success-img w-full h-full object-cover"> | |
| <div class="absolute inset-0 bg-slate-950/40 opacity-0 group-hover/item:opacity-100 transition-opacity flex items-center justify-center"> | |
| <span class="bg-indigo-600/90 text-white text-xs px-3 py-1.5 rounded-lg flex items-center gap-1"> | |
| <i class="fa-solid fa-magnifying-glass-plus"></i> ααΎαααΌααα | |
| </span> | |
| </div> | |
| </div> | |
| <div class="mt-4 flex justify-between items-center"> | |
| <span class="text-xs text-indigo-400 bg-indigo-500/10 px-2.5 py-1 rounded-full font-semibold border border-indigo-500/10">ααΌαααΎα #${sourceNumber} β’ ααααΌα #${sourceCloneNumber}</span> | |
| <button onclick="downloadImage('${finalImageSrc}', ${index})" class="btn-pop-3d text-xs text-white bg-indigo-600 hover:bg-indigo-700 px-3 py-1.5 rounded-lg flex items-center gap-1.5 transition-colors"> | |
| <i class="fa-solid fa-download"></i> ααΆααα | |
| </button> | |
| </div> | |
| `; | |
| } catch (error) { | |
| console.error(error); | |
| if (isCreditError(error)) { | |
| showAdminContactNotice("credit"); | |
| } | |
| const shortError = escapeHtml((error.message || "API request failed").slice(0, 120)); | |
| card.innerHTML = ` | |
| <div class="relative flex-grow flex items-center justify-center bg-red-950/20 border border-red-900/30 rounded-lg h-52"> | |
| <div class="text-center p-4"> | |
| <i class="fa-solid fa-triangle-exclamation text-2xl text-red-500 mb-2"></i> | |
| <p class="text-xs text-red-400 font-semibold">αααΆααααααα»αααΆαααααΌα</p> | |
| <p class="text-[9px] text-slate-500 mt-1 break-words">${shortError}</p> | |
| </div> | |
| </div> | |
| <div class="mt-4 flex justify-between items-center"> | |
| <span class="text-xs text-red-500/50 bg-red-500/5 px-2.5 py-1 rounded-full font-semibold border border-red-500/10">ααΌαααΎα #${sourceNumber} β’ ααααΌα #${sourceCloneNumber}</span> | |
| <button disabled class="btn-pop-3d opacity-40 text-slate-500 bg-slate-800 px-3 py-1.5 rounded-lg text-xs cursor-not-allowed"> | |
| <i class="fa-solid fa-ban"></i> αααΆααα | |
| </button> | |
| </div> | |
| `; | |
| } | |
| } | |
| // Master downloader | |
| downloadAllBtn.addEventListener('click', () => { | |
| if (isIOS) { | |
| showToast("αααααΆαα iPhoneα ααΌαα α»α ααΎααΌαααΈαα½αα αα½α ααααααααΎααααΈαααααΆαα»ααααα»αααΌαααααα", "success"); | |
| return; | |
| } | |
| const successImages = document.querySelectorAll('.cloned-success-img'); | |
| successImages.forEach((img, i) => { | |
| setTimeout(() => { | |
| const link = document.createElement('a'); | |
| link.href = img.src; | |
| link.download = `cloned_image_${i + 1}.png`; | |
| document.body.appendChild(link); | |
| link.click(); | |
| link.remove(); | |
| }, i * 300); | |
| }); | |
| showToast("αααα»αααΆαααααΌαααΆαααΆααα’αα...", "success"); | |
| }); | |
| </script> | |
| </body> | |
| </html> | |