Files
2026-07-13 01:21:13 +07:00

305 lines
19 KiB
HTML

<!DOCTYPE html>
<html lang="en">
<head>
<meta charset="UTF-8">
<meta name="viewport" content="width=device-width, initial-scale=1.0">
<title>Dynamic Dropdowns Node Builder</title>
<script src="https://cdn.tailwindcss.com"></script>
<script src="https://unpkg.com/react@18/umd/react.production.min.js" crossorigin></script>
<script src="https://unpkg.com/react-dom@18/umd/react-dom.production.min.js" crossorigin></script>
<script src="https://unpkg.com/@babel/standalone/babel.min.js"></script>
<script src="https://unpkg.com/lucide@0.344.0/dist/umd/lucide.min.js"></script>
<style>
@import url('https://fonts.googleapis.com/css2?family=Inter:wght@400;500;600&family=JetBrains+Mono&display=swap');
body {
font-family: 'Inter', sans-serif;
background-color: #09090f;
margin: 0;
padding: 0;
user-select: none;
}
.mono {
font-family: 'JetBrains Mono', monospace;
}
</style>
</head>
<body>
<div id="root"></div>
<script type="text/babel">
const { useState, useRef, useCallback, useEffect } = React;
// Custom simple Toast implementation to replace Sonner since we are in a standalone HTML file
const showToast = (message, type = "info") => {
const container = document.getElementById('toast-container');
if (!container) return;
const toast = document.createElement('div');
toast.className = `px-4 py-2.5 rounded-md text-xs font-medium shadow-xl transition-all duration-300 transform translate-y-2 opacity-0 text-white ${
type === 'success' ? 'bg-purple-600 border border-purple-400/20' :
type === 'error' ? 'bg-red-600 border border-red-400/20' : 'bg-[#1a1a28] border border-white/10'
}`;
toast.innerText = message;
container.appendChild(toast);
setTimeout(() => {
toast.classList.remove('translate-y-2', 'opacity-0');
}, 10);
setTimeout(() => {
toast.classList.add('translate-y-2', 'opacity-0');
setTimeout(() => toast.remove(), 300);
}, 3000);
};
const INITIAL_SLOTS = [
{ id: 1, name: "Style", source: "custom", options: ["Studio Cinematic","Anime Flat","Editorial","Raw Documentary","Soft Portrait"], selectedIndex: 0, active: true },
{ id: 2, name: "Lighting", source: "demo", options: ["Soft Natural","Harsh Neon","Golden Hour","Blue Hour","Studio Key"], selectedIndex: 0, active: true },
{ id: 3, name: "Post-Process",source: "demo", options: ["Orange Down","Teal & Orange","Matte Black","Film Grain","Clean"], selectedIndex: 0, active: true },
{ id: 4, name: "Camera", source: "demo", options: ["Studio Cinematic, Tyren Style","35mm f/1.4","Anamorphic","Wide Angle","Macro"], selectedIndex: 0, active: true },
{ id: 5, name: "Composition", source: "custom", options: ["Studio Cinematic","Rule of Thirds","Centered","Dutch Angle","Overhead"], selectedIndex: 0, active: true },
];
let nextId = 20;
// ── Port Row Component ──
function Port({ label, type, color, side }) {
const dotStyle = {
background: color,
[side === "left" ? "left" : "right"]: "-6.5px",
top: "50%",
transform: "translateY(-50%)",
boxShadow: `0 0 6px ${color}88`,
};
return (
<div className={`relative flex items-center h-8 ${side === "right" ? "flex-row-reverse" : ""}`}>
<div className="absolute w-[13px] h-[13px] rounded-full border-2 border-[#0c0c14] shrink-0" style={dotStyle} />
<span className="text-[11.5px] pl-4 mono" style={{ color }}>{label}</span>
<span className="ml-auto pr-4 text-[10px] text-white/30 mono">{type}</span>
</div>
);
}
// ── Slot Card Component ──
function SlotCard({ slot, onSelect, onToggle, open, onOpen, onClose }) {
const value = slot.options[slot.selectedIndex];
return (
<div className="relative" style={{ borderBottom: "1px solid rgba(255,255,255,0.05)", opacity: slot.active ? 1 : 0.38, transition: "opacity 0.2s" }}>
<div className="absolute left-0 top-0 bottom-0 w-[2.5px] rounded-r transition-colors duration-200" style={{ background: slot.active ? "#7c5cf6" : "transparent" }} />
<div className="pl-4 pr-3 pt-[7px] pb-[6px]">
<div className="flex items-center justify-between mb-[2px]">
<span className="text-[10px] tracking-wide font-medium text-white/40">
{slot.name}
{slot.source === "demo" && <span className="text-white/20 ml-1">(Demo)</span>}
{slot.source === "custom" && <span className="text-white/20 ml-1">(Custom)</span>}
{slot.source === "file" && <span className="text-cyan-500/50 ml-1">(.txt)</span>}
</span>
<button onClick={onToggle} className="w-7 h-[14px] rounded-full relative transition-colors duration-200 shrink-0" style={{ background: slot.active ? "#7c5cf6" : "#2e2e44" }}>
<div className="absolute top-[2px] w-[10px] h-[10px] rounded-full bg-white shadow-sm transition-all duration-200" style={{ left: slot.active ? "13px" : "2px" }} />
</button>
</div>
<div className="relative">
<button onClick={(e) => { e.stopPropagation(); open ? onClose() : onOpen(); }} className="w-full flex items-center justify-between group">
<span className="text-[12.5px] text-white/85 truncate leading-tight group-hover:text-white transition-colors text-left" style={{ maxWidth: "calc(100% - 20px)" }}>
{value}
</span>
<i data-lucide="chevron-down" className="w-3.5 h-3.5 text-white/25 transition-transform duration-150" style={{ transform: open ? "rotate(180deg)" : "rotate(0deg)" }}></i>
</button>
{open && (
<div className="absolute top-full left-0 right-0 z-50 rounded-md overflow-hidden mt-1 bg-[#1e1e2e] border border-white/10 shadow-[0_12px_32px_rgba(0,0,0,0.7)]" onClick={e => e.stopPropagation()}>
{slot.options.map((opt, i) => (
<button key={i} onClick={() => { onSelect(i); onClose(); }} className="w-full text-left px-3 py-[7px] text-[11.5px] transition-colors hover:bg-white/5"
style={{
color: i === slot.selectedIndex ? "#a78bfa" : "rgba(255,255,255,0.72)",
background: i === slot.selectedIndex ? "rgba(124,92,246,0.12)" : "transparent",
borderBottom: i < slot.options.length - 1 ? "1px solid rgba(255,255,255,0.04)" : "none",
}}>
{opt}
</button>
))}
</div>
)}
</div>
</div>
</div>
);
}
// ── Main App Loop ──
function App() {
const [slots, setSlots] = useState(INITIAL_SLOTS);
const [openId, setOpenId] = useState(null);
const fileInputRef = useRef(null);
// Trigger lucide icon hydration on layout mount/updates
useEffect(() => {
if (window.lucide) {
window.lucide.createIcons();
}
}, [slots, openId]);
const select = useCallback((id, i) => {
setSlots(prev => prev.map(s => s.id === id ? { ...s, selectedIndex: i } : s));
}, []);
const toggle = useCallback((id) => {
setSlots(prev => prev.map(s => s.id === id ? { ...s, active: !s.active } : s));
}, []);
const addSlot = () => {
const id = nextId++;
setSlots(prev => [
...prev,
{ id, name: `Slot ${prev.length + 1}`, options: ["(empty — load a file)"], selectedIndex: 0, active: true, source: "custom" },
]);
};
const removeSlot = () => {
setSlots(prev => {
if (!prev.length) return prev;
const name = prev[prev.length - 1].name;
showToast(`Removed "${name}"`);
return prev.slice(0, -1);
});
};
const handleFile = (e) => {
const file = e.target.files?.[0];
if (!file) return;
const rawFileName = file.name;
const name = rawFileName.replace(/\.txt$/i, "").replace(/[-_]/g, " ").replace(/\b\w/g, c => c.toUpperCase());
const reader = new FileReader();
reader.onload = ev => {
const textContent = ev.target?.result;
const lines = textContent.split(/\r?\n/).map(l => l.trim()).filter(Boolean);
if (!lines.length) { showToast("File is empty.", "error"); return; }
const id = nextId++;
setSlots(prev => [...prev, { id, name, options: lines, selectedIndex: 0, active: true, source: "file" }]);
// Python PyWebView Asset Pipeline sync hooks
if (window.pywebview?.api?.save_list_asset) {
window.pywebview.api.save_list_asset(rawFileName, textContent);
}
showToast(`Loaded "${name}" — ${lines.length} options`, "success");
};
reader.readAsText(file);
e.target.value = "";
};
const buildCustomNode = () => {
// Call Python engine wrapper to compile the node config list mappings
if (window.pywebview?.api?.build_node) {
window.pywebview.api.build_node(JSON.stringify(slots));
showToast("Custom Node Code generated successfully!", "success");
} else {
showToast("Local workspace compilation execution only available inside Assistant app wrapper.", "error");
}
};
return (
<div className="min-h-screen w-full flex flex-col items-center justify-center overflow-hidden relative select-none" onClick={() => setOpenId(null)}>
{/* Toast Notification Mount Wrapper */}
<div id="toast-container" className="absolute bottom-16 right-5 z-[9999] flex flex-col gap-2 pointer-events-none"></div>
{/* dot background layout grid */}
<div className="absolute inset-0 pointer-events-none" style={{ backgroundImage: "radial-gradient(circle, rgba(255,255,255,0.038) 1px, transparent 1px)", backgroundSize: "28px 28px" }} />
{/* subtle ambient background glow */}
<div className="absolute w-[600px] h-[600px] rounded-full pointer-events-none -translate-x-1/4" style={{ background: "radial-gradient(circle, rgba(50,30,110,0.14) 0%, transparent 65%)" }} />
{/* ── Node Card Wrapper ── */}
<div className="relative z-10 rounded-xl overflow-visible bg-gradient-to-b from-[#1a1a28] to-[#161622]" style={{ width: "340px", boxShadow: "0 0 0 1px rgba(255,255,255,0.07), 0 0 0 3px rgba(80,60,180,0.15), 0 32px 80px rgba(0,0,0,0.75)" }} onClick={e => e.stopPropagation()}>
{/* ── Title Bar ── */}
<div className="flex items-center gap-2 px-3 py-[10px] rounded-t-xl bg-gradient-to-b from-[#1f1f30] to-[#191926]" style={{ borderBottom: "1px solid rgba(255,255,255,0.07)" }}>
<div className="w-5 h-5 rounded-md flex items-center justify-center shrink-0 bg-purple-600/25">
<svg width="11" height="11" viewBox="0 0 11 11" fill="none">
<rect x="0.5" y="0.5" width="4" height="4" rx="1" fill="#a78bfa"/>
<rect x="6.5" y="0.5" width="4" height="4" rx="1" fill="#a78bfa" opacity="0.5"/>
<rect x="0.5" y="6.5" width="4" height="4" rx="1" fill="#a78bfa" opacity="0.5"/>
<rect x="6.5" y="6.5" width="4" height="4" rx="1" fill="#a78bfa" opacity="0.25"/>
</svg>
</div>
<span className="text-white/90 text-[12.5px] font-semibold tracking-tight">Dynamic Dropdowns</span>
<span className="text-white/35 text-[11px] font-normal">(Pro Mode)</span>
</div>
{/* ── Output Pipeline Slots ── */}
<div style={{ borderBottom: "1px solid rgba(255,255,255,0.06)" }}>
<Port label="CLIP" type="CLIP" color="#c084fc" side="left" />
<div style={{ borderTop: "1px solid rgba(255,255,255,0.04)" }} />
<Port label="Input String" type="PROMPT_STRING" color="#c084fc" side="left" />
{/* Simulated right-side ports block wrapper */}
<div className="absolute right-0 top-[51px]" style={{ width: "50%" }}>
<div style={{ height: "32px", position: "relative" }}>
<span className="absolute right-4 top-1/2 -translate-y-1/2 text-[10px] text-[#ffe66d]/50 mono">PROMPT_STRING</span>
<div className="absolute rounded-full border-2 border-[#0c0c14]" style={{ right: "-6.5px", top: "50%", transform: "translateY(-50%)", width: 13, height: 13, background: "#ffe66d", boxShadow: "0 0 6px #ffe66d88" }} />
</div>
<div style={{ height: "32px", position: "relative", borderTop: "1px solid rgba(255,255,255,0.04)" }}>
<span className="absolute right-4 top-1/2 -translate-y-1/2 text-[10px] text-[#f97316]/50 mono">CONDITIONING</span>
<div className="absolute rounded-full border-2 border-[#0c0c14]" style={{ right: "-6.5px", top: "50%", transform: "translateY(-50%)", width: 13, height: 13, background: "#f97316", boxShadow: "0 0 6px #f9731688" }} />
</div>
</div>
</div>
{/* ── Selection Dropdowns List Content ── */}
<div className="max-h-[280px] overflow-y-auto custom-scrollbar">
{slots.length === 0 && (
<div className="text-center py-6 text-[11px] text-white/20 italic">
No slots — add one or load a .txt file
</div>
)}
{slots.map(slot => (
<SlotCard key={slot.id} slot={slot} open={openId === slot.id} onOpen={() => setOpenId(slot.id)} onClose={() => setOpenId(null)} onSelect={i => select(slot.id, i)} onToggle={() => toggle(slot.id)} />
))}
</div>
{/* ── Updated Horizontal Bottom Button Actions Bar (Figma Design Spec Alignment) ── */}
<div className="px-3 py-3 flex flex-row items-center justify-between gap-2 rounded-b-xl w-full" style={{ borderTop: "1px solid rgba(255,255,255,0.06)", background: "rgba(0,0,0,0.2)" }}>
<div className="flex items-center gap-2 flex-1">
<button onClick={addSlot} className="flex items-center justify-center gap-1.5 px-2.5 py-1.5 rounded-md text-[11px] font-medium text-white/75 hover:text-white transition-colors whitespace-nowrap bg-white/5 border border-white/10 flex-1">
<i data-lucide="plus" className="w-3 h-3 text-white/75"></i>
Add Slot
</button>
<button onClick={removeSlot} className="flex items-center justify-center gap-1.5 px-2.5 py-1.5 rounded-md text-[11px] font-medium text-white/75 hover:text-white transition-colors whitespace-nowrap bg-white/5 border border-white/10 flex-1">
<i data-lucide="minus" className="w-3 h-3 text-white/75"></i>
Remove Slot
</button>
</div>
{/* Load dynamic list .txt file element block */}
<button onClick={() => fileInputRef.current?.click()} className="p-1.5 rounded-md text-white/40 hover:text-cyan-400 transition-colors shrink-0 bg-white/5 border border-white/10" title="Load .txt file as new slot">
<i data-lucide="folder-open" className="w-3.5 h-3.5"></i>
</button>
</div>
</div>
{/* Integrated System Build Node Control Trigger Button Group */}
<div className="mt-4 z-10">
<button onClick={buildCustomNode} className="px-6 py-2 bg-purple-600 hover:bg-purple-700 border border-purple-400/20 text-white font-medium text-xs rounded-md shadow-lg transition-all transform active:scale-95">
Build Custom Node Code (__init__.py)
</button>
</div>
<input ref={fileInputRef} type="file" accept=".txt" className="hidden" onChange={handleFile} />
<p className="absolute bottom-4 text-[10px] text-white/20 tracking-wide">
Click a value to open its dropdown · Use the folder icon to load a .txt file
</p>
</div>
);
}
const root = ReactDOM.createRoot(document.getElementById('root'));
root.render(<App />);
</script>
</body>
</html>