new
<!DOCTYPE html>
<html lang="es">
<head>
<meta charset="UTF-8">
<meta name="viewport" content="width=device-width, initial-scale=1.0">
<title>FlowMind — AI Workspace</title>
<link href="https://fonts.googleapis.com/css2?family=JetBrains+Mono:wght@300;400;500;600;700&family=DM+Sans:wght@400;500;600;700&display=swap" rel="stylesheet">
<style>
:root {
--bg: #08080d;
--surface: #101018;
--surface2: #16161f;
--border: #1e1e2e;
--accent: #6c5ce7;
--accent2: #4fc3f7;
--accentG: rgba(108,92,231,0.12);
--text: #e4e2ee;
--muted: #7e7c92;
--dim: #4a4860;
--red: #ff6b6b;
--green: #00d2a0;
--orange: #f5a623;
--blue: #4285f4;
--font: 'DM Sans', sans-serif;
--mono: 'JetBrains Mono', monospace;
}
* { margin:0; padding:0; box-sizing:border-box; }
body { background:var(--bg); color:var(--text); font-family:var(--font); overflow:hidden; width:100vw; height:100vh; }
::-webkit-scrollbar { width:4px; }
::-webkit-scrollbar-track { background:transparent; }
::-webkit-scrollbar-thumb { background:var(--border); border-radius:4px; }
@keyframes pulse { 0%,100%{opacity:.3;transform:scale(.8)} 50%{opacity:1;transform:scale(1.2)} }
@keyframes fadeIn { from{opacity:0;transform:translateY(12px)} to{opacity:1;transform:translateY(0)} }
@keyframes float { 0%,100%{transform:translateY(0)} 50%{transform:translateY(-6px)} }
@keyframes spin { to{transform:rotate(360deg)} }
button { font-family:var(--font); }
input, textarea { font-family:var(--mono); }
/* Top bar */
#topbar {
position:fixed; top:0; left:0; right:0; height:48px; z-index:200;
display:flex; align-items:center; justify-content:space-between; padding:0 16px;
background:rgba(8,8,13,0.88); backdrop-filter:blur(20px); border-bottom:1px solid var(--border);
}
#topbar .logo { display:flex; align-items:center; gap:10px; }
#topbar .logo-icon { width:28px; height:28px; border-radius:8px; background:linear-gradient(135deg,var(--accent),var(--accent2)); display:flex; align-items:center; justify-content:center; font-size:14px; }
#topbar .logo-text { font-size:15px; font-weight:700; letter-spacing:-0.5px; }
#topbar .badge { font-size:10px; padding:2px 8px; background:var(--accentG); border-radius:4px; color:var(--muted); }
#topbar .right { display:flex; align-items:center; gap:10px; }
#topbar .stats { font-size:11px; color:var(--dim); font-family:var(--mono); }
#topbar .conn-msg { font-size:11px; color:var(--accent); padding:3px 10px; background:var(--accentG); border-radius:6px; animation:pulse 1.5s infinite; display:none; }
#topbar .btn-clear { padding:5px 12px; background:rgba(255,107,107,0.1); border:1px solid rgba(255,107,107,0.2); border-radius:6px; color:var(--red); font-size:11px; cursor:pointer; }
/* Canvas */
#canvas-wrap {
position:fixed; top:48px; left:0; right:0; bottom:0; overflow:hidden; cursor:default;
background-image:radial-gradient(circle at 1px 1px, rgba(74,72,96,0.08) 1px, transparent 0);
background-size:20px 20px;
}
#canvas { position:absolute; top:0; left:0; width:20000px; height:20000px; transform-origin:0 0; }
/* Welcome */
#welcome {
position:fixed; top:50%; left:50%; transform:translate(-50%,-50%); z-index:50;
display:flex; flex-direction:column; align-items:center; gap:14px; pointer-events:none;
animation:fadeIn 0.6s ease-out;
}
#welcome .icon { width:60px; height:60px; border-radius:18px; background:linear-gradient(135deg,var(--accent),var(--accent2)); display:flex; align-items:center; justify-content:center; font-size:26px; animation:float 3s ease-in-out infinite; box-shadow:0 8px 32px rgba(108,92,231,0.3); }
#welcome h2 { font-size:22px; font-weight:700; letter-spacing:-0.5px; }
#welcome p { font-size:13px; color:var(--muted); text-align:center; max-width:420px; line-height:1.7; }
#welcome .hints { display:flex; gap:14px; color:var(--dim); font-size:11px; flex-wrap:wrap; justify-content:center; }
/* Toolbar */
#toolbar {
position:fixed; bottom:20px; left:50%; transform:translateX(-50%); z-index:200;
display:flex; gap:2px; padding:6px 12px; background:rgba(12,12,20,0.92);
backdrop-filter:blur(20px); border:1px solid var(--border); border-radius:16px;
box-shadow:0 8px 32px rgba(0,0,0,0.5);
}
.tool-btn {
display:flex; flex-direction:column; align-items:center; gap:2px;
padding:8px 11px; background:transparent; border:none; border-radius:10px;
cursor:pointer; color:var(--muted); transition:all 0.15s; font-size:10px; font-weight:500;
}
.tool-btn:hover { background:var(--surface2); color:var(--text); }
.tool-btn .ico { font-size:18px; line-height:1; }
.zoom-btn { display:flex; align-items:center; justify-content:center; width:32px; height:32px; background:transparent; border:none; border-radius:8px; cursor:pointer; color:var(--muted); font-size:16px; }
.zoom-btn:hover { color:var(--text); }
#zoom-label { min-width:40px; text-align:center; color:var(--muted); font-size:11px; font-family:var(--mono); display:flex; align-items:center; justify-content:center; }
/* Nodes */
.node {
position:absolute; border-radius:12px; display:flex; flex-direction:column;
overflow:hidden; box-shadow:0 4px 16px rgba(0,0,0,0.3); transition:box-shadow 0.2s;
min-width:240px; min-height:160px;
}
.node.selected { box-shadow:0 0 0 1.5px var(--node-border), 0 8px 32px rgba(0,0,0,0.4), 0 0 20px rgba(108,92,231,0.15); }
.node-header {
display:flex; align-items:center; padding:8px 10px; gap:6px;
border-bottom:1px solid var(--border); background:rgba(0,0,0,0.2);
min-height:40px; flex-shrink:0; cursor:grab; user-select:none;
}
.node-header:active { cursor:grabbing; }
.node-header .grip { opacity:0.3; font-size:10px; letter-spacing:2px; }
.node-header .type-ico { font-size:14px; }
.node-header input {
flex:1; background:transparent; border:none; color:var(--text);
font-size:13px; font-weight:600; outline:none; min-width:0; font-family:var(--font);
}
.node-header .color-dot {
width:14px; height:14px; border-radius:50%; border:none; cursor:pointer; flex-shrink:0;
}
.node-header .hdr-btn {
width:24px; height:24px; border-radius:6px; border:1px solid var(--border);
background:transparent; cursor:pointer; display:flex; align-items:center; justify-content:center;
color:var(--muted); font-size:12px; flex-shrink:0;
}
.node-header .hdr-btn:hover { border-color:var(--accent); color:var(--text); }
.node-header .hdr-btn.active { background:var(--accent); border-color:var(--accent); color:#fff; }
.node-header .hdr-btn.del { border:none; }
.node-header .hdr-btn.del:hover { color:var(--red); }
.node-body { flex:1; overflow:hidden; display:flex; flex-direction:column; }
.node-body > * { overflow-y:auto; }
.resize-handle {
position:absolute; right:0; bottom:0; width:16px; height:16px; cursor:nwse-resize;
}
/* Node content styles */
.note-area {
width:100%; height:100%; background:transparent; border:none; color:var(--text);
resize:none; font-family:var(--mono); font-size:13px; line-height:1.6;
padding:8px 12px; outline:none; overflow-y:auto;
}
.url-input, .yt-input {
width:100%; padding:8px 12px; background:rgba(0,0,0,0.3); border:1px solid var(--border);
border-radius:8px; color:var(--text); font-size:13px; outline:none;
}
.url-input:focus, .yt-input:focus { border-color:var(--accent); }
.yt-thumb-wrap {
position:relative; display:block; border-radius:8px; overflow:hidden; text-decoration:none;
}
.yt-thumb-wrap img { width:100%; display:block; }
.yt-play {
position:absolute; top:50%; left:50%; transform:translate(-50%,-50%);
width:50px; height:50px; border-radius:50%; background:rgba(255,0,0,0.9);
display:flex; align-items:center; justify-content:center; font-size:20px; color:#fff;
}
.yt-overlay {
position:absolute; bottom:0; left:0; right:0; padding:6px 10px;
background:linear-gradient(transparent,rgba(0,0,0,0.8));
font-size:10px; color:rgba(255,255,255,0.8);
}
/* Media node */
.media-btns { display:flex; gap:4px; flex-shrink:0; }
.gemini-btn {
flex:1; padding:7px 10px; border-radius:8px; border:none; cursor:pointer;
font-size:11px; font-weight:600; display:flex; align-items:center; justify-content:center; gap:4px;
transition:opacity 0.2s;
}
.gemini-btn:disabled { opacity:0.5; cursor:default; }
.gemini-btn.primary { background:linear-gradient(135deg,var(--accent),var(--accent2)); color:#fff; }
.gemini-btn.secondary { background:rgba(245,166,35,0.12); color:var(--orange); border:1px solid rgba(245,166,35,0.25); }
.analysis-result {
flex:1; min-height:80px; overflow-y:auto; padding:8px 10px;
background:rgba(0,0,0,0.25); border-radius:8px; border:1px solid var(--border);
color:var(--text); font-size:12px; line-height:1.6; white-space:pre-wrap; word-break:break-word;
}
/* AI Chat */
.model-sel { display:flex; gap:4px; padding:6px 10px; border-bottom:1px solid var(--border); flex-shrink:0; }
.model-btn {
flex:1; padding:4px 8px; border-radius:6px; border:1px solid var(--border);
background:transparent; color:var(--muted); font-size:11px; font-weight:600; cursor:pointer;
transition:all 0.15s;
}
.model-btn.active-claude { border-color:var(--accent); background:rgba(108,92,231,0.12); color:var(--accent); }
.model-btn.active-gemini { border-color:var(--blue); background:rgba(66,133,244,0.12); color:var(--blue); }
.chat-messages { flex:1; overflow-y:auto; padding:8px 12px; display:flex; flex-direction:column; gap:8px; }
.chat-msg {
padding:8px 12px; border-radius:10px; font-size:13px; line-height:1.5;
max-width:90%; white-space:pre-wrap; word-break:break-word;
}
.chat-msg.user { background:var(--accentG); align-self:flex-end; border:1px solid rgba(108,92,231,0.2); }
.chat-msg.ai { background:rgba(0,0,0,0.3); align-self:flex-start; border:1px solid var(--border); }
.chat-input-wrap { display:flex; gap:6px; padding:8px 12px; border-top:1px solid var(--border); flex-shrink:0; }
.chat-input {
flex:1; padding:8px 12px; background:rgba(0,0,0,0.3); border:1px solid var(--border);
border-radius:8px; color:var(--text); font-size:13px; outline:none;
}
.chat-input:focus { border-color:var(--accent); }
.chat-send {
padding:8px 14px; background:var(--accent); border:none; border-radius:8px;
cursor:pointer; color:#fff; font-size:14px;
}
.chat-send:disabled { background:var(--border); cursor:default; }
/* Drop zone */
.dropzone {
flex:1; display:flex; flex-direction:column; align-items:center; justify-content:center; gap:8px;
border:2px dashed var(--border); border-radius:8px; background:rgba(0,0,0,0.1);
cursor:pointer; padding:16px; min-height:100px; transition:all 0.2s;
}
.dropzone.over { border-color:var(--accent); background:var(--accentG); }
.dropzone .dz-ico { font-size:28px; }
.dropzone .dz-text { color:var(--muted); font-size:12px; text-align:center; }
/* Editor */
.editor-area {
width:100%; height:100%; padding:12px 16px; color:var(--text); font-size:14px;
line-height:1.7; outline:none; font-family:'DM Sans',Georgia,serif; overflow-y:auto;
}
/* SVG connections */
#conn-svg { position:absolute; top:0; left:0; width:100%; height:100%; pointer-events:none; overflow:visible; }
#conn-svg path { pointer-events:stroke; cursor:pointer; }
/* Dots loader */
.dots { display:flex; gap:4px; }
.dots span {
width:6px; height:6px; border-radius:50%; background:var(--accent);
animation:pulse 1s ease-in-out infinite;
}
.dots span:nth-child(2) { animation-delay:0.15s; }
.dots span:nth-child(3) { animation-delay:0.3s; }
/* Mindmap */
.mm-branch { margin-left:16px; }
.mm-item { display:flex; align-items:center; gap:6px; padding:4px 8px; margin:2px 0; }
.mm-dot { width:8px; height:8px; border-radius:50%; flex-shrink:0; }
.mm-text { color:var(--text); font-size:13px; flex:1; }
.mm-add { width:18px; height:18px; border-radius:4px; border:1px solid var(--border); background:transparent; cursor:pointer; color:var(--muted); font-size:12px; display:flex; align-items:center; justify-content:center; opacity:0.6; flex-shrink:0; }
.mm-input-wrap { display:flex; gap:4px; padding:8px 12px; border-top:1px solid var(--border); flex-shrink:0; }
.mm-input { flex:1; padding:6px 10px; background:rgba(0,0,0,0.3); border:1px solid var(--border); border-radius:6px; color:var(--text); font-size:12px; outline:none; }
</style>
</head>
<body>
<!-- Top Bar -->
<div id="topbar">
<div class="logo">
<div class="logo-icon">⚡</div>
<span class="logo-text">FlowMind</span>
<span class="badge">AI Workspace</span>
<span class="badge" style="color:#4285f4;background:rgba(66,133,244,0.1)">Gemini Video</span>
</div>
<div class="right">
<span class="stats" id="stats">0 nodes · 0 links</span>
<span class="conn-msg" id="conn-msg">Click node to connect</span>
<button class="btn-clear" onclick="clearAll()">Clear All</button>
</div>
</div>
<!-- Canvas -->
<div id="canvas-wrap">
<div id="canvas">
<svg id="conn-svg">
<defs>
<linearGradient id="cg" x1="0%" y1="0%" x2="100%" y2="0%">
<stop offset="0%" stop-color="#6c5ce7" stop-opacity="0.5"/>
<stop offset="50%" stop-color="#4fc3f7" stop-opacity="0.7"/>
<stop offset="100%" stop-color="#6c5ce7" stop-opacity="0.5"/>
</linearGradient>
</defs>
</svg>
</div>
</div>
<!-- Welcome -->
<div id="welcome">
<div class="icon">✦</div>
<h2>FlowMind AI Workspace</h2>
<p>Drop videos from TikTok/IG on the canvas. Gemini will <b>see and analyze</b> them.<br>Connect nodes to AI Chat (Claude or Gemini) for deep analysis.</p>
<div class="hints">
<span>Scroll = zoom</span><span>·</span>
<span>Drag canvas = pan</span><span>·</span>
<span>Drop files = auto-create</span>
</div>
</div>
<!-- Toolbar -->
<div id="toolbar">
<button class="tool-btn" onclick="addNode('note')"><span class="ico">📝</span>Note</button>
<button class="tool-btn" onclick="addNode('youtube')"><span class="ico">▶️</span>YouTube</button>
<button class="tool-btn" onclick="addNode('image')"><span class="ico">🖼️</span>Image</button>
<button class="tool-btn" onclick="addNode('media')"><span class="ico">🎬</span>Media</button>
<button class="tool-btn" onclick="addNode('url')"><span class="ico">🔗</span>URL</button>
<button class="tool-btn" onclick="addNode('ai_chat')"><span class="ico">🤖</span>AI Chat</button>
<button class="tool-btn" onclick="addNode('editor')"><span class="ico">✏️</span>Editor</button>
<button class="tool-btn" onclick="addNode('mindmap')"><span class="ico">🧠</span>MindMap</button>
<div style="width:1px;background:var(--border);margin:4px 6px"></div>
<button class="zoom-btn" onclick="changeZoom(0.1)">+</button>
<span id="zoom-label">100%</span>
<button class="zoom-btn" onclick="changeZoom(-0.1)">−</button>
</div>
<script>
const GEMINI_KEY = "AIzaSyCA2C7MnzkdFbAH3ivC8fjVUgYdCLhZI-M";
const COLORS = [
{bg:"#1a1535",border:"#6c5ce7"},{bg:"#0d2137",border:"#4fc3f7"},
{bg:"#0d2a20",border:"#00d2a0"},{bg:"#2a1a0d",border:"#f5a623"},
{bg:"#2a0d1a",border:"#f78fb3"},{bg:"#2a2a0d",border:"#ffd93d"}
];
let state = { nodes: [], connections: [], pan: {x:0,y:0}, zoom: 1, selected: null, connecting: null };
let _id = 0;
const uid = () => "n" + Date.now() + (++_id);
const wrap = document.getElementById("canvas-wrap");
const canvas = document.getElementById("canvas");
const svg = document.getElementById("conn-svg");
// ─── PAN & ZOOM ──────────────────────────────────────────────────────
let isPanning = false, panStart = {x:0,y:0,px:0,py:0};
wrap.addEventListener("mousedown", (e) => {
if (e.target !== wrap && e.target !== canvas && e.target.tagName !== "svg") return;
if (state.connecting) { state.connecting = null; updateUI(); return; }
state.selected = null; updateUI();
isPanning = true;
panStart = { x:e.clientX, y:e.clientY, px:state.pan.x, py:state.pan.y };
wrap.style.cursor = "grabbing";
});
window.addEventListener("mousemove", (e) => {
if (!isPanning) return;
state.pan.x = panStart.px + (e.clientX - panStart.x) / state.zoom;
state.pan.y = panStart.py + (e.clientY - panStart.y) / state.zoom;
applyTransform();
});
window.addEventListener("mouseup", () => { isPanning = false; wrap.style.cursor = "default"; });
wrap.addEventListener("wheel", (e) => {
if (e.target.closest(".node-body")) return;
e.preventDefault();
state.zoom = Math.max(0.2, Math.min(2, state.zoom + (e.deltaY > 0 ? -0.05 : 0.05)));
applyTransform();
document.getElementById("zoom-label").textContent = Math.round(state.zoom*100)+"%";
}, {passive:false});
function changeZoom(d) {
state.zoom = Math.max(0.2, Math.min(2, state.zoom + d));
applyTransform();
document.getElementById("zoom-label").textContent = Math.round(state.zoom*100)+"%";
}
function applyTransform() {
canvas.style.transform = "scale("+state.zoom+") translate("+state.pan.x+"px,"+state.pan.y+"px)";
}
// ─── DRAG & DROP FILES ON CANVAS ─────────────────────────────────────
wrap.addEventListener("dragover", (e) => e.preventDefault());
wrap.addEventListener("drop", async (e) => {
e.preventDefault();
const file = e.dataTransfer?.files?.[0];
if (!file) return;
const rect = wrap.getBoundingClientRect();
const x = (e.clientX - rect.left) / state.zoom - state.pan.x;
const y = (e.clientY - rect.top) / state.zoom - state.pan.y;
const data = await fileToBase64(file);
if (file.type.startsWith("image/")) {
addNode("image", x - 175, y - 160, {imageData:data, imageName:file.name, title:file.name});
} else if (file.type.startsWith("video/") || file.type.startsWith("audio/")) {
addNode("media", x - 200, y - 225, {mediaData:data, mediaName:file.name, mediaType:file.type, title:file.name});
}
});
function fileToBase64(file) {
return new Promise((r,j) => { const rd = new FileReader(); rd.onload = () => r(rd.result); rd.onerror = j; rd.readAsDataURL(file); });
}
// ─── NODE MANAGEMENT ─────────────────────────────────────────────────
function addNode(type, cx, cy, extra) {
const sizes = {note:[300,250],youtube:[380,360],image:[350,320],media:[400,480],url:[320,200],ai_chat:[400,480],editor:[450,350],mindmap:[350,350]};
const [w,h] = sizes[type] || [300,250];
if (cx === undefined) {
cx = (window.innerWidth/2) / state.zoom - state.pan.x - w/2 + (state.nodes.length%5)*40;
cy = (window.innerHeight/2) / state.zoom - state.pan.y - h/2 + (state.nodes.length%5)*40;
}
const node = {
id: uid(), type, x:cx, y:cy, w, h,
title: "", content: "", url: "", imageUrl: "", imageData: null, imageName: "",
mediaData: null, mediaName: "", mediaType: "", analysis: "",
messages: [], aiModel: "claude",
mindmapData: type === "mindmap" ? [{id:"root",text:"Central Idea",children:[]}] : null,
colorIndex: Math.floor(Math.random()*COLORS.length),
z: state.nodes.length + 1,
...extra
};
state.nodes.push(node);
state.selected = node.id;
document.getElementById("welcome").style.display = "none";
renderNode(node);
updateUI();
saveState();
}
function deleteNode(id) {
const el = document.getElementById(id);
if (el) el.remove();
state.nodes = state.nodes.filter(n => n.id !== id);
state.connections = state.connections.filter(c => c.from !== id && c.to !== id);
if (state.selected === id) state.selected = null;
updateUI();
saveState();
}
function getNode(id) { return state.nodes.find(n => n.id === id); }
// ─── RENDER NODE ─────────────────────────────────────────────────────
function renderNode(node) {
const nc = COLORS[node.colorIndex || 0];
const div = document.createElement("div");
div.className = "node" + (state.selected === node.id ? " selected" : "");
div.id = node.id;
div.style.cssText = "left:"+node.x+"px;top:"+node.y+"px;width:"+node.w+"px;height:"+node.h+"px;background:"+nc.bg+";border:1.5px solid "+(state.selected===node.id?nc.border:"var(--border)")+";z-index:"+(node.z||1)+";--node-border:"+nc.border;
const icons = {note:"📝",youtube:"▶️",image:"🖼️",url:"🔗",ai_chat:"🤖",editor:"✏️",mindmap:"🧠",media:"🎬"};
const labels = {note:"Note",youtube:"YouTube",image:"Image",url:"URL",ai_chat:"AI Chat",editor:"Editor",mindmap:"Mind Map",media:"Media"};
div.innerHTML = `
<div class="node-header" data-drag="${node.id}">
<span class="grip">⠿</span>
<span class="type-ico">${icons[node.type]}</span>
<input value="${node.title||""}" placeholder="${labels[node.type]}" oninput="getNode('${node.id}').title=this.value;saveState()">
<button class="color-dot" style="background:${nc.border}" onclick="cycleColor('${node.id}')"></button>
<button class="hdr-btn ${state.connecting===node.id?'active':''}" onclick="toggleConnect('${node.id}')" title="Connect">⟷</button>
<button class="hdr-btn del" onclick="deleteNode('${node.id}')" title="Delete">✕</button>
</div>
<div class="node-body" id="body-${node.id}"></div>
<div class="resize-handle" data-resize="${node.id}"></div>
`;
canvas.appendChild(div);
renderNodeBody(node);
setupDrag(div, node);
setupResize(div, node);
}
function renderNodeBody(node) {
const body = document.getElementById("body-" + node.id);
if (!body) return;
body.innerHTML = "";
switch(node.type) {
case "note": body.innerHTML = `<textarea class="note-area" oninput="getNode('${node.id}').content=this.value;saveState()" placeholder="Write your thoughts...">${node.content||""}</textarea>`; break;
case "youtube": renderYouTube(body, node); break;
case "image": renderImage(body, node); break;
case "media": renderMedia(body, node); break;
case "url": renderUrl(body, node); break;
case "ai_chat": renderAIChat(body, node); break;
case "editor": body.innerHTML = `<div class="editor-area" contenteditable="true" onblur="getNode('${node.id}').content=this.innerHTML;saveState()">${node.content||"<p>Start writing...</p>"}</div>`; break;
case "mindmap": renderMindmap(body, node); break;
}
}
// ─── YOUTUBE NODE ────────────────────────────────────────────────────
function renderYouTube(body, node) {
const vid = extractYTId(node.url);
let html = `<div style="padding:8px 12px;height:100%;display:flex;flex-direction:column;gap:8px;overflow-y:auto">
<input class="yt-input" value="${node.url||""}" placeholder="Paste YouTube URL..." oninput="getNode('${node.id}').url=this.value;renderNodeBody(getNode('${node.id}'));saveState()">`;
if (vid) {
html += `<a class="yt-thumb-wrap" href="https://www.youtube.com/watch?v=${vid}" target="_blank">
<img src="https://img.youtube.com/vi/${vid}/hqdefault.jpg" alt="">
<div class="yt-play">▶</div>
<div class="yt-overlay">Open in YouTube ↗</div>
</a>
<textarea class="note-area" style="min-height:40px;flex:1" oninput="getNode('${node.id}').content=this.value;saveState()" placeholder="Notes...">${node.content||""}</textarea>`;
} else {
html += `<div style="flex:1;display:flex;align-items:center;justify-content:center;color:var(--dim);font-size:12px">Paste a YouTube link above</div>`;
}
html += `</div>`;
body.innerHTML = html;
}
function extractYTId(url) {
if (!url) return null;
const m = url.match(/(?:youtube\.com\/watch\?v=|youtu\.be\/|youtube\.com\/shorts\/)([a-zA-Z0-9_-]{11})/);
return m ? m[1] : null;
}
// ─── IMAGE NODE ──────────────────────────────────────────────────────
function renderImage(body, node) {
const has = node.imageData || node.imageUrl;
if (!has) {
body.innerHTML = `<div style="padding:8px 12px;height:100%;display:flex;flex-direction:column;gap:8px">
<input class="url-input" value="${node.imageUrl||""}" placeholder="Paste image URL or drop below..." oninput="getNode('${node.id}').imageUrl=this.value;renderNodeBody(getNode('${node.id}'));saveState()">
<div class="dropzone" id="dz-${node.id}"><span class="dz-ico">📁</span><span class="dz-text">Drop image or click</span></div>
</div>`;
setupDropzone("dz-"+node.id, "image/*", (f) => { node.imageData = f.data; node.imageName = f.name; renderNodeBody(node); saveState(); });
} else {
body.innerHTML = `<div style="padding:8px 12px;height:100%;display:flex;flex-direction:column;gap:8px;overflow-y:auto">
<div style="position:relative;flex:1;border-radius:8px;overflow:hidden">
<img src="${node.imageData||node.imageUrl}" style="width:100%;height:100%;object-fit:contain">
<button onclick="getNode('${node.id}').imageData=null;getNode('${node.id}').imageUrl='';renderNodeBody(getNode('${node.id}'));saveState()" style="position:absolute;top:4px;right:4px;width:24px;height:24px;border-radius:6px;background:rgba(0,0,0,0.7);border:none;cursor:pointer;color:#fff;font-size:12px">✕</button>
</div>
${node.imageName ? '<div style="color:var(--muted);font-size:11px;text-align:center">'+node.imageName+'</div>' : ''}
</div>`;
}
}
// ─── MEDIA NODE WITH GEMINI ──────────────────────────────────────────
function renderMedia(body, node) {
if (!node.mediaData) {
body.innerHTML = `<div style="padding:8px 12px;height:100%;display:flex;flex-direction:column;gap:8px">
<div class="dropzone" id="dz-${node.id}"><span class="dz-ico">🎬</span><span class="dz-text">Drop video/audio here<br><small style="color:var(--dim)">MP4, WebM, MP3...</small></span></div>
</div>`;
setupDropzone("dz-"+node.id, "video/*,audio/*", (f) => { node.mediaData = f.data; node.mediaName = f.name; node.mediaType = f.type; renderNodeBody(node); saveState(); });
} else {
let mediaEl = node.mediaType?.startsWith("video")
? `<video src="${node.mediaData}" controls style="width:100%;max-height:200px;border-radius:8px"></video>`
: `<audio src="${node.mediaData}" controls style="width:100%;margin:16px 0"></audio>`;
body.innerHTML = `<div style="padding:8px 12px;height:100%;display:flex;flex-direction:column;gap:8px;overflow-y:auto">
<div style="position:relative;border-radius:8px;overflow:hidden;background:#000;flex-shrink:0">
${mediaEl}
<button onclick="getNode('${node.id}').mediaData=null;getNode('${node.id}').analysis='';renderNodeBody(getNode('${node.id}'));saveState()" style="position:absolute;top:4px;right:4px;width:24px;height:24px;border-radius:6px;background:rgba(0,0,0,0.7);border:none;cursor:pointer;color:#fff;font-size:12px">✕</button>
</div>
<div style="color:var(--muted);font-size:11px;text-align:center">${node.mediaName}</div>
<div class="media-btns">
<button class="gemini-btn primary" id="ga-${node.id}" onclick="geminiAnalyze('${node.id}')">👁 Gemini Analyze</button>
<button class="gemini-btn secondary" id="gad-${node.id}" onclick="geminiAnalyzeAd('${node.id}')">📊 Ad Analysis</button>
</div>
<div class="analysis-result" id="ar-${node.id}" style="${node.analysis?'':'display:none'}">${node.analysis||""}</div>
${!node.analysis ? `<textarea class="note-area" style="min-height:40px" oninput="getNode('${node.id}').content=this.value;saveState()" placeholder="Notes...">${node.content||""}</textarea>` : ''}
</div>`;
}
}
async function geminiAnalyze(nodeId, customPrompt) {
const node = getNode(nodeId);
if (!node || !node.mediaData) return;
const btn = document.getElementById("ga-"+nodeId);
const btn2 = document.getElementById("gad-"+nodeId);
if (btn) { btn.disabled = true; btn.innerHTML = '<span class="dots"><span></span><span></span><span></span></span> Analyzing...'; }
if (btn2) btn2.disabled = true;
try {
const raw = node.mediaData.split(",")[1];
const res = await fetch("https://generativelanguage.googleapis.com/v1beta/models/gemini-2.5-flash:generateContent?key="+GEMINI_KEY, {
method:"POST", headers:{"Content-Type":"application/json"},
body: JSON.stringify({contents:[{parts:[
{inline_data:{mime_type:node.mediaType,data:raw}},
{text: customPrompt || "Analyze this video in detail. Identify: 1) Hook in first 3 seconds 2) Main message and structure 3) Call-to-action 4) Visual style (talking head, B-roll, text overlay, etc) 5) Editing patterns 6) Text on screen 7) Audio/music style 8) What makes it engaging. Respond in the video's language."}
]}]})
});
const data = await res.json();
const text = data.candidates?.[0]?.content?.parts?.[0]?.text || data.error?.message || "Could not analyze.";
node.analysis = text;
node.content = text;
saveState();
const ar = document.getElementById("ar-"+nodeId);
if (ar) { ar.style.display = "block"; ar.textContent = text; }
} catch(e) {
const ar = document.getElementById("ar-"+nodeId);
if (ar) { ar.style.display = "block"; ar.textContent = "Error: "+e.message; }
}
if (btn) { btn.disabled = false; btn.innerHTML = "👁 Gemini Analyze"; }
if (btn2) btn2.disabled = false;
}
function geminiAnalyzeAd(nodeId) {
geminiAnalyze(nodeId, "Analyze this video as a marketing ad. Extract in detail: 1) Hook (first 3 seconds - what catches attention) 2) Pain point addressed 3) Value proposition 4) Call-to-action 5) Copywriting patterns used 6) Target audience 7) Emotional triggers 8) What to replicate for your own ads. Respond in the video's language.");
}
// ─── URL NODE ────────────────────────────────────────────────────────
function renderUrl(body, node) {
body.innerHTML = `<div style="padding:8px 12px;height:100%;display:flex;flex-direction:column;gap:8px;overflow-y:auto">
<input class="url-input" value="${node.url||""}" placeholder="Paste any URL..." oninput="getNode('${node.id}').url=this.value;saveState()">
${node.url ? `<a href="${node.url}" target="_blank" style="flex:1;display:flex;align-items:center;justify-content:center;background:rgba(0,0,0,0.2);border-radius:8px;padding:16px;color:var(--accent);word-break:break-all;font-size:13px;text-decoration:none">↗ ${node.url}</a>` : ''}
</div>`;
}
// ─── AI CHAT NODE ────────────────────────────────────────────────────
function renderAIChat(body, node) {
const model = node.aiModel || "claude";
let msgsHtml = "";
if (node.messages.length === 0) {
msgsHtml = '<div style="color:var(--dim);font-size:12px;text-align:center;padding:20px">Connect nodes for context, then ask</div>';
}
node.messages.forEach((m,i) => {
msgsHtml += `<div class="chat-msg ${m.role==='user'?'user':'ai'}">${escHtml(m.content)}</div>`;
});
body.innerHTML = `
<div class="model-sel">
<button class="model-btn ${model==='claude'?'active-claude':''}" onclick="getNode('${node.id}').aiModel='claude';renderNodeBody(getNode('${node.id}'))">Claude</button>
<button class="model-btn ${model==='gemini'?'active-gemini':''}" onclick="getNode('${node.id}').aiModel='gemini';renderNodeBody(getNode('${node.id}'))">Gemini</button>
</div>
<div class="chat-messages" id="msgs-${node.id}">${msgsHtml}</div>
<div class="chat-input-wrap">
<input class="chat-input" id="ci-${node.id}" placeholder="Ask AI..." onkeydown="if(event.key==='Enter'&&!event.shiftKey)sendChat('${node.id}')">
<button class="chat-send" id="cs-${node.id}" onclick="sendChat('${node.id}')">➤</button>
</div>`;
const mc = document.getElementById("msgs-"+node.id);
if (mc) mc.scrollTop = mc.scrollHeight;
}
async function sendChat(nodeId) {
const node = getNode(nodeId);
const input = document.getElementById("ci-"+nodeId);
const btn = document.getElementById("cs-"+nodeId);
if (!node || !input || !input.value.trim()) return;
const text = input.value.trim();
node.messages.push({role:"user", content:text});
input.value = "";
btn.disabled = true;
// Add user msg
const mc = document.getElementById("msgs-"+nodeId);
mc.innerHTML += `<div class="chat-msg user">${escHtml(text)}</div>`;
mc.innerHTML += `<div class="chat-msg ai" id="loading-${nodeId}"><span class="dots"><span></span><span></span><span></span></span></div>`;
mc.scrollTop = mc.scrollHeight;
// Gather context
const ctx = gatherContext(nodeId);
const sys = ctx ? "AI assistant in workspace. Connected nodes:\n"+ctx+"\nUse this context. Be concise. Respond in user's language." : "AI assistant in workspace. Be concise. Respond in user's language.";
try {
let aiText = "";
if (node.aiModel === "gemini") {
const res = await fetch("https://generativelanguage.googleapis.com/v1beta/models/gemini-2.5-flash:generateContent?key="+GEMINI_KEY, {
method:"POST", headers:{"Content-Type":"application/json"},
body:JSON.stringify({contents:[{parts:[{text:sys+"\n\nUser: "+text}]}]})
});
const data = await res.json();
aiText = data.candidates?.[0]?.content?.parts?.[0]?.text || data.error?.message || "Error";
} else {
const res = await fetch("https://api.anthropic.com/v1/messages", {
method:"POST", headers:{"Content-Type":"application/json"},
body:JSON.stringify({model:"claude-sonnet-4-20250514",max_tokens:1000,system:sys,messages:node.messages.map(m=>({role:m.role,content:m.content}))})
});
const data = await res.json();
aiText = data.content?.map(c=>c.text||"").join("") || "Error";
}
node.messages.push({role:"assistant",content:aiText});
saveState();
const ld = document.getElementById("loading-"+nodeId);
if (ld) { ld.className = "chat-msg ai"; ld.textContent = aiText; }
} catch(e) {
node.messages.push({role:"assistant",content:"Error: "+e.message});
const ld = document.getElementById("loading-"+nodeId);
if (ld) { ld.className = "chat-msg ai"; ld.textContent = "Error: "+e.message; }
}
btn.disabled = false;
mc.scrollTop = mc.scrollHeight;
}
function gatherContext(nodeId) {
const linked = state.connections.filter(c => c.from===nodeId||c.to===nodeId).map(c => c.from===nodeId?c.to:c.from);
return state.nodes.filter(n => linked.includes(n.id)).map(n => {
let s = "["+n.type.toUpperCase()+'] "' + (n.title||"Untitled") + '"';
if (n.content) s += "\nContent: " + n.content;
if (n.url) s += "\nURL: " + n.url;
if (n.analysis) s += "\nVideo Analysis: " + n.analysis;
return s;
}).join("\n---\n");
}
// ─── MINDMAP NODE ────────────────────────────────────────────────────
function renderMindmap(body, node) {
const items = node.mindmapData || [{id:"root",text:"Central Idea",children:[]}];
function renderBranch(item, depth) {
const c = COLORS[depth % COLORS.length].border;
let html = `<div ${depth>0?'class="mm-branch"':''} ${depth>0?'style="border-left:2px solid '+c+'"':''}>
<div class="mm-item"><span class="mm-dot" style="background:${c}"></span><span class="mm-text">${escHtml(item.text)}</span>
<button class="mm-add" onclick="mmAdd('${node.id}','${item.id}')">+</button></div>`;
if (item.children) item.children.forEach(ch => { html += renderBranch(ch, depth+1); });
html += '</div>';
return html;
}
let html = '<div style="flex:1;overflow-y:auto;padding:8px">';
items.forEach(i => { html += renderBranch(i, 0); });
html += `</div><div class="mm-input-wrap"><input class="mm-input" id="mmi-${node.id}" placeholder="New branch..." onkeydown="if(event.key==='Enter')mmAdd('${node.id}','${items[0]?.id}')"></div>`;
body.innerHTML = html;
}
function mmAdd(nodeId, parentId) {
const node = getNode(nodeId);
const input = document.getElementById("mmi-"+nodeId);
const text = input ? input.value.trim() : "";
if (!text) return;
function findAndAdd(items) {
for (const item of items) {
if (item.id === parentId) { item.children = item.children||[]; item.children.push({id:uid(),text,children:[]}); return true; }
if (item.children && findAndAdd(item.children)) return true;
}
return false;
}
findAndAdd(node.mindmapData);
if (input) input.value = "";
renderNodeBody(node);
saveState();
}
// ─── CONNECTIONS ─────────────────────────────────────────────────────
function toggleConnect(nodeId) {
if (state.connecting && state.connecting !== nodeId) {
// Complete connection
const exists = state.connections.some(c => (c.from===state.connecting&&c.to===nodeId)||(c.from===nodeId&&c.to===state.connecting));
if (!exists && state.connecting !== nodeId) {
state.connections.push({id:uid(),from:state.connecting,to:nodeId});
}
state.connecting = null;
} else if (state.connecting === nodeId) {
state.connecting = null;
} else {
state.connecting = nodeId;
}
updateUI();
saveState();
}
function renderConnections() {
const paths = svg.querySelectorAll("g.conn");
paths.forEach(p => p.remove());
state.connections.forEach(conn => {
const nf = getNode(conn.from), nt = getNode(conn.to);
if (!nf || !nt) return;
const fx = nf.x+nf.w/2, fy = nf.y+nf.h/2;
const tx = nt.x+nt.w/2, ty = nt.y+nt.h/2;
const dx = tx-fx;
const g = document.createElementNS("http://www.w3.org/2000/svg","g");
g.setAttribute("class","conn");
g.innerHTML = `<path d="M${fx},${fy} C${fx+dx*0.4},${fy} ${tx-dx*0.4},${ty} ${tx},${ty}" stroke="url(#cg)" stroke-width="2" fill="none" stroke-dasharray="6 4" style="pointer-events:stroke;cursor:pointer"/>
<circle cx="${fx}" cy="${fy}" r="4" fill="#6c5ce7" opacity="0.6"/>
<circle cx="${tx}" cy="${ty}" r="4" fill="#6c5ce7" opacity="0.6"/>`;
g.querySelector("path").addEventListener("click", () => {
state.connections = state.connections.filter(c => c.id !== conn.id);
updateUI(); saveState();
});
svg.appendChild(g);
});
}
// ─── DRAG & RESIZE ───────────────────────────────────────────────────
function setupDrag(el, node) {
const header = el.querySelector("[data-drag]");
if (!header) return;
let dragging = false, ox, oy;
header.addEventListener("mousedown", (e) => {
if (e.target.tagName === "INPUT" || e.target.tagName === "BUTTON") return;
dragging = true;
state.selected = node.id;
const rect = wrap.getBoundingClientRect();
ox = (e.clientX - rect.left) / state.zoom - state.pan.x - node.x;
oy = (e.clientY - rect.top) / state.zoom - state.pan.y - node.y;
updateUI();
});
window.addEventListener("mousemove", (e) => {
if (!dragging) return;
const rect = wrap.getBoundingClientRect();
node.x = (e.clientX - rect.left) / state.zoom - state.pan.x - ox;
node.y = (e.clientY - rect.top) / state.zoom - state.pan.y - oy;
el.style.left = node.x + "px";
el.style.top = node.y + "px";
renderConnections();
});
window.addEventListener("mouseup", () => { if (dragging) { dragging = false; saveState(); } });
}
function setupResize(el, node) {
const handle = el.querySelector("[data-resize]");
if (!handle) return;
let resizing = false, sx, sy, sw, sh;
handle.addEventListener("mousedown", (e) => {
e.stopPropagation();
resizing = true; sx = e.clientX; sy = e.clientY; sw = node.w; sh = node.h;
});
window.addEventListener("mousemove", (e) => {
if (!resizing) return;
node.w = Math.max(240, sw + (e.clientX - sx) / state.zoom);
node.h = Math.max(180, sh + (e.clientY - sy) / state.zoom);
el.style.width = node.w + "px";
el.style.height = node.h + "px";
renderConnections();
});
window.addEventListener("mouseup", () => { if (resizing) { resizing = false; saveState(); } });
}
// ─── DROPZONE SETUP ──────────────────────────────────────────────────
function setupDropzone(id, accept, onFile) {
const dz = document.getElementById(id);
if (!dz) return;
dz.addEventListener("dragover", (e) => { e.preventDefault(); e.stopPropagation(); dz.classList.add("over"); });
dz.addEventListener("dragleave", (e) => { e.preventDefault(); e.stopPropagation(); dz.classList.remove("over"); });
dz.addEventListener("drop", async (e) => {
e.preventDefault(); e.stopPropagation(); dz.classList.remove("over");
const f = e.dataTransfer?.files?.[0];
if (f) { const data = await fileToBase64(f); onFile({data,name:f.name,type:f.type}); }
});
dz.addEventListener("click", () => {
const inp = document.createElement("input");
inp.type = "file"; inp.accept = accept;
inp.onchange = async () => { if (inp.files[0]) { const data = await fileToBase64(inp.files[0]); onFile({data,name:inp.files[0].name,type:inp.files[0].type}); }};
inp.click();
});
}
// ─── UTILS ───────────────────────────────────────────────────────────
function cycleColor(nodeId) {
const node = getNode(nodeId);
node.colorIndex = ((node.colorIndex||0)+1) % COLORS.length;
const el = document.getElementById(nodeId);
const nc = COLORS[node.colorIndex];
el.style.background = nc.bg;
el.style.setProperty("--node-border", nc.border);
el.querySelector(".color-dot").style.background = nc.border;
if (state.selected === nodeId) el.style.borderColor = nc.border;
saveState();
}
function escHtml(s) { const d = document.createElement("div"); d.textContent = s; return d.innerHTML; }
function updateUI() {
document.getElementById("stats").textContent = state.nodes.length + " nodes · " + state.connections.length + " links";
const cm = document.getElementById("conn-msg");
cm.style.display = state.connecting ? "inline" : "none";
// Update selected state
document.querySelectorAll(".node").forEach(el => {
const n = getNode(el.id);
if (!n) return;
const nc = COLORS[n.colorIndex||0];
if (state.selected === el.id) {
el.classList.add("selected");
el.style.borderColor = nc.border;
el.style.zIndex = 100;
} else {
el.classList.remove("selected");
el.style.borderColor = "var(--border)";
el.style.zIndex = n.z || 1;
}
// Update connect button
const cb = el.querySelector(".hdr-btn:not(.del)");
if (cb) { cb.className = "hdr-btn" + (state.connecting===el.id?" active":""); }
});
renderConnections();
}
// ─── SAVE/LOAD ───────────────────────────────────────────────────────
let saveTimeout;
function saveState() {
clearTimeout(saveTimeout);
saveTimeout = setTimeout(() => {
const data = {
nodes: state.nodes.map(n => {
const c = {...n};
if (c.mediaData && c.mediaData.length > 500000) { c.mediaData = null; c._cleared = true; }
if (c.imageData && c.imageData.length > 500000) { c.imageData = null; c._cleared = true; }
return c;
}),
connections: state.connections,
pan: state.pan,
zoom: state.zoom
};
try { localStorage.setItem("flowmind-v1", JSON.stringify(data)); } catch(e) {}
}, 600);
}
function loadState() {
try {
const raw = localStorage.getItem("flowmind-v1");
if (!raw) return;
const data = JSON.parse(raw);
if (data.nodes && data.nodes.length > 0) {
state.pan = data.pan || {x:0,y:0};
state.zoom = data.zoom || 1;
state.connections = data.connections || [];
applyTransform();
document.getElementById("zoom-label").textContent = Math.round(state.zoom*100)+"%";
document.getElementById("welcome").style.display = "none";
data.nodes.forEach(n => {
state.nodes.push(n);
renderNode(n);
});
updateUI();
}
} catch(e) {}
}
function clearAll() {
state.nodes.forEach(n => { const el = document.getElementById(n.id); if (el) el.remove(); });
state.nodes = []; state.connections = []; state.selected = null; state.connecting = null;
state.pan = {x:0,y:0}; state.zoom = 1;
applyTransform();
document.getElementById("zoom-label").textContent = "100%";
document.getElementById("welcome").style.display = "flex";
updateUI();
try { localStorage.removeItem("flowmind-v1"); } catch(e) {}
}
// ─── INIT ────────────────────────────────────────────────────────────
loadState();
</script>
</body>
</html>