| <!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> |