235a9abbfe
- Replace hardcoded secret_key with os.environ.get('SECRET_KEY')
- RuntimeError if SECRET_KEY not set (fail fast)
- Restrict CORS to localhost:8765 origins (was allow all with credentials)
- Add .gitignore excluding db, env, __pycache__, backups
Co-Authored-By: Claude Sonnet 4.6 <noreply@anthropic.com>
1344 lines
73 KiB
HTML
1344 lines
73 KiB
HTML
<!DOCTYPE html>
|
|
<html lang="es">
|
|
<head>
|
|
<meta charset="UTF-8">
|
|
<meta name="viewport" content="width=device-width, initial-scale=1.0, viewport-fit=cover">
|
|
<title>Boat&Ship-Finder — Global Vessel Intelligence</title>
|
|
<style>
|
|
@import url('https://fonts.googleapis.com/css2?family=DM+Mono:ital,wght@0,400;0,500&family=Syne:wght@400;600;700;800&display=swap');
|
|
*{box-sizing:border-box;margin:0;padding:0;-webkit-tap-highlight-color:transparent}
|
|
:root{
|
|
--bg:#080c12;--s1:#0e1420;--s2:#141b28;--s3:#1a2235;
|
|
--b:rgba(255,255,255,0.07);--b2:rgba(255,255,255,0.14);
|
|
--accent:#00b4ff;--green:#00e5a0;--amber:#ffb547;--red:#ff5252;--purple:#a78bfa;
|
|
--text:#dde4f0;--muted:#5a6a88;--mono:'DM Mono',monospace;--head:'Syne',system-ui,sans-serif;
|
|
}
|
|
body{background:var(--bg);color:var(--text);font-family:var(--head);min-height:100vh;overflow-x:hidden}
|
|
input,select,button,textarea{font-family:inherit;outline:none}
|
|
::-webkit-scrollbar{width:3px;height:3px}
|
|
::-webkit-scrollbar-thumb{background:var(--b2);border-radius:2px}
|
|
|
|
/* Header */
|
|
.hdr{background:var(--s1);border-bottom:1px solid var(--b);padding:11px 18px;display:flex;align-items:center;gap:12px;position:sticky;top:0;z-index:200;backdrop-filter:blur(12px)}
|
|
.logo{font-weight:800;font-size:16px;letter-spacing:-.03em;white-space:nowrap;display:flex;align-items:center;gap:8px;flex-shrink:0}
|
|
.logo-icon{width:30px;height:30px;background:linear-gradient(135deg,var(--accent),var(--green));border-radius:8px;display:flex;align-items:center;justify-content:center;font-size:15px}
|
|
.search-wrap{flex:1;position:relative}
|
|
.search-in{width:100%;background:var(--s2);border:1px solid var(--b);border-radius:10px;padding:9px 40px 9px 14px;color:var(--text);font-size:13px;transition:.2s}
|
|
.search-in:focus{border-color:rgba(0,180,255,.4);background:var(--s3)}
|
|
.search-in::placeholder{color:var(--muted)}
|
|
.search-go{position:absolute;right:10px;top:50%;transform:translateY(-50%);background:none;border:none;color:var(--accent);cursor:pointer;font-size:17px;padding:2px}
|
|
.hdr-btns{display:flex;gap:6px;flex-shrink:0}
|
|
.hbtn{background:var(--s2);border:1px solid var(--b);border-radius:8px;padding:7px 11px;color:var(--muted);cursor:pointer;font-size:11px;font-weight:700;transition:.2s;white-space:nowrap;letter-spacing:.03em}
|
|
.hbtn:hover,.hbtn.on{color:var(--accent);border-color:rgba(0,180,255,.3)}
|
|
|
|
/* Tabs */
|
|
.tabs{background:var(--s1);border-bottom:1px solid var(--b);display:flex;overflow-x:auto;padding:0 16px}
|
|
.tab{padding:10px 16px;font-size:12px;font-weight:700;color:var(--muted);border:none;background:none;cursor:pointer;border-bottom:2px solid transparent;transition:.2s;white-space:nowrap;letter-spacing:.04em}
|
|
.tab.on{color:var(--accent);border-bottom-color:var(--accent)}
|
|
|
|
/* Filters */
|
|
.fltbar{background:var(--s1);padding:9px 16px;display:flex;gap:7px;flex-wrap:wrap;border-bottom:1px solid var(--b);align-items:center}
|
|
.flt{background:var(--s2);border:1px solid var(--b);border-radius:8px;padding:7px 10px;color:var(--text);font-size:12px;cursor:pointer;transition:.2s;height:34px}
|
|
.flt:hover{border-color:var(--b2)}
|
|
select.flt option{background:var(--s2)}
|
|
.flt-price{width:140px}
|
|
.flt-year{width:96px}
|
|
|
|
/* Stats */
|
|
.stats{padding:12px 16px;display:flex;gap:8px;overflow-x:auto}
|
|
.stat{background:var(--s1);border:1px solid var(--b);border-radius:10px;padding:9px 14px;flex-shrink:0;min-width:100px}
|
|
.stat-l{font-size:10px;color:var(--muted);font-weight:700;letter-spacing:.08em;text-transform:uppercase}
|
|
.stat-v{font-size:20px;font-weight:700;font-family:var(--mono);margin-top:2px}
|
|
|
|
/* Grid */
|
|
.grid{padding:0 16px 100px;display:grid;grid-template-columns:repeat(auto-fill,minmax(300px,1fr));gap:12px}
|
|
|
|
/* Card */
|
|
.card{background:var(--s1);border:1px solid var(--b);border-radius:14px;overflow:hidden;transition:border-color .2s,transform .1s;display:flex;flex-direction:column}
|
|
.card:hover{border-color:rgba(0,180,255,.25);transform:translateY(-1px)}
|
|
|
|
/* Photo area */
|
|
.card-photo{position:relative;height:170px;background:var(--s2);overflow:hidden;flex-shrink:0}
|
|
.card-photo img{width:100%;height:100%;object-fit:cover;transition:transform .3s}
|
|
.card:hover .card-photo img{transform:scale(1.03)}
|
|
.card-photo .no-photo{width:100%;height:100%;display:flex;flex-direction:column;align-items:center;justify-content:center;gap:8px;color:var(--muted)}
|
|
.card-photo .no-photo .icon{font-size:40px;opacity:.3}
|
|
.card-photo .no-photo .lbl{font-size:11px;opacity:.5}
|
|
.photo-badges{position:absolute;top:10px;left:10px;display:flex;gap:5px;flex-wrap:wrap}
|
|
.photo-score{position:absolute;top:10px;right:10px}
|
|
.pbadge{font-size:10px;font-weight:700;padding:3px 9px;border-radius:20px;border:1px solid;backdrop-filter:blur(4px)}
|
|
|
|
/* Card body */
|
|
.card-body{padding:12px 14px;display:flex;flex-direction:column;gap:8px;flex:1}
|
|
.card-name{font-size:14px;font-weight:700;line-height:1.3;color:var(--text)}
|
|
.card-loc{font-size:11px;color:var(--muted);display:flex;align-items:center;gap:4px}
|
|
.specs{display:grid;grid-template-columns:repeat(3,1fr);gap:5px}
|
|
.spec{background:var(--s2);border-radius:7px;padding:6px 8px}
|
|
.spec-k{font-size:9px;font-weight:700;color:var(--muted);text-transform:uppercase;letter-spacing:.07em}
|
|
.spec-v{font-size:12px;font-weight:500;font-family:var(--mono);margin-top:2px;color:var(--text)}
|
|
.desc{font-size:11px;color:var(--muted);line-height:1.6;display:-webkit-box;-webkit-line-clamp:2;-webkit-box-orient:vertical;overflow:hidden}
|
|
.flags{display:flex;flex-wrap:wrap;gap:4px}
|
|
.flag{font-size:10px;padding:2px 8px;border-radius:20px;background:rgba(0,180,255,.1);border:1px solid rgba(0,180,255,.2);color:var(--accent)}
|
|
|
|
/* Card footer */
|
|
.card-foot{display:flex;justify-content:space-between;align-items:center;border-top:1px solid var(--b);padding-top:10px;margin-top:auto}
|
|
.price{font-size:18px;font-weight:700;font-family:var(--mono)}
|
|
.card-btns{display:flex;gap:5px}
|
|
.cbtn{padding:6px 11px;border-radius:8px;font-size:11px;font-weight:700;border:1px solid var(--b);background:transparent;color:var(--muted);cursor:pointer;transition:.2s;letter-spacing:.03em}
|
|
.cbtn:hover{border-color:var(--b2);color:var(--text)}
|
|
.cbtn.saved{background:rgba(0,229,160,.1);border-color:rgba(0,229,160,.3);color:var(--green)}
|
|
.cbtn.primary{background:rgba(0,180,255,.12);border-color:rgba(0,180,255,.3);color:var(--accent)}
|
|
|
|
/* Score ring */
|
|
.ring{position:relative;width:44px;height:44px;flex-shrink:0}
|
|
.ring svg{position:absolute;top:0;left:0}
|
|
.ring-n{position:absolute;inset:0;display:flex;align-items:center;justify-content:center;font-size:12px;font-weight:700;font-family:var(--mono)}
|
|
|
|
/* Panels */
|
|
.panel{position:fixed;inset:0;z-index:500;display:none;align-items:flex-end;background:rgba(0,0,0,.75);backdrop-filter:blur(6px)}
|
|
.panel.show{display:flex}
|
|
.pbox{background:var(--s1);border-top:1px solid var(--b);border-radius:20px 20px 0 0;padding:22px 18px;width:100%;max-width:600px;margin:0 auto;max-height:92vh;overflow-y:auto}
|
|
.ptitle{font-size:15px;font-weight:800;margin-bottom:14px;display:flex;justify-content:space-between;align-items:center}
|
|
.pclose{background:none;border:none;color:var(--muted);cursor:pointer;font-size:20px;line-height:1;padding:0 2px}
|
|
.field{margin-bottom:12px}
|
|
.field label{display:block;font-size:11px;font-weight:700;color:var(--muted);letter-spacing:.07em;text-transform:uppercase;margin-bottom:5px}
|
|
.field input,.field select,.field textarea{width:100%;background:var(--s2);border:1px solid var(--b);border-radius:9px;padding:9px 12px;color:var(--text);font-size:13px;transition:.2s}
|
|
.field input:focus,.field select:focus,.field textarea:focus{border-color:rgba(0,180,255,.4)}
|
|
.field textarea{min-height:80px;resize:vertical}
|
|
.btn-full{width:100%;background:var(--accent);color:#000;border:none;border-radius:10px;padding:12px;font-size:13px;font-weight:800;cursor:pointer;margin-top:6px;transition:.15s;letter-spacing:.02em}
|
|
.btn-full:hover{opacity:.9}
|
|
.btn-full:active{transform:scale(.98)}
|
|
.btn-sec{background:var(--s2);border:1px solid var(--b);color:var(--text)}
|
|
.fg2{display:grid;grid-template-columns:1fr 1fr;gap:10px}
|
|
|
|
/* Status bar */
|
|
.sbar{position:fixed;bottom:0;left:0;right:0;background:var(--s1);border-top:1px solid var(--b);padding:7px 18px;display:flex;align-items:center;justify-content:space-between;z-index:100;font-size:11px;color:var(--muted)}
|
|
.sdot{width:7px;height:7px;border-radius:50%;background:var(--green);flex-shrink:0}
|
|
.sdot.off{background:var(--red)}
|
|
@keyframes pulse{0%,100%{opacity:.5}50%{opacity:1}}
|
|
.spin{width:30px;height:30px;border:2px solid var(--b2);border-top-color:var(--accent);border-radius:50%;animation:spin 1s linear infinite}
|
|
@keyframes spin{to{transform:rotate(360deg)}}
|
|
|
|
/* Toast */
|
|
.toast{position:fixed;bottom:72px;left:50%;transform:translateX(-50%);background:var(--s3);border:1px solid var(--b2);border-radius:10px;padding:9px 18px;font-size:13px;font-weight:600;z-index:999;opacity:0;transition:opacity .25s;pointer-events:none;white-space:nowrap}
|
|
.toast.show{opacity:1}
|
|
|
|
/* Empty */
|
|
.empty{text-align:center;padding:50px 20px;color:var(--muted)}
|
|
.empty-icon{font-size:44px;margin-bottom:12px;opacity:.4}
|
|
|
|
/* Search progress */
|
|
.srch-prog{background:var(--s2);border:1px solid var(--b);border-radius:12px;padding:20px;text-align:center;margin-top:14px}
|
|
.srch-prog .spin{margin:0 auto 12px}
|
|
.srch-log{max-height:120px;overflow-y:auto;text-align:left;font-size:11px;color:var(--muted);font-family:var(--mono);margin-top:10px;padding:8px;background:var(--bg);border-radius:8px}
|
|
|
|
/* Table */
|
|
.twrap{padding:0 16px 100px;overflow-x:auto}
|
|
table{width:100%;border-collapse:collapse;min-width:900px}
|
|
th{padding:9px 12px;text-align:left;font-size:10px;font-weight:700;color:var(--muted);text-transform:uppercase;letter-spacing:.08em;border-bottom:1px solid var(--b);white-space:nowrap}
|
|
td{padding:9px 12px;font-size:12px;border-bottom:1px solid rgba(255,255,255,.03)}
|
|
tr:hover td{background:rgba(255,255,255,.02)}
|
|
|
|
/* Sources grid */
|
|
.src-cat{margin-bottom:18px}
|
|
.src-cat-title{font-size:11px;font-weight:700;color:var(--muted);letter-spacing:.08em;text-transform:uppercase;margin-bottom:8px}
|
|
.src-grid{display:grid;grid-template-columns:repeat(auto-fill,minmax(150px,1fr));gap:6px}
|
|
.src-item{background:var(--s2);border:1px solid var(--b);border-radius:8px;padding:7px 10px;display:flex;justify-content:space-between;align-items:center}
|
|
.src-name{font-size:11px;font-weight:600}
|
|
.src-type{font-size:10px;color:var(--muted)}
|
|
|
|
@media(min-width:640px){
|
|
.grid{grid-template-columns:repeat(2,1fr)}
|
|
.hdr{padding:12px 24px}
|
|
.stats{padding:12px 24px}
|
|
.fltbar{padding:9px 24px}
|
|
.tabs{padding:0 24px}
|
|
.grid{padding:0 24px 100px}
|
|
}
|
|
@media(min-width:1100px){.grid{grid-template-columns:repeat(3,1fr)}}
|
|
@media(min-width:1400px){.grid{grid-template-columns:repeat(4,1fr)}}
|
|
|
|
.login-overlay{position:fixed;inset:0;background:var(--bg);z-index:9999;display:flex;align-items:center;justify-content:center;padding:20px}
|
|
.login-box{background:var(--s1);border:1px solid var(--b2);border-radius:16px;padding:32px;width:100%;max-width:360px}
|
|
.login-logo{text-align:center;margin-bottom:24px}
|
|
.login-logo .icon{font-size:36px;margin-bottom:8px}
|
|
.login-logo .name{font-size:20px;font-weight:800;letter-spacing:-.02em}
|
|
.login-err{background:rgba(255,82,82,.1);border:1px solid rgba(255,82,82,.3);border-radius:8px;padding:9px 12px;font-size:12px;color:var(--red);margin-bottom:12px;display:none}
|
|
</style>
|
|
</head>
|
|
<body>
|
|
|
|
<!-- Header -->
|
|
<div class="hdr">
|
|
<div class="logo">
|
|
<div class="logo-icon">⚓</div>
|
|
<span><span style="color:var(--accent)">Boat</span><span style="color:var(--muted);font-weight:400">&</span><span style="color:var(--green)">Ship</span><span style="color:var(--text);font-weight:400">-Finder</span></span>
|
|
</div>
|
|
<div class="search-wrap">
|
|
<input class="search-in" id="mainSearch" placeholder="Buscar: tug acero subasta, offshore vessel Florida, pesquero noruego…" onkeydown="if(event.key==='Enter')doSearch()">
|
|
<button class="search-go" onclick="doSearch()">⌕</button>
|
|
</div>
|
|
<div class="hdr-btns">
|
|
<span id="headerUser" style="font-size:11px;color:var(--muted);display:flex;align-items:center;gap:6px"></span>
|
|
<button class="hbtn" onclick="doLogout()" title="Cerrar sesión">⎋ Salir</button>
|
|
<button class="hbtn" onclick="openPanel('analyze')">📋 Analizar</button>
|
|
<button class="hbtn" onclick="openPanel('add')">+ Agregar</button>
|
|
<button class="hbtn" onclick="openPanel('sources')">🌐 Fuentes</button>
|
|
<button class="hbtn" id="viewBtn" onclick="toggleView()">☰ Tabla</button>
|
|
</div>
|
|
</div>
|
|
|
|
<!-- Tabs -->
|
|
<div class="tabs">
|
|
<button class="tab on" onclick="switchTab('all',this)">🌊 Todas</button>
|
|
<button class="tab" onclick="switchTab('saved',this)">★ Guardadas <span id="savedCount" style="background:rgba(0,229,160,.2);color:var(--green);font-size:10px;padding:1px 6px;border-radius:10px;margin-left:4px;display:none">0</span></button>
|
|
<button class="tab" onclick="switchTab('auction',this)">🔨 Subastas</button>
|
|
<button class="tab" onclick="switchTab('salvage',this)">🔧 Salvage</button>
|
|
<button class="tab" onclick="switchTab('alerts',this)">🔔 Alertas</button>
|
|
<button class="tab" onclick="switchTab('history',this)">🕐 Historial</button>
|
|
<button class="tab" onclick="switchTab('collections',this)">📁 Carpetas</button>
|
|
</div>
|
|
|
|
<!-- Selection toolbar -->
|
|
<div id="selBar" style="display:none;background:rgba(0,180,255,.1);border-bottom:1px solid rgba(0,180,255,.25);padding:9px 18px;display:none;align-items:center;gap:12px;flex-wrap:wrap">
|
|
<span style="font-size:13px;font-weight:700;color:var(--accent)" id="selCount">0 seleccionadas</span>
|
|
<button onclick="saveSelectedToCollection()" style="background:var(--accent);color:#000;border:none;border-radius:8px;padding:7px 16px;font-size:12px;font-weight:800;cursor:pointer">
|
|
📁 Guardar en carpeta
|
|
</button>
|
|
<button onclick="clearSelection()" style="background:none;border:1px solid var(--b2);border-radius:8px;padding:7px 12px;font-size:12px;color:var(--muted);cursor:pointer">
|
|
Cancelar selección
|
|
</button>
|
|
<span style="font-size:11px;color:var(--muted)">Clic en ☐ de cada tarjeta para seleccionar</span>
|
|
</div>
|
|
|
|
<!-- Filters -->
|
|
<div class="fltbar" id="fltbar">
|
|
<select class="flt" onchange="state.f.type=this.value;loadVessels()">
|
|
<option value="">Todos los tipos</option>
|
|
<option>Yacht</option><option>Motor</option><option>Sailboat</option>
|
|
<option>Fishing</option><option>Tug</option><option>Barge</option>
|
|
<option>Offshore</option><option>Ferry</option><option>Other</option>
|
|
</select>
|
|
<select class="flt" onchange="state.f.status=this.value;loadVessels()">
|
|
<option value="">Todos los estados</option>
|
|
<option value="active">Activo</option>
|
|
<option value="auction">Subasta</option>
|
|
<option value="salvage">Salvage</option>
|
|
<option value="abandoned">Abandonado</option>
|
|
</select>
|
|
<select class="flt" onchange="state.f.hull=this.value;loadVessels()">
|
|
<option value="">Cualquier casco</option>
|
|
<option>Fiberglass</option><option>Steel</option>
|
|
<option>Aluminum</option><option>Wood</option><option>Composite</option>
|
|
</select>
|
|
<input class="flt flt-price" type="number" placeholder="Precio máx. USD" min="0"
|
|
oninput="state.f.max_price=this.value;loadVessels()">
|
|
<input class="flt flt-price" type="number" placeholder="LOA mín. (m)" min="0"
|
|
oninput="state.f.min_loa=this.value;loadVessels()">
|
|
<input class="flt flt-year" type="number" placeholder="Año desde" min="1900" max="2026"
|
|
oninput="state.f.year_min=this.value;loadVessels()">
|
|
<input class="flt flt-year" type="number" placeholder="Año hasta" min="1900" max="2026"
|
|
oninput="state.f.year_max=this.value;loadVessels()">
|
|
<select class="flt" onchange="state.f.sort=this.value;loadVessels()">
|
|
<option value="score">↓ Mejor oportunidad</option>
|
|
<option value="price_asc">$ Precio ↑</option>
|
|
<option value="price_desc">$ Precio ↓</option>
|
|
<option value="loa">LOA ↓</option>
|
|
<option value="year">Año ↓</option>
|
|
<option value="newest">Más recientes</option>
|
|
</select>
|
|
</div>
|
|
|
|
<!-- Stats -->
|
|
<div class="stats">
|
|
<div class="stat"><div class="stat-l">Total</div><div class="stat-v" id="sTotal">—</div></div>
|
|
<div class="stat"><div class="stat-l">Score prom.</div><div class="stat-v" id="sScore" style="color:var(--green)">—</div></div>
|
|
<div class="stat"><div class="stat-l">Precio prom.</div><div class="stat-v" id="sPrice" style="font-size:14px">—</div></div>
|
|
<div class="stat"><div class="stat-l">Subastas</div><div class="stat-v" id="sAuct" style="color:var(--amber)">—</div></div>
|
|
<div class="stat"><div class="stat-l">Salvage</div><div class="stat-v" id="sSalv" style="color:var(--red)">—</div></div>
|
|
<div class="stat"><div class="stat-l">Guardadas</div><div class="stat-v" id="sSaved" style="color:var(--accent)">—</div></div>
|
|
</div>
|
|
|
|
<!-- Content -->
|
|
<div id="content"></div>
|
|
|
|
<!-- Status bar -->
|
|
<div class="sbar">
|
|
<div style="display:flex;align-items:center;gap:8px">
|
|
<div class="sdot" id="sdot"></div>
|
|
<span id="sstat">Conectando…</span>
|
|
</div>
|
|
<div style="display:flex;gap:14px">
|
|
<span id="smodel"></span>
|
|
<span id="ssrc"></span>
|
|
</div>
|
|
</div>
|
|
|
|
<div class="toast" id="toast"></div>
|
|
|
|
<!-- Panel: Search -->
|
|
<div class="panel" id="pSearch" onclick="if(event.target===this)closePanel('search')">
|
|
<div class="pbox">
|
|
<div class="ptitle">⚡ Búsqueda Global Real-Time <button class="pclose" onclick="closePanel('search')">✕</button></div>
|
|
<p style="font-size:12px;color:var(--muted);margin-bottom:14px;line-height:1.6">
|
|
Busca en <strong style="color:var(--text)">todo internet simultáneamente</strong> — subastas, clasificados, astilleros, periódicos, revistas náuticas, registros de buques y más. La IA extrae datos reales de cada resultado.
|
|
</p>
|
|
<div class="field"><label>¿Qué buscas?</label>
|
|
<input id="aiQ" placeholder="Ej: offshore support vessel steel 50m+ auction Gulf, pesquero acero salvage, tug harbor diesel 2000HP…">
|
|
</div>
|
|
<div class="fg2">
|
|
<div class="field"><label>Tipo de embarcación</label>
|
|
<select id="aiType"><option value="">Todos</option><option>Yacht</option><option>Motor</option><option>Sailboat</option><option>Fishing</option><option>Tug</option><option>Barge</option><option>Offshore</option><option>Ferry</option></select>
|
|
</div>
|
|
<div class="field"><label>Estado preferido</label>
|
|
<select id="aiStat"><option value="">Cualquiera</option><option value="active">Activo</option><option value="auction">Subasta</option><option value="salvage">Salvage</option></select>
|
|
</div>
|
|
<div class="field"><label>Precio máx. (USD)</label>
|
|
<input id="aiPrice" type="number" placeholder="Sin límite">
|
|
</div>
|
|
<div class="field"><label>LOA mín. (metros)</label>
|
|
<input id="aiLoa" type="number" placeholder="Ej: 10">
|
|
</div>
|
|
<div class="field"><label>LOA máx. (metros)</label>
|
|
<input id="aiLoaMax" type="number" placeholder="Sin límite">
|
|
</div>
|
|
<div class="field"><label>Año mín.</label>
|
|
<input id="aiYearMin" type="number" placeholder="Ej: 1990" min="1900" max="2025">
|
|
</div>
|
|
<div class="field"><label>Año máx.</label>
|
|
<input id="aiYearMax" type="number" placeholder="Ej: 2010" min="1900" max="2025">
|
|
</div>
|
|
</div>
|
|
<div class="field"><label>Región / País</label>
|
|
<input id="aiRegion" placeholder="USA, Europa, Caribe, Asia, Global…">
|
|
</div>
|
|
<div class="field"><label>Notas adicionales</label>
|
|
<textarea id="aiNotes" placeholder="Material, uso previsto, condición, historial…"></textarea>
|
|
</div>
|
|
<button class="btn-full" id="srchBtn" onclick="runSearch()">⚡ Buscar en Todo Internet</button>
|
|
<div id="srchResult"></div>
|
|
</div>
|
|
</div>
|
|
|
|
<!-- Panel: Analyze -->
|
|
<div class="panel" id="pAnalyze" onclick="if(event.target===this)closePanel('analyze')">
|
|
<div class="pbox">
|
|
<div class="ptitle">📋 Analizar Anuncio <button class="pclose" onclick="closePanel('analyze')">✕</button></div>
|
|
<p style="font-size:12px;color:var(--muted);margin-bottom:14px">Pega cualquier texto — anuncio de periódico, email, clasificado, descripción de subasta. La IA extrae todos los datos técnicos.</p>
|
|
<div class="field"><label>Fuente (opcional)</label>
|
|
<input id="aSrc" placeholder="Nombre del sitio, periódico, contacto…">
|
|
</div>
|
|
<div class="field"><label>Texto del anuncio</label>
|
|
<textarea id="aTxt" style="min-height:160px" placeholder="Pega aquí el texto completo…"></textarea>
|
|
</div>
|
|
<button class="btn-full" id="aBtn" onclick="runAnalyze()">🤖 Extraer con IA</button>
|
|
<div id="aResult"></div>
|
|
</div>
|
|
</div>
|
|
|
|
<!-- Panel: Add -->
|
|
<div class="panel" id="pAdd" onclick="if(event.target===this)closePanel('add')">
|
|
<div class="pbox">
|
|
<div class="ptitle">+ Agregar Embarcación <button class="pclose" onclick="closePanel('add')">✕</button></div>
|
|
<div class="fg2">
|
|
<div class="field" style="grid-column:1/-1"><label>Nombre</label><input id="nName" placeholder="Nombre del barco"></div>
|
|
<div class="field"><label>Tipo</label>
|
|
<select id="nType"><option>Yacht</option><option>Motor</option><option>Sailboat</option><option>Fishing</option><option>Tug</option><option>Barge</option><option>Offshore</option><option>Other</option></select>
|
|
</div>
|
|
<div class="field"><label>Estado</label>
|
|
<select id="nStat"><option value="active">Activo</option><option value="auction">Subasta</option><option value="salvage">Salvage</option><option value="abandoned">Abandonado</option></select>
|
|
</div>
|
|
<div class="field"><label>LOA (metros)</label><input id="nLoa" type="number" step="0.1" placeholder="0.0"></div>
|
|
<div class="field"><label>Manga (metros)</label><input id="nBeam" type="number" step="0.1" placeholder="0.0"></div>
|
|
<div class="field"><label>Calado (metros)</label><input id="nDraft" type="number" step="0.1" placeholder="0.0"></div>
|
|
<div class="field"><label>Año</label><input id="nYear" type="number" placeholder="2000"></div>
|
|
<div class="field"><label>Casco</label>
|
|
<select id="nHull"><option>Fiberglass</option><option>Steel</option><option>Aluminum</option><option>Wood</option><option>Composite</option><option>Unknown</option></select>
|
|
</div>
|
|
<div class="field"><label>Propulsión</label>
|
|
<select id="nProp"><option>Diesel</option><option>Gasoline</option><option>Electric</option><option>Sail</option><option>None</option><option>Unknown</option></select>
|
|
</div>
|
|
<div class="field"><label>Precio (USD)</label><input id="nPrice" type="number" placeholder="Precio"></div>
|
|
<div class="field" style="grid-column:1/-1"><label>Ubicación</label><input id="nLoc" placeholder="Ciudad, País"></div>
|
|
<div class="field" style="grid-column:1/-1"><label>URL del anuncio</label><input id="nUrl" placeholder="https://…"></div>
|
|
<div class="field" style="grid-column:1/-1"><label>Foto (URL de imagen)</label><input id="nImg" placeholder="https://imagen.jpg"></div>
|
|
<div class="field" style="grid-column:1/-1"><label>Fuente</label><input id="nSrc2" placeholder="YachtWorld, Craigslist, Contacto directo…"></div>
|
|
<div class="field" style="grid-column:1/-1"><label>Descripción</label><textarea id="nDesc" placeholder="Condición, historia, notas…"></textarea></div>
|
|
</div>
|
|
<button class="btn-full" onclick="addVessel()">Guardar Embarcación</button>
|
|
</div>
|
|
</div>
|
|
|
|
<!-- Panel: Sources -->
|
|
<div class="panel" id="pSources" onclick="if(event.target===this)closePanel('sources')">
|
|
<div class="pbox">
|
|
<div class="ptitle">🌐 Fuentes Globales <button class="pclose" onclick="closePanel('sources')">✕</button></div>
|
|
|
|
<!-- Facebook Marketplace setup -->
|
|
<div id="fbSetupBox" style="background:rgba(24,119,242,.1);border:1px solid rgba(24,119,242,.35);border-radius:12px;padding:14px;margin-bottom:16px">
|
|
<div style="display:flex;align-items:center;gap:10px;margin-bottom:8px">
|
|
<span style="font-size:20px">📘</span>
|
|
<div>
|
|
<div style="font-size:12px;font-weight:700;color:#4a9eff">Facebook Marketplace</div>
|
|
<div id="fbStatus" style="font-size:11px;color:var(--muted)">Verificando sesión…</div>
|
|
</div>
|
|
<button id="fbSetupBtn" onclick="setupFacebook()" style="margin-left:auto;background:rgba(24,119,242,.2);border:1px solid rgba(24,119,242,.5);border-radius:8px;padding:6px 14px;color:#4a9eff;font-size:11px;font-weight:700;cursor:pointer;font-family:inherit;white-space:nowrap">
|
|
Configurar FB
|
|
</button>
|
|
</div>
|
|
<div style="font-size:10px;color:var(--muted);line-height:1.6">
|
|
⚠ Usa una <strong>cuenta secundaria</strong>, no tu cuenta personal. Al hacer clic, se abrirá un navegador para que inicies sesión manualmente — solo se hace una vez.
|
|
</div>
|
|
</div>
|
|
|
|
<!-- Add source form -->
|
|
<div style="background:var(--s2);border:1px solid var(--b);border-radius:12px;padding:14px;margin-bottom:16px">
|
|
<div style="font-size:12px;font-weight:700;color:var(--accent);margin-bottom:10px">+ Agregar nueva fuente</div>
|
|
<div style="display:grid;grid-template-columns:1fr 1fr;gap:8px;margin-bottom:8px">
|
|
<input id="newSrcName" placeholder="Nombre ej: SailNL" style="background:var(--s1);border:1px solid var(--b);border-radius:8px;padding:7px 10px;color:var(--text);font-size:12px">
|
|
<select id="newSrcType" style="background:var(--s1);border:1px solid var(--b);border-radius:8px;padding:7px 10px;color:var(--text);font-size:12px">
|
|
<option value="broker">Broker / Especializado</option>
|
|
<option value="classifieds">Clasificados</option>
|
|
<option value="auction">Subasta</option>
|
|
<option value="salvage">Salvage</option>
|
|
<option value="commercial">Comercial</option>
|
|
<option value="news">Noticias</option>
|
|
</select>
|
|
</div>
|
|
<div style="margin-bottom:8px">
|
|
<input id="newSrcUrl" placeholder="URL: https://sitio.com/buscar (se agrega ?q= automáticamente)" style="width:100%;background:var(--s1);border:1px solid var(--b);border-radius:8px;padding:7px 10px;color:var(--text);font-size:12px">
|
|
</div>
|
|
<div style="display:grid;grid-template-columns:1fr auto;gap:8px">
|
|
<input id="newSrcCat" placeholder="Categoría ej: Veleros Holanda" style="background:var(--s1);border:1px solid var(--b);border-radius:8px;padding:7px 10px;color:var(--text);font-size:12px">
|
|
<button onclick="addCustomSource()" style="background:var(--accent);color:#000;border:none;border-radius:8px;padding:7px 16px;font-size:12px;font-weight:800;cursor:pointer;white-space:nowrap">Agregar ✓</button>
|
|
</div>
|
|
<div style="font-size:10px;color:var(--muted);margin-top:6px">
|
|
💡 Si la URL ya tiene búsqueda: pega la URL completa con <code style="color:var(--accent)">{query}</code> donde va el texto.<br>
|
|
Ejemplo: <code style="color:var(--muted)">https://www.sitio.com/buscar?texto={query}&tipo=barco</code>
|
|
</div>
|
|
</div>
|
|
|
|
<div id="srcList"></div>
|
|
</div>
|
|
</div>
|
|
|
|
<!-- Panel: Alert -->
|
|
<div class="panel" id="pAlert" onclick="if(event.target===this)closePanel('alert')">
|
|
<div class="pbox">
|
|
<div class="ptitle">🔔 Nueva Alerta <button class="pclose" onclick="closePanel('alert')">✕</button></div>
|
|
<div class="field"><label>Nombre</label><input id="alName" placeholder="Ej: Tugs baratos Florida"></div>
|
|
<div class="fg2">
|
|
<div class="field"><label>Tipo</label>
|
|
<select id="alType"><option value="">Todos</option><option>Yacht</option><option>Fishing</option><option>Tug</option><option>Barge</option><option>Offshore</option></select>
|
|
</div>
|
|
<div class="field"><label>Estado</label>
|
|
<select id="alStat"><option value="">Cualquiera</option><option value="auction">Subasta</option><option value="salvage">Salvage</option></select>
|
|
</div>
|
|
<div class="field"><label>Precio máx. USD</label><input id="alPrice" type="number" placeholder="Sin límite"></div>
|
|
<div class="field"><label>LOA mín. (m)</label><input id="alLoa" type="number" placeholder="Cualquiera"></div>
|
|
</div>
|
|
<button class="btn-full" onclick="createAlert()">Crear Alerta</button>
|
|
</div>
|
|
</div>
|
|
|
|
<script>
|
|
const $ = id => document.getElementById(id);
|
|
|
|
// ── Auth ───────────────────────────────────────────────────────────────────
|
|
let currentUser = null;
|
|
|
|
async function checkAuth(){
|
|
try{
|
|
const r = await fetch('/api/me', {credentials:'include'});
|
|
if(r.ok){
|
|
const d = await r.json();
|
|
if(d.logged_in){
|
|
currentUser = d;
|
|
showApp();
|
|
return;
|
|
}
|
|
}
|
|
} catch(e){}
|
|
showLogin();
|
|
}
|
|
|
|
function showLogin(){
|
|
$('loginOverlay').style.display = 'flex';
|
|
$('mainApp').style.display = 'none';
|
|
setTimeout(()=>$('loginUser').focus(), 100);
|
|
}
|
|
|
|
function showApp(){
|
|
$('loginOverlay').style.display = 'none';
|
|
$('mainApp').style.display = 'block';
|
|
// Update user display in header
|
|
const userEl = $('headerUser');
|
|
if(userEl && currentUser) userEl.textContent = '👤 ' + currentUser.username;
|
|
checkStatus();
|
|
loadVessels();
|
|
}
|
|
|
|
async function doLogin(){
|
|
const username = $('loginUser').value.trim();
|
|
const password = $('loginPass').value;
|
|
if(!username || !password){ $('loginErr').style.display='block'; $('loginErr').textContent='Completa usuario y contraseña'; return; }
|
|
$('loginBtn').textContent = 'Entrando…';
|
|
$('loginBtn').disabled = true;
|
|
try{
|
|
const r = await fetch('/api/login', {
|
|
method:'POST', credentials:'include',
|
|
headers:{'Content-Type':'application/json'},
|
|
body: JSON.stringify({username, password})
|
|
});
|
|
const d = await r.json();
|
|
if(d.ok){
|
|
currentUser = d;
|
|
$('loginErr').style.display = 'none';
|
|
showApp();
|
|
} else {
|
|
$('loginErr').style.display = 'block';
|
|
$('loginErr').textContent = d.error || 'Error de autenticación';
|
|
}
|
|
} catch(e){
|
|
$('loginErr').style.display = 'block';
|
|
$('loginErr').textContent = 'Error de conexión con el servidor';
|
|
}
|
|
$('loginBtn').textContent = 'Entrar →';
|
|
$('loginBtn').disabled = false;
|
|
}
|
|
|
|
async function doLogout(){
|
|
await fetch('/api/logout', {method:'POST', credentials:'include'});
|
|
currentUser = null;
|
|
showLogin();
|
|
}
|
|
const API = '';
|
|
|
|
const FLAG_L = {
|
|
below_market:'💰 Precio bajo', rare:'⭐ Raro',
|
|
salvage_value:'🔧 Valor salvage', motivated_seller:'🔥 Urgente',
|
|
auction:'🔨 Subasta', commercial:'🏭 Comercial',
|
|
government_surplus:'🏛 Excedente gobierno'
|
|
};
|
|
const SC = {active:'#00e5a0',auction:'#ffb547',salvage:'#ff5252',abandoned:'#5a6a88',sold:'#5a6a88'};
|
|
const SL = {active:'Activo',auction:'Subasta',salvage:'Salvage',abandoned:'Abandonado',sold:'Vendido'};
|
|
const TI = {Yacht:'⚓',Fishing:'🎣',Sailboat:'⛵',Tug:'🚢',Motor:'🛥️',Barge:'🔵',Offshore:'🔧',Ferry:'⛴️',Other:'🚤'};
|
|
|
|
// Placeholder images by vessel type
|
|
const TYPE_IMG = {
|
|
Yacht:'https://images.unsplash.com/photo-1567899378494-47b22a2ae96a?w=600&q=80',
|
|
Sailboat:'https://images.unsplash.com/photo-1506197603052-3cc9c3a201bd?w=600&q=80',
|
|
Fishing:'https://images.unsplash.com/photo-1511884642898-4c92249e20b6?w=600&q=80',
|
|
Tug:'https://images.unsplash.com/photo-1568430462989-44163eb1752f?w=600&q=80',
|
|
Barge:'https://images.unsplash.com/photo-1558618666-fcd25c85cd64?w=600&q=80',
|
|
Offshore:'https://images.unsplash.com/photo-1504309092620-4d0ec726efa4?w=600&q=80',
|
|
Motor:'https://images.unsplash.com/photo-1520637836993-a081443f2a56?w=600&q=80',
|
|
Ferry:'https://images.unsplash.com/photo-1534889156165-d5de8e545c9d?w=600&q=80',
|
|
Other:'https://images.unsplash.com/photo-1493770348161-369560ae357d?w=600&q=80',
|
|
};
|
|
|
|
const state = {
|
|
tab:'all', view:'grid', vessels:[], savedIds:new Set(),
|
|
selected:new Set(), collections:[], activeCollection:null,
|
|
f:{type:'',status:'',hull:'',max_price:'',min_loa:'',year_min:'',year_max:'',sort:'score'}
|
|
};
|
|
|
|
// Score helpers
|
|
function sc(s){return s>=80?'var(--green)':s>=60?'var(--amber)':'var(--muted)'}
|
|
function ring(s){
|
|
const c=sc(s),r=19,ci=2*Math.PI*r,d=(s/100)*ci;
|
|
return `<div class="ring"><svg width="44" height="44" viewBox="0 0 44 44">
|
|
<circle cx="22" cy="22" r="${r}" fill="none" stroke="rgba(255,255,255,.07)" stroke-width="2.5"/>
|
|
<circle cx="22" cy="22" r="${r}" fill="none" stroke="${c}" stroke-width="2.5"
|
|
stroke-dasharray="${d} ${ci}" stroke-dashoffset="${ci/4}" stroke-linecap="round"/>
|
|
</svg><div class="ring-n" style="color:${c}">${Math.round(s)}</div></div>`;
|
|
}
|
|
|
|
// Card renderer
|
|
function card(v){
|
|
const stc=SC[v.status]||'#5a6a88';
|
|
const saved=state.savedIds.has(v.id);
|
|
const flags=(v.flags||[]).filter(f=>FLAG_L[f]);
|
|
const price=v.price_usd?'$'+Math.round(v.price_usd).toLocaleString():'Consultar';
|
|
const pc=(v.score||0)>=80?'var(--green)':'var(--text)';
|
|
|
|
// Photo: use first image from array, or type placeholder
|
|
const imgs=v.images||[];
|
|
const _PROXY_HOSTS=['sailboatlistings.com','yachtworld.com','boattrader.com',
|
|
'apolloduck.com','rightboat.com','boat24.com','seaboats.net','boats.com'];
|
|
function proxyImg(u){
|
|
if(!u||!u.startsWith('http')) return u;
|
|
try{ const h=new URL(u).hostname; if(_PROXY_HOSTS.some(d=>h.includes(d))) return '/api/img_proxy?url='+encodeURIComponent(u); } catch(e){}
|
|
return u;
|
|
}
|
|
let imgSrc = imgs.length ? proxyImg(imgs[0]) : (TYPE_IMG[v.vessel_type]||TYPE_IMG.Other);
|
|
// If stored image looks like a real URL use it, else use placeholder
|
|
const hasRealImg = imgs.length > 0 && imgs[0].startsWith('http');
|
|
|
|
return `<div class="card">
|
|
<div class="card-photo" onclick="toggleSelect(${v.id},event)" style="cursor:pointer">
|
|
<img src="${imgSrc}" alt="${v.name||''}" loading="lazy"
|
|
onerror="this.style.display='none';this.nextElementSibling.style.display='flex'">
|
|
<div class="no-photo" style="display:none">
|
|
<div class="icon">${TI[v.vessel_type]||'🚢'}</div>
|
|
<div class="lbl">${v.vessel_type||'Vessel'}</div>
|
|
</div>
|
|
<div class="photo-badges">
|
|
<span class="pbadge" style="color:${stc};border-color:${stc}40;background:rgba(0,0,0,.55)">${SL[v.status]||v.status}</span>
|
|
${flags.slice(0,2).map(f=>`<span class="pbadge" style="color:var(--accent);border-color:rgba(0,180,255,.3);background:rgba(0,0,0,.55)">${FLAG_L[f]}</span>`).join('')}
|
|
</div>
|
|
<div class="photo-score">${ring(v.score||0)}</div>
|
|
<div id="chk-${v.id}" style="position:absolute;bottom:10px;left:10px;width:24px;height:24px;border-radius:6px;border:2px solid rgba(255,255,255,.7);background:${state.selected.has(v.id)?'var(--accent)':'rgba(0,0,0,.4)'};display:flex;align-items:center;justify-content:center;font-size:13px;transition:.15s">
|
|
${state.selected.has(v.id)?'✓':''}
|
|
</div>
|
|
</div>
|
|
<div class="card-body">
|
|
<div>
|
|
<div style="display:flex;align-items:center;gap:5px;margin-bottom:4px;flex-wrap:wrap">
|
|
<span style="font-size:11px;background:var(--s2);padding:2px 8px;border-radius:6px;color:var(--muted);font-weight:700">${v.vessel_type||'?'}</span>
|
|
${v.source_name?`<span style="font-size:10px;font-weight:600;padding:2px 7px;border-radius:5px;background:rgba(0,180,255,.10);color:var(--accent);border:1px solid rgba(0,180,255,.22);white-space:nowrap;overflow:hidden;text-overflow:ellipsis;max-width:130px" title="${v.source_name}">🔗 ${v.source_name}</span>`:''}
|
|
</div>
|
|
<div class="card-name">${v.name||'Sin nombre'}</div>
|
|
<div class="card-loc">📍 ${v.location||'—'}${v.country?' ('+v.country+')':''}</div>
|
|
</div>
|
|
<div class="specs">
|
|
<div class="spec"><div class="spec-k">LOA</div><div class="spec-v">${v.loa_m?v.loa_m+'m':'—'}</div></div>
|
|
<div class="spec"><div class="spec-k">Manga</div><div class="spec-v">${v.beam_m?v.beam_m+'m':'—'}</div></div>
|
|
<div class="spec"><div class="spec-k">Calado</div><div class="spec-v">${v.draft_m?v.draft_m+'m':'—'}</div></div>
|
|
<div class="spec"><div class="spec-k">Año</div><div class="spec-v">${v.year_built||'—'}</div></div>
|
|
<div class="spec"><div class="spec-k">Casco</div><div class="spec-v">${v.hull||'—'}</div></div>
|
|
<div class="spec"><div class="spec-k">Motor</div><div class="spec-v">${v.propulsion||'—'}</div></div>
|
|
</div>
|
|
${v.description?`<div class="desc">${v.description}</div>`:''}
|
|
${flags.length?`<div class="flags">${flags.map(f=>`<span class="flag">${FLAG_L[f]}</span>`).join('')}</div>`:''}
|
|
<div class="card-foot">
|
|
<div>
|
|
<span class="price" style="color:${pc}">${price}</span>
|
|
${v.currency&&v.currency!=='USD'?`<span style="font-size:10px;color:var(--muted);margin-left:3px">${v.currency}</span>`:''}
|
|
</div>
|
|
<div class="card-btns">
|
|
<button class="cbtn ${saved?'saved':''}" onclick="toggleSave(${v.id})" style="${saved?'':'border-color:rgba(0,229,160,.3);color:var(--green)'}">${saved?'★ Guardada':'☆ Guardar'}</button>
|
|
${v.source_url?`<button class="cbtn primary" onclick="openSrc(${v.id})">Ver →</button>`:''}
|
|
</div>
|
|
</div>
|
|
</div>
|
|
</div>`;
|
|
}
|
|
|
|
function renderGrid(v){
|
|
$('content').innerHTML=`<div class="grid">${v.map(card).join('')}</div>`;
|
|
}
|
|
|
|
function renderTable(vessels){
|
|
$('content').innerHTML=`<div class="twrap"><table>
|
|
<thead><tr><th>Score</th><th>Foto</th><th>Nombre</th><th>Tipo</th><th>Estado</th><th>LOA</th><th>Año</th><th>Precio</th><th>Ubicación</th><th>Fuente</th><th></th></tr></thead>
|
|
<tbody>${vessels.map(v=>{
|
|
const imgs=v.images||[];
|
|
const imgSrc=imgs.length?imgs[0]:(TYPE_IMG[v.vessel_type]||TYPE_IMG.Other);
|
|
const saved=state.savedIds.has(v.id);
|
|
return `<tr>
|
|
<td style="font-family:var(--mono);font-weight:700;color:${sc(v.score||0)}">${Math.round(v.score||0)}</td>
|
|
<td><img src="${imgSrc}" style="width:48px;height:36px;object-fit:cover;border-radius:5px" onerror="this.style.display='none'"></td>
|
|
<td style="font-weight:600;max-width:180px">${v.name||'—'}</td>
|
|
<td style="color:var(--muted)">${v.vessel_type||'—'}</td>
|
|
<td><span style="color:${SC[v.status]||'#5a6a88'};font-size:11px;font-weight:600">${SL[v.status]||v.status}</span></td>
|
|
<td style="font-family:var(--mono)">${v.loa_m?v.loa_m+'m':'—'}</td>
|
|
<td style="color:var(--muted)">${v.year_built||'—'}</td>
|
|
<td style="font-family:var(--mono);font-weight:600;color:${(v.score||0)>=80?'var(--green)':'var(--text)'}">${v.price_usd?'$'+Math.round(v.price_usd).toLocaleString():'—'}</td>
|
|
<td style="color:var(--muted);max-width:150px;overflow:hidden;text-overflow:ellipsis;white-space:nowrap">${v.location||'—'}</td>
|
|
<td style="color:var(--accent);font-size:11px">${v.source_name||'—'}</td>
|
|
<td><button class="cbtn ${saved?'saved':''}" onclick="toggleSave(${v.id})">${saved?'★':'☆'}</button></td>
|
|
</tr>`;
|
|
}).join('')}</tbody>
|
|
</table></div>`;
|
|
}
|
|
|
|
// Status & data
|
|
async function checkStatus(){
|
|
try{
|
|
const r=await fetch('/api/status');const d=await r.json();
|
|
$('sdot').classList.remove('off');
|
|
$('sstat').textContent=`Conectado · ${d.db_counts?.vessels||0} embarcaciones`;
|
|
$('smodel').textContent=`IA: ${d.active_model||'—'}`;
|
|
$('ssrc').textContent=`Fuentes: ${d.sources_count||0}`;
|
|
loadStats();
|
|
}catch(e){
|
|
$('sdot').classList.add('off');
|
|
$('sstat').textContent='Servidor desconectado — inicia INICIAR.bat';
|
|
}
|
|
}
|
|
|
|
async function loadStats(){
|
|
try{
|
|
const r=await fetch('/api/stats');const d=await r.json();
|
|
$('sTotal').textContent=d.total||0;
|
|
$('sScore').textContent=Math.round(d.avg_score||0);
|
|
$('sPrice').textContent=d.avg_price?'$'+(Math.round(d.avg_price/1000))+'k':'—';
|
|
$('sAuct').textContent=(d.by_status?.auction)||0;
|
|
$('sSalv').textContent=(d.by_status?.salvage)||0;
|
|
$('sSaved').textContent=d.saved||0;
|
|
const sc=$('savedCount');
|
|
if(sc){if(d.saved>0){sc.textContent=d.saved;sc.style.display='inline';}else sc.style.display='none';}
|
|
}catch(e){}
|
|
}
|
|
|
|
async function loadVessels(){
|
|
$('content').innerHTML=`<div class="empty"><div class="spin" style="margin:0 auto"></div></div>`;
|
|
try{
|
|
let data;
|
|
if(state.tab==='saved'){
|
|
const r=await fetch('/api/saved');data=await r.json();
|
|
} else {
|
|
const p=new URLSearchParams();
|
|
const f=state.f;
|
|
if(f.type) p.set('type',f.type);
|
|
if(f.hull) p.set('hull',f.hull);
|
|
if(f.max_price) p.set('max_price',f.max_price);
|
|
if(f.min_loa) p.set('min_loa',f.min_loa);
|
|
if(f.year_min) p.set('year_min',f.year_min);
|
|
if(f.year_max) p.set('year_max',f.year_max);
|
|
if(f.sort) p.set('sort',f.sort);
|
|
if(state.tab==='auction') p.set('status','auction');
|
|
else if(state.tab==='salvage') p.set('status','salvage');
|
|
else if(f.status) p.set('status',f.status);
|
|
const r=await fetch('/api/vessels?'+p.toString());data=await r.json();
|
|
}
|
|
state.vessels=data.vessels||[];
|
|
const sr=await fetch('/api/saved');const sd=await sr.json();
|
|
state.savedIds=new Set((sd.vessels||[]).map(v=>v.id));
|
|
renderContent();
|
|
}catch(e){
|
|
$('content').innerHTML=`<div class="empty"><div class="empty-icon">⚠️</div><div>No se pudo conectar al servidor</div><div style="font-size:12px;margin-top:6px;color:var(--muted)">Asegúrate que server.py está corriendo</div></div>`;
|
|
}
|
|
}
|
|
|
|
function renderContent(){
|
|
const v=state.vessels;
|
|
if(!v.length){
|
|
if($('liveProgress')) return;
|
|
if(state.tab==='saved'){
|
|
$('content').innerHTML=`<div class="empty">
|
|
<div class="empty-icon">★</div>
|
|
<div style="font-size:15px;font-weight:600;margin-bottom:8px">No tienes embarcaciones guardadas</div>
|
|
<div style="font-size:12px;margin-bottom:8px;color:var(--muted)">Para guardar una embarcación:</div>
|
|
<div style="background:var(--s1);border:1px solid var(--b);border-radius:12px;padding:16px;max-width:320px;margin:0 auto;text-align:left">
|
|
<div style="font-size:12px;color:var(--text);margin-bottom:8px;display:flex;align-items:center;gap:8px">
|
|
<span style="background:rgba(0,229,160,.15);border:1px solid rgba(0,229,160,.3);border-radius:6px;padding:3px 10px;color:var(--green);font-size:11px;font-weight:700">☆ Guardar</span>
|
|
<span style="color:var(--muted)">Clic en el botón verde de cualquier tarjeta</span>
|
|
</div>
|
|
<div style="font-size:12px;color:var(--text);display:flex;align-items:center;gap:8px">
|
|
<span style="background:rgba(0,180,255,.1);border:1px solid rgba(0,180,255,.2);border-radius:6px;padding:3px 10px;color:var(--accent);font-size:11px;font-weight:700">📁 Carpetas</span>
|
|
<span style="color:var(--muted)">O guarda en carpetas por categoría</span>
|
|
</div>
|
|
</div>
|
|
<button class="btn-full" style="max-width:220px;margin:16px auto 0" onclick="switchTab('all',document.querySelector('.tab'))">Ver todas las embarcaciones</button>
|
|
</div>`;
|
|
} else {
|
|
$('content').innerHTML=`<div class="empty">
|
|
<div class="empty-icon">🚢</div>
|
|
<div style="font-size:15px;font-weight:600;margin-bottom:8px">No hay embarcaciones</div>
|
|
<div style="font-size:12px;margin-bottom:20px">Usa la búsqueda para encontrar resultados en todo internet</div>
|
|
<button class="btn-full" style="max-width:220px;margin:0 auto" onclick="openPanel('search')">⚡ Buscar ahora</button>
|
|
</div>`;
|
|
}
|
|
return;
|
|
}
|
|
if(state.view==='table') renderTable(v);
|
|
else renderGrid(v);
|
|
}
|
|
|
|
// Actions
|
|
async function toggleSave(id){
|
|
if(state.savedIds.has(id)){
|
|
await fetch('/api/saved/'+id,{method:'DELETE'});
|
|
state.savedIds.delete(id);toast('Eliminada de favoritos');
|
|
} else {
|
|
await fetch('/api/saved/'+id,{method:'POST',headers:{'Content-Type':'application/json'},body:'{}'});
|
|
state.savedIds.add(id);toast('★ Guardada');
|
|
}
|
|
renderContent();loadStats();
|
|
}
|
|
|
|
function doSearch(){
|
|
const q=$('mainSearch').value.trim();
|
|
if(!q) return;
|
|
$('aiQ').value=q;
|
|
openPanel('search');
|
|
}
|
|
|
|
let searchPoller = null;
|
|
|
|
async function runSearch(){
|
|
const q=$('aiQ').value.trim();
|
|
if(!q){toast('Escribe qué buscas');return;}
|
|
$('mainSearch').value=q;
|
|
|
|
$('srchBtn').textContent='⏳ Buscando…';
|
|
$('srchBtn').disabled=true;
|
|
$('srchResult').innerHTML=`
|
|
<div style="margin-top:14px">
|
|
<div style="display:flex;align-items:center;gap:10px;margin-bottom:10px">
|
|
<div class="spin" style="width:20px;height:20px;border-width:2px;flex-shrink:0"></div>
|
|
<span style="font-size:13px;font-weight:600" id="srchStatus">Iniciando búsqueda global…</span>
|
|
</div>
|
|
<div style="display:flex;gap:16px;margin-bottom:10px">
|
|
<div style="background:var(--s2);border-radius:8px;padding:8px 14px;text-align:center;flex:1">
|
|
<div style="font-size:10px;color:var(--muted);text-transform:uppercase;letter-spacing:.06em">Fuentes</div>
|
|
<div style="font-size:20px;font-weight:700;font-family:var(--mono);color:var(--accent)" id="srchSources">0</div>
|
|
</div>
|
|
<div style="background:var(--s2);border-radius:8px;padding:8px 14px;text-align:center;flex:1">
|
|
<div style="font-size:10px;color:var(--muted);text-transform:uppercase;letter-spacing:.06em">Encontradas</div>
|
|
<div style="font-size:20px;font-weight:700;font-family:var(--mono);color:var(--green)" id="srchFound">0</div>
|
|
</div>
|
|
</div>
|
|
<div style="background:var(--bg);border:1px solid var(--b);border-radius:8px;padding:10px;max-height:160px;overflow-y:auto;font-size:11px;font-family:var(--mono);color:var(--muted);line-height:1.8" id="srchLog">
|
|
Conectando con fuentes…
|
|
</div>
|
|
<button onclick="closePanel('search')" style="width:100%;margin-top:10px;background:var(--s2);border:1px solid var(--b);border-radius:10px;padding:10px;color:var(--text);cursor:pointer;font-size:13px;font-family:inherit">
|
|
Ver resultados en tiempo real →
|
|
</button>
|
|
</div>`;
|
|
|
|
try{
|
|
const filters={
|
|
type:$('aiType').value, status:$('aiStat').value,
|
|
max_price:$('aiPrice').value, min_loa:$('aiLoa').value,
|
|
max_loa:($('aiLoaMax')?$('aiLoaMax').value:''),
|
|
year_min:($('aiYearMin')?$('aiYearMin').value:''),
|
|
year_max:($('aiYearMax')?$('aiYearMax').value:''),
|
|
region:$('aiRegion').value, notes:$('aiNotes').value
|
|
};
|
|
await fetch('/api/search',{
|
|
method:'POST', headers:{'Content-Type':'application/json'},
|
|
body:JSON.stringify({query:q, filters})
|
|
});
|
|
|
|
// 1. Close panel immediately
|
|
closePanel('search');
|
|
|
|
// 2. Clear screen right away — no old results visible
|
|
state.vessels = [];
|
|
state.savedIds = new Set();
|
|
$('content').innerHTML = '<div class="empty" style="padding:40px 20px"><div style="font-size:36px;opacity:.4;margin-bottom:12px">🔍</div><div style="font-size:14px;font-weight:600;color:var(--muted)">Buscando “'+q+'”…</div><div style="font-size:12px;color:var(--muted);margin-top:6px">Los resultados aparecen aquí conforme se encuentran</div></div>';
|
|
loadStats();
|
|
|
|
// 3. Show live progress bar above grid
|
|
showSearchProgress(q);
|
|
|
|
// 4. Poll every 1.5s — append new vessels as they arrive
|
|
let lastCount = 0;
|
|
if(searchPoller) clearInterval(searchPoller);
|
|
|
|
searchPoller = setInterval(async()=>{
|
|
try{
|
|
const r = await fetch('/api/search/status');
|
|
const s = await r.json();
|
|
updateSearchProgress(s);
|
|
|
|
if(s.found > lastCount || (!s.running && s.found > 0)){
|
|
lastCount = s.found;
|
|
// Full reload — simple and reliable
|
|
await loadVessels();
|
|
loadStats();
|
|
}
|
|
|
|
if(!s.running){
|
|
clearInterval(searchPoller);
|
|
searchPoller = null;
|
|
$('srchBtn').textContent = '⚡ Buscar en Todo Internet';
|
|
$('srchBtn').disabled = false;
|
|
hideSearchProgress(s.found);
|
|
await loadVessels();
|
|
loadStats();
|
|
}
|
|
} catch(e){}
|
|
}, 1500);
|
|
|
|
}catch(e){
|
|
$('srchResult').innerHTML=`<div style="margin-top:12px;color:var(--red);font-size:12px">Error: ${e.message}</div>`;
|
|
$('srchBtn').textContent='⚡ Buscar en Todo Internet';
|
|
$('srchBtn').disabled=false;
|
|
}
|
|
}
|
|
|
|
async function runAnalyze(){
|
|
const txt=$('aTxt').value.trim();
|
|
if(!txt){toast('Pega un texto primero');return;}
|
|
$('aBtn').textContent='🤖 Analizando…';$('aBtn').disabled=true;
|
|
$('aResult').innerHTML=`<div style="text-align:center;padding:20px"><div class="spin" style="margin:0 auto 10px"></div><div style="font-size:12px;color:var(--muted)">Extrayendo datos con IA…</div></div>`;
|
|
try{
|
|
const r=await fetch('/api/analyze',{method:'POST',headers:{'Content-Type':'application/json'},
|
|
body:JSON.stringify({text:txt,source:$('aSrc').value||'Manual'})});
|
|
const d=await r.json();
|
|
$('aResult').innerHTML=`<div style="background:var(--s2);border-radius:10px;padding:14px;margin-top:12px;font-size:12px;font-family:var(--mono);line-height:1.8;max-height:200px;overflow-y:auto">${JSON.stringify(d,null,2)}</div>
|
|
<button class="btn-full btn-sec" style="margin-top:8px" onclick="closePanel('analyze');loadVessels()">Ver en dashboard →</button>`;
|
|
loadStats();
|
|
}catch(e){
|
|
$('aResult').innerHTML=`<div style="margin-top:12px;color:var(--red);font-size:12px">Error: ${e.message}</div>`;
|
|
}
|
|
$('aBtn').textContent='🤖 Extraer con IA';$('aBtn').disabled=false;
|
|
}
|
|
|
|
async function addVessel(){
|
|
const v={
|
|
name:$('nName').value,vessel_type:$('nType').value,
|
|
loa_m:parseFloat($('nLoa').value)||null,beam_m:parseFloat($('nBeam').value)||null,
|
|
draft_m:parseFloat($('nDraft').value)||null,year_built:parseInt($('nYear').value)||null,
|
|
hull:$('nHull').value,propulsion:$('nProp').value,status:$('nStat').value,
|
|
price_usd:parseFloat($('nPrice').value)||null,
|
|
location:$('nLoc').value,source_name:$('nSrc2').value||'Manual',
|
|
source_url:$('nUrl').value,
|
|
images:$('nImg').value?[$('nImg').value]:[],
|
|
description:$('nDesc').value,score:50,
|
|
};
|
|
if(!v.name){toast('Escribe un nombre');return;}
|
|
const r=await fetch('/api/vessels',{method:'POST',headers:{'Content-Type':'application/json'},body:JSON.stringify(v)});
|
|
const d=await r.json();
|
|
if(d.ok){
|
|
await fetch('/api/saved/'+d.id,{method:'POST',headers:{'Content-Type':'application/json'},body:'{}'});
|
|
toast('✓ Guardada y marcada como favorita');
|
|
closePanel('add');loadVessels();loadStats();
|
|
}
|
|
}
|
|
|
|
function openSrc(id){
|
|
const v=state.vessels.find(x=>x.id===id);
|
|
if(!v||!v.source_url) return;
|
|
window.open(v.source_url,'_blank');
|
|
}
|
|
|
|
async function createAlert(){
|
|
const name=$('alName').value;
|
|
if(!name){toast('Escribe un nombre');return;}
|
|
const filters={type:$('alType').value,status:$('alStat').value,max_price:$('alPrice').value,min_loa:$('alLoa').value};
|
|
await fetch('/api/alerts',{method:'POST',headers:{'Content-Type':'application/json'},body:JSON.stringify({name,filters})});
|
|
toast('🔔 Alerta creada');closePanel('alert');
|
|
}
|
|
|
|
async function loadAlerts(){
|
|
const r=await fetch('/api/alerts');const d=await r.json();
|
|
const alerts=d.alerts||[];
|
|
$('content').innerHTML=alerts.length
|
|
?`<div style="padding:16px 20px 80px">${alerts.map(a=>`<div class="card" style="padding:14px;margin-bottom:10px">
|
|
<div style="display:flex;justify-content:space-between;align-items:center">
|
|
<div><div style="font-weight:700">${a.name}</div><div style="font-size:11px;color:var(--muted);margin-top:3px">${a.filters}</div></div>
|
|
<button class="cbtn" onclick="delAlert(${a.id})">Eliminar</button>
|
|
</div></div>`).join('')}
|
|
<button class="btn-full btn-sec" style="margin-top:8px" onclick="openPanel('alert')">+ Nueva Alerta</button>
|
|
</div>`
|
|
:`<div class="empty"><div class="empty-icon">🔔</div><div>No hay alertas activas</div>
|
|
<button class="btn-full" style="max-width:200px;margin:16px auto 0" onclick="openPanel('alert')">Crear Alerta</button></div>`;
|
|
}
|
|
|
|
async function delAlert(id){
|
|
await fetch('/api/alerts/'+id,{method:'DELETE'});
|
|
toast('Alerta eliminada');loadAlerts();
|
|
}
|
|
|
|
async function loadHistory(){
|
|
const r=await fetch('/api/history');const d=await r.json();
|
|
const h=d.history||[];
|
|
$('content').innerHTML=h.length
|
|
?`<div style="padding:16px 20px 80px">${h.map(item=>`<div class="card" style="padding:12px 14px;margin-bottom:8px;cursor:pointer" onclick="$('aiQ').value='${item.query}';openPanel('search')">
|
|
<div style="display:flex;justify-content:space-between">
|
|
<span style="font-size:13px;font-weight:600">${item.query}</span>
|
|
<span style="font-size:10px;color:var(--muted)">${(item.searched_at||'').split('T')[0]}</span>
|
|
</div></div>`).join('')}</div>`
|
|
:`<div class="empty"><div class="empty-icon">🕐</div><div>No hay búsquedas recientes</div></div>`;
|
|
}
|
|
|
|
async function checkFbStatus(){
|
|
try{
|
|
const r=await fetch('/api/fb-status');
|
|
const d=await r.json();
|
|
const el=$('fbStatus');
|
|
const btn=$('fbSetupBtn');
|
|
if(!el) return;
|
|
if(d.active){
|
|
el.textContent='✅ Sesión activa — Marketplace habilitado';
|
|
el.style.color='var(--green)';
|
|
btn.textContent='Reconectar FB';
|
|
btn.style.background='rgba(0,200,100,.1)';
|
|
btn.style.borderColor='rgba(0,200,100,.3)';
|
|
btn.style.color='var(--green)';
|
|
} else {
|
|
el.textContent='⚪ No configurado — haz clic para activar';
|
|
el.style.color='var(--muted)';
|
|
btn.textContent='Configurar FB';
|
|
}
|
|
} catch(e){}
|
|
}
|
|
|
|
async function setupFacebook(){
|
|
const btn=$('fbSetupBtn');
|
|
const el=$('fbStatus');
|
|
btn.disabled=true;
|
|
btn.textContent='⏳ Abriendo navegador…';
|
|
el.textContent='Se abrirá una ventana — inicia sesión en Facebook y luego ve a Marketplace';
|
|
el.style.color='var(--amber)';
|
|
try{
|
|
const r=await fetch('/api/fb-setup',{method:'POST'});
|
|
const d=await r.json();
|
|
if(d.ok){
|
|
el.textContent='✅ '+d.msg;
|
|
el.style.color='var(--green)';
|
|
btn.textContent='Reconectar FB';
|
|
toast('✅ Facebook Marketplace activado');
|
|
} else {
|
|
el.textContent='❌ '+d.msg;
|
|
el.style.color='var(--red)';
|
|
btn.textContent='Reintentar';
|
|
}
|
|
} catch(e){
|
|
el.textContent='❌ Error de conexión';
|
|
el.style.color='var(--red)';
|
|
btn.textContent='Reintentar';
|
|
}
|
|
btn.disabled=false;
|
|
}
|
|
|
|
async function loadSources(){
|
|
checkFbStatus();
|
|
const r=await fetch('/api/sources');const d=await r.json();
|
|
const sources=d.sources||{};
|
|
const total=d.total||0;
|
|
const TC={auction:'var(--amber)',broker:'var(--accent)',classifieds:'var(--green)',
|
|
salvage:'var(--red)',news:'var(--purple)',magazine:'var(--purple)',
|
|
registry:'var(--muted)',commercial:'var(--muted)'};
|
|
|
|
let html = `<div style="font-size:12px;color:var(--muted);margin-bottom:12px">
|
|
<span style="color:var(--accent);font-weight:700">${total}</span> fuentes activas — la IA las consulta en cada búsqueda
|
|
</div>`;
|
|
|
|
for(const [cat, items] of Object.entries(sources)){
|
|
html += `<div class="src-cat">
|
|
<div class="src-cat-title">${cat} (${items.length})</div>
|
|
<div class="src-grid">
|
|
${items.map(s=>`<div class="src-item" style="${!s.active?'opacity:.4':''};${!s.builtin?'border-color:rgba(0,229,160,.25)':''}">
|
|
<div style="flex:1;min-width:0">
|
|
<div class="src-name" style="display:flex;align-items:center;gap:5px">
|
|
${!s.builtin?'<span style="color:var(--green);font-size:9px">★</span>':''}
|
|
${s.name}
|
|
</div>
|
|
<div class="src-type" style="color:${TC[s.type]||'var(--muted)'};">${s.type}</div>
|
|
</div>
|
|
<div style="display:flex;gap:4px;align-items:center;flex-shrink:0">
|
|
<a href="${s.url}" target="_blank" style="color:var(--accent);font-size:11px;text-decoration:none;padding:2px 6px">→</a>
|
|
${!s.builtin?`
|
|
<button onclick="toggleSource(${s.id},${s.active})" style="background:none;border:1px solid var(--b);border-radius:5px;padding:2px 7px;font-size:10px;cursor:pointer;color:${s.active?'var(--amber)':'var(--green)'};font-family:inherit">
|
|
${s.active?'⏸':'▶'}
|
|
</button>
|
|
<button onclick="deleteSource(${s.id})" style="background:none;border:1px solid var(--b);border-radius:5px;padding:2px 7px;font-size:10px;cursor:pointer;color:var(--red);font-family:inherit">✕</button>
|
|
`:''}
|
|
</div>
|
|
</div>`).join('')}
|
|
</div>
|
|
</div>`;
|
|
}
|
|
$('srcList').innerHTML = html;
|
|
}
|
|
|
|
async function addCustomSource(){
|
|
const name=$('newSrcName').value.trim();
|
|
const url=$('newSrcUrl').value.trim();
|
|
const type=$('newSrcType').value;
|
|
const cat=$('newSrcCat').value.trim()||'Custom';
|
|
if(!name||!url){toast('Nombre y URL son requeridos');return;}
|
|
if(!url.startsWith('http')){toast('La URL debe empezar con http:// o https://');return;}
|
|
const r=await fetch('/api/custom_sources',{method:'POST',
|
|
headers:{'Content-Type':'application/json'},credentials:'include',
|
|
body:JSON.stringify({name,search_url:url,source_type:type,category:cat})});
|
|
const d=await r.json();
|
|
if(d.ok){
|
|
$('newSrcName').value='';$('newSrcUrl').value='';$('newSrcCat').value='';
|
|
toast('✓ Fuente agregada — activa en próxima búsqueda');
|
|
loadSources();
|
|
} else {
|
|
toast('Error: '+d.error);
|
|
}
|
|
}
|
|
|
|
async function toggleSource(id,active){
|
|
await fetch('/api/custom_sources/'+id,{method:'PUT',
|
|
headers:{'Content-Type':'application/json'},credentials:'include',
|
|
body:JSON.stringify({active:active?0:1})});
|
|
toast(active?'Fuente pausada':'Fuente activada');
|
|
loadSources();
|
|
}
|
|
|
|
async function deleteSource(id){
|
|
if(!confirm('¿Eliminar esta fuente?'))return;
|
|
await fetch('/api/custom_sources/'+id,{method:'DELETE',credentials:'include'});
|
|
toast('Fuente eliminada');
|
|
loadSources();
|
|
}
|
|
|
|
// UI
|
|
function switchTab(t,el){
|
|
state.tab=t;
|
|
document.querySelectorAll('.tab').forEach(b=>b.classList.remove('on'));
|
|
el.classList.add('on');
|
|
$('fltbar').style.display=['all','auction','salvage'].includes(t)?'flex':'none';
|
|
if(t==='alerts') loadAlerts();
|
|
else if(t==='history') loadHistory();
|
|
else if(t==='collections') loadCollectionsTab();
|
|
else loadVessels();
|
|
}
|
|
|
|
function toggleView(){
|
|
state.view=state.view==='grid'?'table':'grid';
|
|
$('viewBtn').textContent=state.view==='grid'?'☰ Tabla':'⊞ Grid';
|
|
renderContent();
|
|
}
|
|
|
|
function openPanel(n){
|
|
if(n==='search') $('pSearch').classList.add('show');
|
|
else if(n==='analyze') $('pAnalyze').classList.add('show');
|
|
else if(n==='add') $('pAdd').classList.add('show');
|
|
else if(n==='sources'){loadSources();$('pSources').classList.add('show');}
|
|
else if(n==='alert') $('pAlert').classList.add('show');
|
|
}
|
|
function closePanel(n){
|
|
['pSearch','pAnalyze','pAdd','pSources','pAlert'].forEach(id=>$('content'),$( 'p'+n.charAt(0).toUpperCase()+n.slice(1))?.classList.remove('show'));
|
|
$('pSearch').classList.remove('show');$('pAnalyze').classList.remove('show');
|
|
$('pAdd').classList.remove('show');$('pSources').classList.remove('show');
|
|
$('pAlert').classList.remove('show');
|
|
}
|
|
|
|
function toast(msg){
|
|
const el=$('toast');el.textContent=msg;el.classList.add('show');
|
|
setTimeout(()=>el.classList.remove('show'),2200);
|
|
}
|
|
|
|
// ── Live search progress bar ──────────────────────────────────────────────
|
|
function showSearchProgress(query){
|
|
// Remove existing if any
|
|
const existing = $('liveProgress');
|
|
if(existing) existing.remove();
|
|
|
|
const bar = document.createElement('div');
|
|
bar.id = 'liveProgress';
|
|
bar.style.cssText = 'background:var(--s1);border-bottom:1px solid rgba(0,180,255,.25);padding:10px 20px;display:flex;align-items:center;gap:14px;flex-wrap:wrap;position:sticky;top:0;z-index:150;backdrop-filter:blur(8px)';
|
|
bar.innerHTML = `
|
|
<div style="width:16px;height:16px;border:2px solid rgba(0,180,255,.3);border-top-color:var(--accent);border-radius:50%;animation:spin 1s linear infinite;flex-shrink:0" id="lpSpinner"></div>
|
|
<span style="font-size:12px;font-weight:700;color:var(--accent)" id="lpQuery">"${query}"</span>
|
|
<span style="font-size:12px;color:var(--muted)" id="lpStatus">Consultando fuentes…</span>
|
|
<div style="margin-left:auto;display:flex;gap:12px;align-items:center">
|
|
<div style="text-align:center">
|
|
<div style="font-size:10px;color:var(--muted);text-transform:uppercase;letter-spacing:.06em">Fuentes</div>
|
|
<div style="font-size:16px;font-weight:700;font-family:var(--mono);color:var(--accent)" id="lpSources">0</div>
|
|
</div>
|
|
<div style="text-align:center">
|
|
<div style="font-size:10px;color:var(--muted);text-transform:uppercase;letter-spacing:.06em">Encontradas</div>
|
|
<div style="font-size:16px;font-weight:700;font-family:var(--mono);color:var(--green)" id="lpFound">0</div>
|
|
</div>
|
|
<div style="width:100px;height:4px;background:var(--b2);border-radius:2px;overflow:hidden">
|
|
<div id="lpBar" style="height:100%;background:var(--accent);border-radius:2px;width:0%;transition:width .5s"></div>
|
|
</div>
|
|
<button onclick="cancelSearch()" id="lpCancel" style="background:rgba(255,82,82,.12);border:1px solid rgba(255,82,82,.3);border-radius:8px;padding:5px 12px;color:var(--red);font-size:11px;font-weight:700;cursor:pointer;font-family:inherit;white-space:nowrap">⏹ Parar</button>
|
|
</div>`;
|
|
|
|
// Insert after filters bar
|
|
const fltbar = $('fltbar');
|
|
fltbar.insertAdjacentElement('afterend', bar);
|
|
}
|
|
|
|
function updateSearchProgress(s){
|
|
const lpStatus = $('lpStatus');
|
|
const lpSources = $('lpSources');
|
|
const lpFound = $('lpFound');
|
|
const lpBar = $('lpBar');
|
|
if(!lpStatus) return;
|
|
const pct = s.total_sources > 0 ? Math.round((s.sources_done/s.total_sources)*100) : 0;
|
|
lpStatus.textContent = s.running ? `Consultando fuentes… (${s.sources_done}/${s.total_sources})` : '✓ Completado';
|
|
lpSources.textContent = `${s.sources_done}/${s.total_sources}`;
|
|
lpFound.textContent = s.found || 0;
|
|
if(lpBar) lpBar.style.width = pct + '%';
|
|
}
|
|
|
|
function hideSearchProgress(found){
|
|
const bar = $('liveProgress');
|
|
if(!bar) return;
|
|
// Show completion briefly then fade out
|
|
const lpStatus = $('lpStatus');
|
|
if(lpStatus) lpStatus.textContent = `✓ Búsqueda completa — ${found} embarcaciones encontradas`;
|
|
const lpBar = $('lpBar');
|
|
if(lpBar) lpBar.style.width = '100%';
|
|
setTimeout(()=>{
|
|
if(bar){ bar.style.transition='opacity .5s'; bar.style.opacity='0'; }
|
|
setTimeout(()=>{ const b=$('liveProgress'); if(b)b.remove(); }, 500);
|
|
}, 3000);
|
|
}
|
|
|
|
// ── Cancel search ─────────────────────────────────────────────────────────
|
|
async function cancelSearch(){
|
|
if(searchPoller){ clearInterval(searchPoller); searchPoller = null; }
|
|
try{ await fetch('/api/search/cancel', {method:'POST'}); }catch(e){}
|
|
const btn = $('lpCancel');
|
|
const spinner = $('lpSpinner');
|
|
const lpStatus = $('lpStatus');
|
|
if(btn){ btn.style.display='none'; }
|
|
if(spinner){ spinner.style.borderTopColor='var(--amber)'; spinner.style.animation='none'; }
|
|
if(lpStatus){ lpStatus.textContent='⏹ Búsqueda cancelada'; lpStatus.style.color='var(--amber)'; }
|
|
$('srchBtn').textContent='⚡ Buscar en Todo Internet';
|
|
$('srchBtn').disabled=false;
|
|
loadVessels(); loadStats();
|
|
setTimeout(()=>{ const b=$('liveProgress'); if(b){ b.style.transition='opacity .5s'; b.style.opacity='0'; setTimeout(()=>{ const b2=$('liveProgress'); if(b2)b2.remove(); },500); }}, 2500);
|
|
}
|
|
|
|
// ── Selection ─────────────────────────────────────────────────────────────
|
|
function toggleSelect(id,e){
|
|
if(e)e.stopPropagation();
|
|
if(state.selected.has(id))state.selected.delete(id);
|
|
else state.selected.add(id);
|
|
updateSelBar();
|
|
const chk=$('chk-'+id);
|
|
if(chk){chk.style.background=state.selected.has(id)?'var(--accent)':'rgba(0,0,0,.4)';chk.textContent=state.selected.has(id)?'✓':'';}
|
|
}
|
|
function clearSelection(){state.selected.clear();updateSelBar();renderContent();}
|
|
function updateSelBar(){
|
|
const bar=$('selBar');if(!bar)return;
|
|
if(state.selected.size>0){bar.style.display='flex';$('selCount').textContent=state.selected.size+' seleccionada'+(state.selected.size>1?'s':'');}
|
|
else bar.style.display='none';
|
|
}
|
|
|
|
// ── Collections ────────────────────────────────────────────────────────────
|
|
async function loadCollections(){
|
|
const r=await fetch('/api/collections');const d=await r.json();
|
|
state.collections=d.collections||[];
|
|
}
|
|
|
|
async function loadCollectionsTab(){
|
|
await loadCollections();
|
|
const cols=state.collections;
|
|
$('fltbar').style.display='none';
|
|
$('content').innerHTML=`<div style="padding:16px 20px 100px">
|
|
<div style="display:grid;grid-template-columns:repeat(auto-fill,minmax(220px,1fr));gap:12px">
|
|
${cols.map(c=>`<div class="card" style="cursor:pointer" onclick="openCollection(${c.id},'${c.name.replace(/'/g,"\\'")}')">
|
|
<div style="font-size:32px;margin-bottom:8px">${c.icon||'📁'}</div>
|
|
<div style="font-weight:700;font-size:14px;margin-bottom:4px">${c.name}</div>
|
|
${c.description?`<div style="font-size:11px;color:var(--muted);margin-bottom:8px">${c.description}</div>`:''}
|
|
<div style="font-size:13px;color:var(--accent);font-weight:600;margin-bottom:10px">${c.vessel_count||0} embarcaciones</div>
|
|
<button onclick="event.stopPropagation();deleteCollection(${c.id})" style="background:none;border:1px solid var(--b);border-radius:6px;padding:4px 10px;color:var(--muted);font-size:11px;cursor:pointer;font-family:inherit">Eliminar</button>
|
|
</div>`).join('')}
|
|
<div class="card" onclick="openNewColModal()" style="cursor:pointer;border-style:dashed;min-height:120px;display:flex;flex-direction:column;align-items:center;justify-content:center;gap:8px">
|
|
<div style="font-size:32px;opacity:.35">+</div>
|
|
<div style="font-size:12px;color:var(--muted)">Nueva carpeta</div>
|
|
</div>
|
|
</div>
|
|
</div>`;
|
|
}
|
|
|
|
async function openCollection(id,name){
|
|
const r=await fetch('/api/collections/'+id+'/vessels');
|
|
const d=await r.json();
|
|
state.vessels=d.vessels||[];
|
|
state.savedIds=new Set(state.vessels.map(v=>v.id));
|
|
$('content').innerHTML='';
|
|
const hdr=document.createElement('div');
|
|
hdr.style.cssText='padding:10px 20px;display:flex;align-items:center;gap:12px;border-bottom:1px solid var(--b);margin-bottom:4px';
|
|
hdr.innerHTML=`<button onclick="loadCollectionsTab()" style="background:none;border:none;color:var(--muted);cursor:pointer;font-size:13px;font-family:inherit">← Carpetas</button>
|
|
<span style="font-weight:700;font-size:15px">${name}</span>
|
|
<span style="font-size:12px;color:var(--muted)">${state.vessels.length} embarcaciones</span>`;
|
|
$('content').appendChild(hdr);
|
|
if(state.vessels.length){
|
|
const grid=document.createElement('div');grid.className='grid';
|
|
grid.innerHTML=state.vessels.map(card).join('');
|
|
$('content').appendChild(grid);
|
|
} else {
|
|
const empty=document.createElement('div');empty.className='empty';
|
|
empty.innerHTML='<div class="empty-icon">📭</div><div>Carpeta vacía</div>';
|
|
$('content').appendChild(empty);
|
|
}
|
|
}
|
|
|
|
async function saveSelectedToCollection(){
|
|
if(state.selected.size===0){toast('Selecciona embarcaciones primero');return;}
|
|
await loadCollections();
|
|
showColPickerModal([...state.selected]);
|
|
}
|
|
|
|
function showColPickerModal(ids){
|
|
const cols=state.collections;
|
|
const modal=document.createElement('div');
|
|
modal.style.cssText='position:fixed;inset:0;background:rgba(0,0,0,.85);z-index:9999;display:flex;align-items:center;justify-content:center;padding:20px';
|
|
modal.innerHTML=`<div style="background:var(--s1);border:1px solid var(--b2);border-radius:14px;padding:22px;max-width:440px;width:100%;font-family:var(--head)">
|
|
<div style="font-size:15px;font-weight:800;margin-bottom:16px">📁 Guardar ${ids.length} embarcación${ids.length>1?'es':''}</div>
|
|
${cols.length?`<div style="margin-bottom:14px">
|
|
<div style="font-size:11px;font-weight:700;color:var(--muted);text-transform:uppercase;letter-spacing:.07em;margin-bottom:8px">Carpetas existentes</div>
|
|
<div style="display:flex;flex-direction:column;gap:6px;max-height:180px;overflow-y:auto" id="colList">
|
|
${cols.map(c=>`<button onclick="doAddToCol(${c.id})" style="background:var(--s2);border:1px solid var(--b);border-radius:9px;padding:10px 14px;text-align:left;cursor:pointer;display:flex;align-items:center;gap:10px;font-family:inherit">
|
|
<span style="font-size:20px">${c.icon||'📁'}</span>
|
|
<div><div style="font-size:13px;font-weight:600;color:var(--text)">${c.name}</div><div style="font-size:11px;color:var(--muted)">${c.vessel_count||0} embarcaciones</div></div>
|
|
</button>`).join('')}
|
|
</div>
|
|
</div>`:''}
|
|
<div style="border-top:1px solid var(--b);padding-top:14px">
|
|
<div style="font-size:11px;font-weight:700;color:var(--muted);text-transform:uppercase;margin-bottom:8px">Nueva carpeta</div>
|
|
<div style="display:flex;gap:8px;margin-bottom:8px">
|
|
<input id="mIcon" value="📁" style="width:46px;background:var(--s2);border:1px solid var(--b);border-radius:8px;padding:7px;color:var(--text);font-size:18px;text-align:center">
|
|
<input id="mName" placeholder="Ej: Tugs Diesel 15m+" style="flex:1;background:var(--s2);border:1px solid var(--b);border-radius:8px;padding:7px 12px;color:var(--text);font-size:13px">
|
|
</div>
|
|
<input id="mDesc" placeholder="Descripción opcional" style="width:100%;background:var(--s2);border:1px solid var(--b);border-radius:8px;padding:7px 12px;color:var(--text);font-size:12px;margin-bottom:8px">
|
|
<button onclick="doCreateAndAdd()" style="width:100%;background:var(--accent);color:#000;border:none;border-radius:9px;padding:11px;font-size:13px;font-weight:800;cursor:pointer">Crear y guardar</button>
|
|
</div>
|
|
<button onclick="this.closest('[style*=fixed]').remove()" style="width:100%;margin-top:8px;background:none;border:1px solid var(--b);border-radius:9px;padding:10px;color:var(--muted);cursor:pointer;font-size:12px;font-family:inherit">Cancelar</button>
|
|
</div>`;
|
|
|
|
window.doAddToCol = async function(cid){
|
|
modal.remove();
|
|
await fetch('/api/collections/'+cid+'/vessels',{method:'POST',
|
|
headers:{'Content-Type':'application/json'},body:JSON.stringify({vessel_ids:ids})});
|
|
toast('✓ Guardadas en carpeta');
|
|
clearSelection();
|
|
loadStats();
|
|
};
|
|
window.doCreateAndAdd = async function(){
|
|
const name=$('mName').value.trim();
|
|
if(!name){toast('Escribe un nombre');return;}
|
|
const r=await fetch('/api/collections',{method:'POST',headers:{'Content-Type':'application/json'},
|
|
body:JSON.stringify({name,icon:$('mIcon').value||'📁',description:$('mDesc').value})});
|
|
const d=await r.json();
|
|
if(d.id) window.doAddToCol(d.id);
|
|
};
|
|
modal.onclick=e=>{if(e.target===modal)modal.remove();};
|
|
document.body.appendChild(modal);
|
|
}
|
|
|
|
async function deleteCollection(id){
|
|
if(!confirm('¿Eliminar esta carpeta?'))return;
|
|
await fetch('/api/collections/'+id,{method:'DELETE'});
|
|
toast('Carpeta eliminada');loadCollectionsTab();
|
|
}
|
|
|
|
function openNewColModal(){
|
|
const modal=document.createElement('div');
|
|
modal.style.cssText='position:fixed;inset:0;background:rgba(0,0,0,.85);z-index:9999;display:flex;align-items:center;justify-content:center;padding:20px';
|
|
modal.innerHTML=`<div style="background:var(--s1);border:1px solid var(--b2);border-radius:14px;padding:22px;max-width:380px;width:100%;font-family:var(--head)">
|
|
<div style="font-size:15px;font-weight:800;margin-bottom:16px">📁 Nueva Carpeta</div>
|
|
<div style="display:flex;gap:8px;margin-bottom:10px">
|
|
<input id="nc3Icon" value="📁" style="width:46px;background:var(--s2);border:1px solid var(--b);border-radius:8px;padding:7px;color:var(--text);font-size:18px;text-align:center">
|
|
<input id="nc3Name" placeholder="Ej: Pesqueros Acero Noruega" style="flex:1;background:var(--s2);border:1px solid var(--b);border-radius:8px;padding:7px 12px;color:var(--text);font-size:13px">
|
|
</div>
|
|
<input id="nc3Desc" placeholder="Descripción opcional" style="width:100%;background:var(--s2);border:1px solid var(--b);border-radius:8px;padding:7px 12px;color:var(--text);font-size:12px;margin-bottom:10px">
|
|
<button onclick="doCreateCol()" style="width:100%;background:var(--accent);color:#000;border:none;border-radius:9px;padding:11px;font-size:13px;font-weight:800;cursor:pointer">Crear Carpeta</button>
|
|
<button onclick="this.closest('[style*=fixed]').remove()" style="width:100%;margin-top:8px;background:none;border:1px solid var(--b);border-radius:9px;padding:10px;color:var(--muted);cursor:pointer;font-size:12px;font-family:inherit">Cancelar</button>
|
|
</div>`;
|
|
window.doCreateCol=async function(){
|
|
const name=$('nc3Name').value.trim();
|
|
if(!name){toast('Escribe un nombre');return;}
|
|
await fetch('/api/collections',{method:'POST',headers:{'Content-Type':'application/json'},
|
|
body:JSON.stringify({name,icon:$('nc3Icon').value||'📁',description:$('nc3Desc').value})});
|
|
modal.remove();toast('✓ Carpeta creada');loadCollectionsTab();
|
|
};
|
|
modal.onclick=e=>{if(e.target===modal)modal.remove();};
|
|
document.body.appendChild(modal);
|
|
}
|
|
|
|
// ── Init ───────────────────────────────────────────────────────────────────
|
|
checkStatus();
|
|
loadVessels();
|
|
setInterval(checkStatus,30000);
|
|
</script>
|
|
</body>
|
|
</html> |