|
|
<!DOCTYPE html>
|
|
|
<html lang="zh-CN">
|
|
|
|
|
|
<head>
|
|
|
<meta charset="UTF-8">
|
|
|
<meta name="viewport" content="width=device-width, initial-scale=1.0">
|
|
|
<link rel="icon" href="/static/logo.png" type="image/png">
|
|
|
<title>Flux Klein | 极简一体化终端</title>
|
|
|
<script src="https://cdn.tailwindcss.com"></script>
|
|
|
<script src="https://unpkg.com/lucide@latest"></script>
|
|
|
<style>
|
|
|
@import url('https://fonts.googleapis.com/css2?family=Inter:wght@300;400;500;600;800&display=swap');
|
|
|
|
|
|
:root {
|
|
|
--accent: #111827;
|
|
|
--bg: #f9fafb;
|
|
|
--card: #ffffff;
|
|
|
--easing: cubic-bezier(0.4, 0, 0.2, 1);
|
|
|
}
|
|
|
|
|
|
|
|
|
*::-webkit-scrollbar {
|
|
|
width: 10px !important;
|
|
|
height: 10px !important;
|
|
|
background: transparent !important;
|
|
|
}
|
|
|
|
|
|
*::-webkit-scrollbar-track {
|
|
|
background: transparent !important;
|
|
|
border: none !important;
|
|
|
}
|
|
|
|
|
|
*::-webkit-scrollbar-thumb {
|
|
|
background-color: #d8d8d8 !important;
|
|
|
border: 3px solid transparent !important;
|
|
|
border-right-width: 5px !important;
|
|
|
|
|
|
background-clip: padding-box !important;
|
|
|
border-radius: 10px !important;
|
|
|
}
|
|
|
|
|
|
*::-webkit-scrollbar-thumb:hover {
|
|
|
background-color: #c0c0c0 !important;
|
|
|
}
|
|
|
|
|
|
*::-webkit-scrollbar-corner {
|
|
|
background: transparent !important;
|
|
|
}
|
|
|
|
|
|
* {
|
|
|
scrollbar-width: thin !important;
|
|
|
scrollbar-color: #d8d8d8 transparent !important;
|
|
|
}
|
|
|
|
|
|
body {
|
|
|
background-color: var(--bg);
|
|
|
font-family: 'Inter', -apple-system, sans-serif;
|
|
|
color: var(--accent);
|
|
|
-webkit-font-smoothing: antialiased;
|
|
|
}
|
|
|
|
|
|
.container-box {
|
|
|
max-width: 1280px;
|
|
|
margin: 0 auto;
|
|
|
padding: 0 40px;
|
|
|
margin-top: 50px;
|
|
|
}
|
|
|
|
|
|
|
|
|
.nano-input {
|
|
|
background: var(--card);
|
|
|
border: 1px solid #eef0f2;
|
|
|
transition: all 0.3s var(--easing);
|
|
|
}
|
|
|
|
|
|
.nano-input:focus {
|
|
|
border-color: #000;
|
|
|
box-shadow: 0 0 0 1px #000;
|
|
|
}
|
|
|
|
|
|
|
|
|
.upload-item {
|
|
|
background: var(--card);
|
|
|
border: 1px dashed #e2e8f0;
|
|
|
transition: all 0.4s var(--easing);
|
|
|
position: relative;
|
|
|
overflow: hidden;
|
|
|
}
|
|
|
|
|
|
.upload-item:hover {
|
|
|
border-color: #000;
|
|
|
background: #fff;
|
|
|
transform: translateY(-2px);
|
|
|
}
|
|
|
|
|
|
.upload-item.drag-over {
|
|
|
border-style: solid;
|
|
|
border-color: #000;
|
|
|
box-shadow: 0 10px 20px rgba(0, 0, 0, 0.05);
|
|
|
}
|
|
|
|
|
|
.preview-img {
|
|
|
position: absolute;
|
|
|
inset: 0;
|
|
|
width: 100%;
|
|
|
height: 100%;
|
|
|
object-fit: cover;
|
|
|
animation: fadeIn 0.5s var(--easing);
|
|
|
}
|
|
|
|
|
|
|
|
|
.glass-btn {
|
|
|
background: #111827;
|
|
|
transition: all 0.3s var(--easing);
|
|
|
}
|
|
|
|
|
|
.glass-btn:hover {
|
|
|
background: #000;
|
|
|
transform: translateY(-1px);
|
|
|
box-shadow: 0 12px 24px rgba(0, 0, 0, 0.1);
|
|
|
}
|
|
|
|
|
|
.glass-btn:active {
|
|
|
transform: scale(0.98);
|
|
|
}
|
|
|
|
|
|
|
|
|
.result-frame {
|
|
|
background: #ffffff;
|
|
|
border-radius: 32px;
|
|
|
border: 1px solid #f1f5f9;
|
|
|
box-shadow: 0 2px 15px rgba(0, 0, 0, 0.02);
|
|
|
}
|
|
|
|
|
|
|
|
|
.masonry-grid {
|
|
|
display: grid;
|
|
|
grid-template-columns: repeat(2, 1fr);
|
|
|
gap: 20px;
|
|
|
}
|
|
|
|
|
|
@media (min-width: 768px) {
|
|
|
.masonry-grid {
|
|
|
grid-template-columns: repeat(4, 1fr);
|
|
|
}
|
|
|
}
|
|
|
|
|
|
.masonry-item {
|
|
|
aspect-ratio: 1 / 1;
|
|
|
border-radius: 24px;
|
|
|
overflow: hidden;
|
|
|
background: #fff;
|
|
|
border: 1px solid #f1f5f9;
|
|
|
transition: all 0.5s var(--easing);
|
|
|
}
|
|
|
|
|
|
.masonry-item:hover {
|
|
|
transform: translateY(-6px);
|
|
|
box-shadow: 0 20px 40px rgba(0, 0, 0, 0.08);
|
|
|
}
|
|
|
|
|
|
@keyframes fadeIn {
|
|
|
from {
|
|
|
opacity: 0;
|
|
|
}
|
|
|
|
|
|
to {
|
|
|
opacity: 1;
|
|
|
}
|
|
|
}
|
|
|
</style>
|
|
|
</head>
|
|
|
|
|
|
<body class="antialiased transition-colors duration-300">
|
|
|
|
|
|
<div class="container-box min-h-screen flex flex-col">
|
|
|
<header class="flex justify-between items-end mb-16">
|
|
|
<div class="space-y-1">
|
|
|
<h1 class="text-4xl font-extrabold tracking-tighter italic">FLUX KLEIN</h1>
|
|
|
<p class="text-[10px] font-bold uppercase tracking-[0.4em] text-gray-400">Next-Gen Generative Interface
|
|
|
</p>
|
|
|
</div>
|
|
|
<nav class="hidden md:flex gap-8 text-[11px] font-bold uppercase tracking-widest text-gray-400">
|
|
|
<span class="text-black border-b-2 border-black pb-1">Create</span>
|
|
|
</nav>
|
|
|
</header>
|
|
|
|
|
|
<main class="grid grid-cols-1 lg:grid-cols-12 gap-12">
|
|
|
<div class="lg:col-span-5 space-y-10">
|
|
|
<section class="space-y-4">
|
|
|
<div class="flex items-center gap-2 text-gray-400">
|
|
|
<i data-lucide="terminal" class="w-3.5 h-3.5"></i>
|
|
|
<span class="text-[10px] font-black uppercase tracking-widest">Input Prompt</span>
|
|
|
</div>
|
|
|
<textarea id="promptInput" rows="5"
|
|
|
class="nano-input w-full p-6 rounded-3xl text-sm outline-none resize-none placeholder-gray-300"
|
|
|
placeholder="Describe your vision here..."></textarea>
|
|
|
</section>
|
|
|
|
|
|
<section class="space-y-4">
|
|
|
<div class="flex items-center gap-2 text-gray-400">
|
|
|
<i data-lucide="image" class="w-3.5 h-3.5"></i>
|
|
|
<span class="text-[10px] font-black uppercase tracking-widest">Reference Layers</span>
|
|
|
</div>
|
|
|
<div class="grid grid-cols-3 gap-4">
|
|
|
<div id="drop-zone-1" onclick="document.getElementById('file1').click()"
|
|
|
class="upload-item group aspect-square rounded-2xl flex flex-col items-center justify-center cursor-pointer">
|
|
|
<input type="file" id="file1" class="hidden" onchange="handleFile(this.files[0], 1)">
|
|
|
<i data-lucide="plus"
|
|
|
class="w-5 h-5 text-gray-300 group-hover:text-black transition-colors"></i>
|
|
|
<span class="text-[9px] mt-2 font-bold text-gray-400 uppercase">Main</span>
|
|
|
<img id="prev1" class="preview-img hidden">
|
|
|
<button id="del1" onclick="clearSlot(1, event)"
|
|
|
class="hidden absolute top-2 right-2 w-6 h-6 bg-white/90 rounded-full shadow-sm z-10 flex items-center justify-center text-xs">×</button>
|
|
|
</div>
|
|
|
<div id="drop-zone-2" onclick="document.getElementById('file2').click()"
|
|
|
class="upload-item group aspect-square rounded-2xl flex flex-col items-center justify-center cursor-pointer">
|
|
|
<input type="file" id="file2" class="hidden" onchange="handleFile(this.files[0], 2)">
|
|
|
<i data-lucide="plus"
|
|
|
class="w-5 h-5 text-gray-300 group-hover:text-black transition-colors"></i>
|
|
|
<span class="text-[9px] mt-2 font-bold text-gray-400 uppercase">Aux A</span>
|
|
|
<img id="prev2" class="preview-img hidden">
|
|
|
<button id="del2" onclick="clearSlot(2, event)"
|
|
|
class="hidden absolute top-2 right-2 w-6 h-6 bg-white/90 rounded-full shadow-sm z-10 flex items-center justify-center text-xs">×</button>
|
|
|
</div>
|
|
|
<div id="drop-zone-3" onclick="document.getElementById('file3').click()"
|
|
|
class="upload-item group aspect-square rounded-2xl flex flex-col items-center justify-center cursor-pointer">
|
|
|
<input type="file" id="file3" class="hidden" onchange="handleFile(this.files[0], 3)">
|
|
|
<i data-lucide="plus"
|
|
|
class="w-5 h-5 text-gray-300 group-hover:text-black transition-colors"></i>
|
|
|
<span class="text-[9px] mt-2 font-bold text-gray-400 uppercase">Aux B</span>
|
|
|
<img id="prev3" class="preview-img hidden">
|
|
|
<button id="del3" onclick="clearSlot(3, event)"
|
|
|
class="hidden absolute top-2 right-2 w-6 h-6 bg-white/90 rounded-full shadow-sm z-10 flex items-center justify-center text-xs">×</button>
|
|
|
</div>
|
|
|
</div>
|
|
|
</section>
|
|
|
|
|
|
<button id="genBtn" onclick="submitWorkflow()"
|
|
|
class="glass-btn w-full py-5 text-white rounded-xl font-bold flex items-center justify-center gap-3 shadow-lg">
|
|
|
<i data-lucide="zap" id="btnIcon" class="w-4 h-4 text-yellow-400"></i>
|
|
|
<span id="btnText" class="tracking-[0.3em] text-[11px] uppercase">Execute Synthesis</span>
|
|
|
</button>
|
|
|
</div>
|
|
|
|
|
|
<div class="lg:col-span-7">
|
|
|
<div id="resultBox"
|
|
|
class="result-frame min-h-[500px] lg:h-full flex items-center justify-center relative overflow-hidden group">
|
|
|
<div id="placeholder" class="text-center space-y-4 opacity-20">
|
|
|
<i data-lucide="layout" class="w-12 h-12 mx-auto stroke-[1px]"></i>
|
|
|
<p class="text-[10px] font-black tracking-[0.5em] uppercase">Canvas Ready</p>
|
|
|
</div>
|
|
|
|
|
|
<div id="loader" class="hidden text-center space-y-4">
|
|
|
<div
|
|
|
class="w-8 h-8 border-2 border-black border-t-transparent rounded-full animate-spin mx-auto">
|
|
|
</div>
|
|
|
<p class="text-[10px] font-black tracking-[0.5em] uppercase">Synthesizing</p>
|
|
|
</div>
|
|
|
|
|
|
<img id="outputImg"
|
|
|
class="hidden w-full h-full object-contain p-8 cursor-zoom-in transition-all duration-700"
|
|
|
onclick="zoomImage()">
|
|
|
|
|
|
<a id="downloadBtn" href="#" download
|
|
|
class="hidden absolute top-8 right-8 w-12 h-12 bg-white/90 backdrop-blur-md shadow-2xl rounded-2xl flex items-center justify-center hover:bg-black hover:text-white active:scale-95 transition-all">
|
|
|
<i data-lucide="download" class="w-4 h-4"></i>
|
|
|
</a>
|
|
|
</div>
|
|
|
</div>
|
|
|
</main>
|
|
|
|
|
|
<section class="mt-32">
|
|
|
<div class="flex items-center gap-6 mb-12">
|
|
|
<h2 class="text-[11px] font-black uppercase tracking-[0.5em]">Archives</h2>
|
|
|
<div class="h-px flex-1 bg-gray-100"></div>
|
|
|
</div>
|
|
|
<div id="masonry" class="masonry-grid"></div>
|
|
|
<div id="loadMoreTrigger"
|
|
|
class="py-20 text-center text-gray-300 text-[10px] font-bold uppercase tracking-widest cursor-pointer hover:text-black transition-colors">
|
|
|
Load More Archive
|
|
|
</div>
|
|
|
</section>
|
|
|
</div>
|
|
|
|
|
|
<div id="lightbox" onclick="handleOutsideClick(event)"
|
|
|
class="hidden fixed inset-0 z-50 flex items-center justify-center p-6 bg-white/95 backdrop-blur-3xl">
|
|
|
<div class="max-w-6xl w-full flex flex-col items-center relative">
|
|
|
|
|
|
<div class="relative w-full flex justify-center mb-8">
|
|
|
<div id="compareContainer"
|
|
|
class="hidden relative w-full h-[75vh] rounded-[2.5rem] overflow-hidden shadow-2xl bg-[#fafafa]">
|
|
|
<img id="compareGenerated" class="absolute inset-0 w-full h-full object-contain">
|
|
|
<div id="compareOriginalWrapper"
|
|
|
class="absolute inset-0 w-full h-full overflow-hidden border-r-2 border-white/50">
|
|
|
<img id="compareOriginal" class="absolute inset-0 w-full h-full object-contain">
|
|
|
</div>
|
|
|
<div id="compareSlider" class="absolute inset-y-0 left-1/2 w-0.5 bg-white z-20 cursor-ew-resize">
|
|
|
<div
|
|
|
class="absolute top-1/2 left-1/2 -translate-x-1/2 -translate-y-1/2 w-10 h-10 bg-white shadow-2xl rounded-full flex items-center justify-center border border-gray-100">
|
|
|
<i data-lucide="move-horizontal" class="w-4 h-4 text-black"></i>
|
|
|
</div>
|
|
|
</div>
|
|
|
</div>
|
|
|
|
|
|
<img id="lightboxImg" src="" class="hidden max-h-[75vh] rounded-[2.5rem] shadow-2xl object-contain">
|
|
|
|
|
|
<div id="lightboxRes"
|
|
|
class="absolute top-6 left-6 bg-black/30 backdrop-blur-md border border-white/20 text-white px-3 py-1.5 rounded-full text-[10px] font-medium tracking-wider opacity-0 transition-opacity duration-300 pointer-events-none z-30">
|
|
|
</div>
|
|
|
|
|
|
<button onclick="downloadLightboxImage()"
|
|
|
class="absolute top-6 right-6 bg-black text-white w-12 h-12 rounded-2xl flex items-center justify-center shadow-2xl z-30 hover:scale-105 transition-transform">
|
|
|
<i data-lucide="download" class="w-5 h-5"></i>
|
|
|
</button>
|
|
|
</div>
|
|
|
|
|
|
<div
|
|
|
class="w-full bg-white border border-gray-100 rounded-[2rem] p-8 shadow-sm flex justify-between items-center gap-8">
|
|
|
<div class="flex-1">
|
|
|
<span class="text-[9px] font-black text-gray-300 uppercase tracking-widest block mb-2">Prompt
|
|
|
Execution</span>
|
|
|
<p id="lightboxPrompt" class="text-gray-700 text-sm leading-relaxed"></p>
|
|
|
</div>
|
|
|
<button id="sameStyleBtn" onclick="applySameStyle()"
|
|
|
class="hidden whitespace-nowrap bg-black text-white px-8 py-3.5 rounded-2xl text-[10px] font-black uppercase tracking-widest hover:bg-gray-800 transition-all active:scale-95 flex items-center gap-2">
|
|
|
<i data-lucide="copy" class="w-4 h-4"></i> Replicate
|
|
|
</button>
|
|
|
</div>
|
|
|
|
|
|
<button onclick="closeLightbox()"
|
|
|
class="absolute -top-12 -right-12 p-4 text-gray-400 hover:text-black transition-colors">
|
|
|
<i data-lucide="x" class="w-8 h-8"></i>
|
|
|
</button>
|
|
|
</div>
|
|
|
</div>
|
|
|
|
|
|
<script>
|
|
|
lucide.createIcons();
|
|
|
function generateUUID() {
|
|
|
if (typeof crypto !== 'undefined' && crypto.randomUUID) {
|
|
|
try { return crypto.randomUUID(); } catch (e) { }
|
|
|
}
|
|
|
return 'xxxxxxxx-xxxx-4xxx-yxxx-xxxxxxxxxxxx'.replace(/[xy]/g, function (c) {
|
|
|
var r = Math.random() * 16 | 0, v = c == 'x' ? r : (r & 0x3 | 0x8);
|
|
|
return v.toString(16);
|
|
|
});
|
|
|
}
|
|
|
const CLIENT_ID_KEY = "client_id";
|
|
|
let CLIENT_ID = localStorage.getItem(CLIENT_ID_KEY) || generateUUID();
|
|
|
localStorage.setItem(CLIENT_ID_KEY, CLIENT_ID);
|
|
|
|
|
|
let uploadedNames = { 1: "", 2: "", 3: "" };
|
|
|
let allHistory = [];
|
|
|
let currentResult = null;
|
|
|
let currentLightboxData = null;
|
|
|
let currentIndex = 0;
|
|
|
const PAGE_SIZE = 24;
|
|
|
let isLoading = false;
|
|
|
|
|
|
|
|
|
let hoveredSlot = null;
|
|
|
[1, 2, 3].forEach(id => {
|
|
|
const zone = document.getElementById(`drop-zone-${id}`);
|
|
|
if (!zone) return;
|
|
|
zone.ondragover = (e) => { e.preventDefault(); zone.classList.add('drag-over'); };
|
|
|
zone.ondragleave = () => { zone.classList.remove('drag-over'); };
|
|
|
zone.ondrop = (e) => { e.preventDefault(); zone.classList.remove('drag-over'); handleFile(e.dataTransfer.files[0], id); };
|
|
|
|
|
|
|
|
|
zone.addEventListener('mouseenter', () => hoveredSlot = id);
|
|
|
zone.addEventListener('mouseleave', () => { if (hoveredSlot === id) hoveredSlot = null; });
|
|
|
});
|
|
|
|
|
|
window.addEventListener('paste', (e) => {
|
|
|
if (!hoveredSlot) return;
|
|
|
const items = (e.clipboardData || e.originalEvent.clipboardData).items;
|
|
|
for (let item of items) {
|
|
|
if (item.kind === 'file' && item.type.startsWith('image/')) {
|
|
|
const file = item.getAsFile();
|
|
|
handleFile(file, hoveredSlot);
|
|
|
break;
|
|
|
}
|
|
|
}
|
|
|
});
|
|
|
|
|
|
async function handleFile(file, index) {
|
|
|
if (!file) return;
|
|
|
const reader = new FileReader();
|
|
|
reader.onload = (e) => {
|
|
|
const prev = document.getElementById(`prev${index}`);
|
|
|
prev.src = e.target.result;
|
|
|
prev.classList.remove('hidden');
|
|
|
document.getElementById(`del${index}`).classList.remove('hidden');
|
|
|
};
|
|
|
reader.readAsDataURL(file);
|
|
|
|
|
|
const formData = new FormData();
|
|
|
formData.append('files', file);
|
|
|
try {
|
|
|
const res = await fetch('/api/upload', { method: 'POST', body: formData });
|
|
|
const data = await res.json();
|
|
|
uploadedNames[index] = data.files[0].comfy_name;
|
|
|
} catch (e) { uploadedNames[index] = file.name; }
|
|
|
}
|
|
|
|
|
|
function clearSlot(index, ev) {
|
|
|
if (ev) ev.stopPropagation();
|
|
|
const prev = document.getElementById(`prev${index}`);
|
|
|
prev.src = ""; prev.classList.add("hidden");
|
|
|
document.getElementById(`del${index}`).classList.add("hidden");
|
|
|
uploadedNames[index] = "";
|
|
|
}
|
|
|
|
|
|
async function submitWorkflow() {
|
|
|
if (!uploadedNames[1]) { alert("Please upload Main Image (Slot 1)"); return; }
|
|
|
const btn = document.getElementById('genBtn');
|
|
|
const loader = document.getElementById('loader');
|
|
|
const placeholder = document.getElementById('placeholder');
|
|
|
const outputImg = document.getElementById('outputImg');
|
|
|
const downloadBtn = document.getElementById('downloadBtn');
|
|
|
|
|
|
btn.disabled = true;
|
|
|
btn.style.backgroundColor = '#333';
|
|
|
btn.innerHTML = `<i data-lucide="zap" class="w-4 h-4 text-yellow-400 fill-yellow-400 animate-pulse"></i><span class="tracking-[0.3em] text-[11px] uppercase">Synthesizing...</span>`;
|
|
|
lucide.createIcons();
|
|
|
|
|
|
placeholder.classList.add('hidden');
|
|
|
|
|
|
const payload = {
|
|
|
prompt: document.getElementById('promptInput').value,
|
|
|
workflow_json: "Flux2-Klein.json",
|
|
|
type: "klein",
|
|
|
params: {
|
|
|
"168": { "text": document.getElementById('promptInput').value },
|
|
|
"158": { "noise_seed": Math.floor(Math.random() * 1000000) },
|
|
|
"278": { "image": uploadedNames[1] },
|
|
|
"270": { "image": uploadedNames[2] || "" },
|
|
|
"292": { "image": uploadedNames[3] || "" },
|
|
|
"313": { "value": uploadedNames[2] !== "" },
|
|
|
"314": { "value": uploadedNames[3] !== "" }
|
|
|
}
|
|
|
};
|
|
|
|
|
|
try {
|
|
|
const response = await fetch('/api/generate', {
|
|
|
method: 'POST',
|
|
|
headers: { 'Content-Type': 'application/json' },
|
|
|
body: JSON.stringify({ ...payload, client_id: CLIENT_ID })
|
|
|
});
|
|
|
const result = await response.json();
|
|
|
if (result.images?.[0]) {
|
|
|
currentResult = result;
|
|
|
outputImg.src = result.images[0];
|
|
|
outputImg.classList.remove('hidden');
|
|
|
downloadBtn.classList.remove('hidden');
|
|
|
downloadBtn.href = result.images[0];
|
|
|
renderImageCard(result, true);
|
|
|
}
|
|
|
} catch (err) {
|
|
|
alert("Generation failed");
|
|
|
placeholder.classList.remove('hidden');
|
|
|
} finally {
|
|
|
loader.classList.add('hidden');
|
|
|
btn.disabled = false;
|
|
|
btn.style.backgroundColor = '';
|
|
|
btn.innerHTML = `<i data-lucide="zap" class="w-4 h-4 text-yellow-400"></i><span class="tracking-[0.3em] text-[11px] uppercase">Execute Synthesis</span>`;
|
|
|
lucide.createIcons();
|
|
|
}
|
|
|
}
|
|
|
|
|
|
|
|
|
function initCompareSlider() {
|
|
|
const container = document.getElementById('compareContainer');
|
|
|
const wrapper = document.getElementById('compareOriginalWrapper');
|
|
|
const slider = document.getElementById('compareSlider');
|
|
|
let isDragging = false;
|
|
|
|
|
|
const updateSlider = (clientX) => {
|
|
|
const rect = container.getBoundingClientRect();
|
|
|
let x = clientX - rect.left;
|
|
|
let percent = (x / rect.width) * 100;
|
|
|
percent = Math.max(0, Math.min(100, percent));
|
|
|
wrapper.style.clipPath = `inset(0 ${100 - percent}% 0 0)`;
|
|
|
slider.style.left = `${percent}%`;
|
|
|
};
|
|
|
|
|
|
const start = (e) => { isDragging = true; e.preventDefault(); };
|
|
|
const end = () => isDragging = false;
|
|
|
const move = (e) => {
|
|
|
if (!isDragging) return;
|
|
|
const clientX = e.touches ? e.touches[0].clientX : e.clientX;
|
|
|
updateSlider(clientX);
|
|
|
};
|
|
|
|
|
|
container.addEventListener('mousedown', (e) => { if (e.target === slider) return; updateSlider(e.clientX); start(e); });
|
|
|
slider.addEventListener('mousedown', start);
|
|
|
window.addEventListener('mouseup', end);
|
|
|
window.addEventListener('mousemove', move);
|
|
|
slider.addEventListener('touchstart', start, { passive: false });
|
|
|
window.addEventListener('touchend', end);
|
|
|
window.addEventListener('touchmove', move, { passive: false });
|
|
|
}
|
|
|
initCompareSlider();
|
|
|
|
|
|
function openLightbox(dataOrUrl) {
|
|
|
const lb = document.getElementById('lightbox');
|
|
|
const img = document.getElementById('lightboxImg');
|
|
|
const comp = document.getElementById('compareContainer');
|
|
|
const promptEl = document.getElementById('lightboxPrompt');
|
|
|
const sameStyleBtn = document.getElementById('sameStyleBtn');
|
|
|
const resPill = document.getElementById('lightboxRes');
|
|
|
|
|
|
let data = (typeof dataOrUrl === 'string') ? { images: [dataOrUrl] } : dataOrUrl;
|
|
|
currentLightboxData = data;
|
|
|
promptEl.textContent = data.prompt || "No prompt metadata found";
|
|
|
|
|
|
resPill.style.opacity = '0';
|
|
|
const updateRes = (target) => {
|
|
|
if (target.naturalWidth) {
|
|
|
resPill.innerText = `${target.naturalWidth} x ${target.naturalHeight}`;
|
|
|
resPill.style.opacity = '1';
|
|
|
}
|
|
|
};
|
|
|
|
|
|
if (data.params?.["278"]?.image) {
|
|
|
img.classList.add('hidden');
|
|
|
comp.classList.remove('hidden');
|
|
|
const genImg = document.getElementById('compareGenerated');
|
|
|
genImg.src = data.images[0];
|
|
|
document.getElementById('compareOriginal').src = `/api/view?filename=${encodeURIComponent(data.params["278"].image)}&type=input`;
|
|
|
document.getElementById('compareOriginalWrapper').style.clipPath = 'inset(0 50% 0 0)';
|
|
|
document.getElementById('compareSlider').style.left = '50%';
|
|
|
|
|
|
genImg.onload = () => updateRes(genImg);
|
|
|
if (genImg.complete) updateRes(genImg);
|
|
|
} else {
|
|
|
comp.classList.add('hidden');
|
|
|
img.classList.remove('hidden');
|
|
|
img.src = data.images[0];
|
|
|
|
|
|
img.onload = () => updateRes(img);
|
|
|
if (img.complete) updateRes(img);
|
|
|
}
|
|
|
|
|
|
sameStyleBtn.classList.toggle('hidden', !data.params);
|
|
|
lb.classList.replace('hidden', 'flex');
|
|
|
document.body.style.overflow = 'hidden';
|
|
|
}
|
|
|
|
|
|
function closeLightbox() {
|
|
|
document.getElementById('lightbox').classList.replace('flex', 'hidden');
|
|
|
document.body.style.overflow = 'auto';
|
|
|
}
|
|
|
|
|
|
function handleOutsideClick(e) { if (e.target.id === 'lightbox') closeLightbox(); }
|
|
|
|
|
|
function renderImageCard(data, isNew = false) {
|
|
|
const masonry = document.getElementById('masonry');
|
|
|
if (document.getElementById(`history-${data.timestamp}`)) return;
|
|
|
|
|
|
const card = document.createElement('div');
|
|
|
card.id = `history-${data.timestamp}`;
|
|
|
card.className = 'masonry-item group relative cursor-zoom-in';
|
|
|
card.onclick = () => openLightbox(data);
|
|
|
|
|
|
card.innerHTML = `
|
|
|
<img src="${data.images[0]}" class="w-full h-full object-cover block transform group-hover:scale-105 transition-transform duration-1000" loading="lazy">
|
|
|
<button onclick="deleteHistoryItem('${data.timestamp}', event)" class="absolute top-4 right-4 text-white hover:text-red-400 opacity-0 group-hover:opacity-100 transition-opacity z-10">
|
|
|
<i data-lucide="trash-2" class="w-4 h-4"></i>
|
|
|
</button>
|
|
|
<div class="absolute inset-0 bg-gradient-to-t from-black/80 via-transparent to-transparent opacity-0 group-hover:opacity-100 transition-all p-6 flex flex-col justify-end">
|
|
|
<p class="text-white text-[10px] font-medium line-clamp-2 uppercase tracking-wider">${data.prompt || "Klein Archive"}</p>
|
|
|
</div>
|
|
|
`;
|
|
|
isNew ? masonry.prepend(card) : masonry.appendChild(card);
|
|
|
lucide.createIcons();
|
|
|
}
|
|
|
|
|
|
async function applySameStyle() {
|
|
|
if (!currentLightboxData?.params) return;
|
|
|
document.getElementById('promptInput').value = currentLightboxData.prompt || "";
|
|
|
const params = currentLightboxData.params;
|
|
|
|
|
|
const setSlot = (slotId, nodeId) => {
|
|
|
if (params[nodeId]?.image) {
|
|
|
const fname = params[nodeId].image;
|
|
|
uploadedNames[slotId] = fname;
|
|
|
const prev = document.getElementById(`prev${slotId}`);
|
|
|
prev.src = `/api/view?filename=${encodeURIComponent(fname)}&type=input`;
|
|
|
prev.classList.remove('hidden');
|
|
|
document.getElementById(`del${slotId}`).classList.remove('hidden');
|
|
|
} else { clearSlot(slotId); }
|
|
|
};
|
|
|
|
|
|
setSlot(1, "278"); setSlot(2, "270"); setSlot(3, "292");
|
|
|
closeLightbox();
|
|
|
window.scrollTo({ top: 0, behavior: 'smooth' });
|
|
|
}
|
|
|
|
|
|
async function loadHistory(page = 0) {
|
|
|
if (isLoading) return;
|
|
|
const loader = document.getElementById('loadMoreTrigger');
|
|
|
|
|
|
try {
|
|
|
isLoading = true;
|
|
|
if (page === 0) {
|
|
|
loader.classList.remove('hidden');
|
|
|
loader.innerText = "Loading Archives...";
|
|
|
|
|
|
const res = await fetch('/api/history?type=klein');
|
|
|
allHistory = await res.json();
|
|
|
document.getElementById('masonry').innerHTML = '';
|
|
|
currentIndex = 0;
|
|
|
} else {
|
|
|
loader.innerText = "Loading...";
|
|
|
await new Promise(r => setTimeout(r, 400));
|
|
|
}
|
|
|
|
|
|
const nextData = allHistory.slice(currentIndex, currentIndex + PAGE_SIZE);
|
|
|
nextData.forEach(item => renderImageCard(item));
|
|
|
currentIndex += nextData.length;
|
|
|
|
|
|
if (currentIndex >= allHistory.length) {
|
|
|
loader.classList.add('hidden');
|
|
|
} else {
|
|
|
loader.classList.remove('hidden');
|
|
|
loader.innerText = "Load More Archive";
|
|
|
}
|
|
|
} catch (e) {
|
|
|
console.error(e);
|
|
|
loader.textContent = "Error loading history";
|
|
|
} finally {
|
|
|
isLoading = false;
|
|
|
}
|
|
|
}
|
|
|
|
|
|
function zoomImage() {
|
|
|
if (currentResult) openLightbox(currentResult);
|
|
|
}
|
|
|
|
|
|
function downloadLightboxImage() {
|
|
|
const url = currentLightboxData?.images[0];
|
|
|
if (!url) return;
|
|
|
const link = document.createElement('a');
|
|
|
link.href = url;
|
|
|
link.download = `Klein-${Date.now()}.png`;
|
|
|
link.click();
|
|
|
}
|
|
|
|
|
|
async function deleteHistoryItem(ts, ev) {
|
|
|
ev.stopPropagation();
|
|
|
if (!confirm("Delete this archive?")) return;
|
|
|
try {
|
|
|
const res = await fetch('/api/history/delete', {
|
|
|
method: 'POST',
|
|
|
headers: { 'Content-Type': 'application/json' },
|
|
|
body: JSON.stringify({ timestamp: ts })
|
|
|
});
|
|
|
if ((await res.json()).success) {
|
|
|
document.getElementById(`history-${ts}`).remove();
|
|
|
}
|
|
|
} catch (e) { alert("Delete failed"); }
|
|
|
}
|
|
|
|
|
|
|
|
|
const observer = new IntersectionObserver((entries) => {
|
|
|
if (entries[0].isIntersecting && !isLoading && currentIndex < allHistory.length) {
|
|
|
loadHistory(1);
|
|
|
}
|
|
|
}, { threshold: 0.1 });
|
|
|
|
|
|
window.onload = () => {
|
|
|
loadHistory(0).then(() => {
|
|
|
const trigger = document.getElementById('loadMoreTrigger');
|
|
|
if (trigger) {
|
|
|
observer.observe(trigger);
|
|
|
trigger.onclick = () => loadHistory(1);
|
|
|
}
|
|
|
});
|
|
|
};
|
|
|
</script>
|
|
|
</body>
|
|
|
|
|
|
</html> |