Play-Scrapper / templates /index.html
WebashalarForML's picture
Upload 2 files
88b4fa3 verified
<!DOCTYPE html>
<html lang="en">
<head>
<meta charset="UTF-8"/>
<meta name="viewport" content="width=device-width, initial-scale=1.0"/>
<title>PlayPulse | Single Explorer</title>
<link href="https://fonts.googleapis.com/css2?family=Inter:wght@400;500;600;700;800&display=swap" rel="stylesheet"/>
<style>
:root{
--bg:#0b0e14; --surface:#151921; --surface2:#1c2333; --border:#232a35;
--accent:#3b82f6; --accent-dim:rgba(59,130,246,0.12); --accent-glow:rgba(59,130,246,0.3);
--green:#22c55e; --green-dim:rgba(34,197,94,0.12);
--amber:#f59e0b; --amber-dim:rgba(245,158,11,0.12);
--purple:#a78bfa; --purple-dim:rgba(167,139,250,0.08);
--text:#f1f5f9; --muted:#64748b; --muted2:#94a3b8;
}
*{box-sizing:border-box;margin:0;padding:0;}
::-webkit-scrollbar{width:5px;height:5px;}
::-webkit-scrollbar-track{background:transparent;}
::-webkit-scrollbar-thumb{background:rgba(255,255,255,0.08);border-radius:10px;}
::-webkit-scrollbar-thumb:hover{background:rgba(255,255,255,0.18);}
*{scrollbar-width:thin;scrollbar-color:rgba(255,255,255,0.08) transparent;}
body{font-family:'Inter',sans-serif;background:var(--bg);color:var(--text);height:100vh;overflow:hidden;display:flex;flex-direction:column;}
/* HEADER */
.header{height:52px;background:var(--surface);border-bottom:1px solid var(--border);display:flex;align-items:center;padding:0 16px;gap:12px;flex-shrink:0;}
.logo{font-weight:800;font-size:16px;color:var(--accent);display:flex;align-items:center;gap:7px;text-decoration:none;}
nav{display:flex;gap:3px;margin-left:12px;}
.nav-link{color:var(--muted2);text-decoration:none;font-size:12px;font-weight:600;padding:5px 10px;border-radius:7px;transition:.15s;}
.nav-link:hover{color:var(--text);background:var(--surface2);}
.nav-link.active{color:var(--accent);background:var(--accent-dim);}
/* LAYOUT */
.main{flex:1;display:flex;overflow:hidden;position:relative;}
.sidebar{width:284px;min-width:220px;max-width:460px;background:var(--surface);border-right:1px solid var(--border);display:flex;flex-direction:column;overflow-y:auto;overflow-x:hidden;flex-shrink:0;transition:width .22s ease;}
.sidebar.collapsed{width:0!important;min-width:0;overflow:hidden;}
.sb-inner{padding:13px 14px;display:flex;flex-direction:column;gap:13px;min-width:220px;}
.resize-handle{width:4px;background:transparent;cursor:col-resize;flex-shrink:0;transition:background .18s;z-index:10;}
.resize-handle:hover,.resize-handle.active{background:var(--accent);}
.collapse-btn{position:absolute;top:50%;transform:translateY(-50%);width:16px;height:36px;background:var(--surface2);border:1px solid var(--border);border-left:none;border-radius:0 7px 7px 0;display:flex;align-items:center;justify-content:center;cursor:pointer;z-index:20;color:var(--muted);transition:background .15s,color .15s;}
.collapse-btn:hover{background:var(--surface);color:var(--text);}
.collapse-btn svg{width:9px;height:9px;fill:none;stroke:currentColor;stroke-width:2.5;transition:transform .22s;}
.content{flex:1;background:var(--bg);position:relative;display:flex;flex-direction:column;overflow:hidden;min-width:0;}
/* SIDEBAR ELEMENTS */
.label{font-size:9px;font-weight:800;text-transform:uppercase;color:var(--muted);letter-spacing:.8px;display:flex;align-items:center;justify-content:space-between;}
.input-group{display:flex;flex-direction:column;gap:7px;}
input,select{background:var(--bg);border:1px solid var(--border);color:white;padding:8px 10px;border-radius:7px;font-size:12px;outline:none;width:100%;transition:border-color .15s;}
input:focus,select:focus{border-color:var(--accent);}
.toggle-grp{display:flex;background:var(--bg);padding:3px;border-radius:9px;border:1px solid var(--border);}
.toggle-btn{flex:1;padding:7px;border:none;background:transparent;color:var(--muted);cursor:pointer;border-radius:6px;font-weight:600;font-size:11px;transition:.15s;}
.toggle-btn.active{background:var(--accent);color:white;}
.btn-main{background:var(--accent);color:white;border:none;padding:11px;border-radius:9px;font-weight:800;cursor:pointer;display:flex;align-items:center;justify-content:center;gap:8px;transition:.2s;width:100%;font-size:12px;border-bottom:3px solid rgba(0,0,0,.2);}
.btn-main:hover{transform:translateY(-1px);box-shadow:0 4px 14px var(--accent-glow);}
.btn-main:disabled{opacity:.5;cursor:not-allowed;transform:none;box-shadow:none;}
.star-filter-grid{display:flex;flex-direction:column;gap:5px;}
.star-row{display:flex;align-items:center;gap:8px;padding:7px 9px;border-radius:7px;border:1px solid var(--border);background:var(--bg);cursor:pointer;transition:border-color .15s;user-select:none;}
.star-row:hover{border-color:var(--accent);}
.star-row input[type=checkbox]{width:13px;height:13px;accent-color:var(--accent);cursor:pointer;flex-shrink:0;}
.star-label{display:flex;align-items:center;gap:4px;font-size:12px;font-weight:600;flex:1;}
.stars-on{color:var(--amber);letter-spacing:-1px;}
.stars-off{color:var(--border);letter-spacing:-1px;}
.qbtn{font-size:9px;font-weight:700;color:var(--muted2);cursor:pointer;padding:2px 6px;border-radius:4px;border:1px solid var(--border);background:var(--surface2);transition:.15s;}
.qbtn:hover{color:white;border-color:var(--accent);}
.filter-chips{display:flex;flex-wrap:wrap;gap:5px;}
.chip{font-size:10px;font-weight:700;padding:2px 7px;border-radius:16px;background:var(--accent-dim);color:var(--accent);border:1px solid rgba(59,130,246,.3);}
.search-wrap{position:relative;}
.suggestions-box{position:absolute;top:calc(100% + 6px);left:0;right:0;background:var(--surface2);border:1px solid var(--border);border-radius:10px;z-index:1000;max-height:340px;overflow-y:auto;box-shadow:0 14px 40px rgba(0,0,0,.65);}
.suggestion-item{display:flex;align-items:center;padding:10px 12px;gap:10px;cursor:pointer;transition:.15s;border-bottom:1px solid var(--border);}
.suggestion-item:last-child{border-bottom:none;}
.suggestion-item:hover{background:var(--accent-dim);}
.suggestion-item img{width:36px;height:36px;border-radius:8px;object-fit:cover;border:1px solid var(--border);flex-shrink:0;}
.suggestion-info{flex:1;min-width:0;}
.suggestion-title{font-size:12px;font-weight:700;white-space:nowrap;overflow:hidden;text-overflow:ellipsis;color:var(--text);}
.suggestion-sub{font-size:10px;color:var(--muted);margin-top:1px;white-space:nowrap;overflow:hidden;text-overflow:ellipsis;}
.suggestion-score{font-size:10px;font-weight:700;color:var(--amber);background:var(--amber-dim);padding:2px 5px;border-radius:4px;flex-shrink:0;}
.suggestion-loading{padding:22px 16px;text-align:center;color:var(--muted);font-size:11px;display:flex;flex-direction:column;align-items:center;gap:8px;}
.spinner-small{width:18px;height:18px;border:2px solid var(--border);border-top-color:var(--accent);border-radius:50%;animation:spin .8s linear infinite;}
.recent-item{cursor:pointer;background:var(--bg);padding:8px 9px;border-radius:7px;display:flex;gap:8px;align-items:center;border:1px solid var(--border);transition:.15s;}
.recent-item:hover{border-color:var(--accent);}
/* PERSISTENT TOOLBAR */
.toolbar{display:flex;align-items:center;gap:12px;padding:6px 14px;background:var(--surface);border-bottom:1px solid var(--border);flex-shrink:0;min-height:48px;}
.tb-search{display:flex;align-items:center;gap:8px;background:var(--bg);border:1px solid var(--border);border-radius:8px;padding:0 12px;height:32px;flex:1;max-width:240px;transition:all .2s ease;}
.tb-search:focus-within{border-color:var(--accent);box-shadow:0 0 0 2px var(--accent-dim);}
.tb-search svg{width:13px;height:13px;fill:none;stroke:var(--muted);stroke-width:2.5;flex-shrink:0;}
.tb-search input{background:transparent;border:none;color:var(--text);font-size:12px;outline:none;width:100%;height:100%;}
.tb-filters{display:flex;align-items:center;gap:6px;}
.tb-sel{background:var(--surface2);border:1px solid var(--border);color:var(--muted2);padding:0 8px;height:32px;border-radius:8px;font-size:11px;font-weight:600;cursor:pointer;outline:none;transition:all .15s;min-width:100px;}
.tb-sel:hover{border-color:var(--muted);color:var(--text);}
.tb-sel:focus{border-color:var(--accent);color:var(--text);background:var(--bg);}
.tb-reset{font-size:11px;font-weight:700;color:var(--muted);cursor:pointer;height:32px;padding:0 12px;border-radius:8px;border:1px solid var(--border);background:transparent;white-space:nowrap;transition:.15s;display:flex;align-items:center;justify-content:center;}
.tb-reset:hover{color:var(--text);border-color:var(--muted);background:var(--surface2);}
.tb-right{display:flex;align-items:center;gap:10px;margin-left:auto;flex-shrink:0;}
.tb-count{font-size:11px;color:var(--muted);font-weight:600;font-variant-numeric:tabular-nums;background:var(--surface2);padding:4px 8px;border-radius:6px;border:1px solid var(--border);}
.view-sw{display:flex;background:var(--surface2);padding:2px;border-radius:8px;border:1px solid var(--border);}
.vbtn{width:28px;height:28px;display:flex;align-items:center;justify-content:center;border-radius:6px;cursor:pointer;color:var(--muted);border:none;background:transparent;transition:.15s;}
.vbtn.active{background:var(--accent);color:white;box-shadow:0 2px 8px var(--accent-glow);}
.vbtn svg{width:14px;height:14px;fill:none;stroke:currentColor;stroke-width:2;}
.view-tabs-sw{display:flex;background:var(--surface2);padding:2px;border-radius:8px;border:1px solid var(--border);}
.vtab{padding:0 12px;height:28px;border-radius:6px;font-size:11px;font-weight:700;cursor:pointer;color:var(--muted);border:none;background:transparent;transition:.15s;display:flex;align-items:center;}
.vtab.active{background:var(--accent);color:white;box-shadow:0 2px 8px var(--accent-glow);}
.btn-icon{width:32px;height:32px;border-radius:8px;border:1px solid var(--border);background:var(--surface2);color:var(--muted);display:flex;align-items:center;justify-content:center;cursor:pointer;transition:.2s;flex-shrink:0;}
.btn-icon:hover{color:white;border-color:var(--accent);background:var(--bg);}
.btn-icon svg{width:15px;height:15px;fill:none;stroke:currentColor;stroke-width:2.5;}
/* SCROLL VIEW */
.scroll-view{flex:1;overflow-y:auto;padding:14px 16px;display:flex;flex-direction:column;gap:12px;}
/* APP HEADER */
.app-card{background:var(--surface);border:1px solid var(--border);padding:16px 18px;border-radius:14px;display:flex;gap:15px;align-items:flex-start;}
.app-card img{width:66px;height:66px;border-radius:14px;object-fit:cover;flex-shrink:0;}
.app-stats{display:flex;gap:8px;margin-top:10px;flex-wrap:wrap;}
.stat-pill{background:var(--surface2);border:1px solid var(--border);border-radius:8px;padding:6px 11px;display:flex;flex-direction:column;align-items:center;min-width:54px;}
.stat-val{font-size:14px;font-weight:800;line-height:1;}
.stat-key{font-size:9px;font-weight:700;text-transform:uppercase;color:var(--muted);margin-top:3px;letter-spacing:.4px;}
/* SUMMARY BAR */
.summary-bar{background:var(--surface);border:1px solid var(--border);border-radius:12px;padding:12px 16px;display:flex;gap:16px;align-items:center;flex-wrap:wrap;}
.star-dist{flex:1;display:flex;flex-direction:column;gap:4px;min-width:140px;}
.star-bar-row{display:flex;align-items:center;gap:6px;font-size:10px;}
.star-bar-track{flex:1;height:4px;background:var(--border);border-radius:3px;overflow:hidden;}
.star-bar-fill{height:100%;border-radius:3px;background:var(--amber);}
/* REVIEW CARD */
.review-card{background:var(--surface);border:1px solid var(--border);border-radius:12px;overflow:hidden;transition:border-color .15s;}
.review-card:hover{border-color:#2d3a4f;}
.review-main{padding:13px 15px;}
.review-header{display:flex;align-items:center;gap:9px;margin-bottom:8px;}
.user-avatar{width:31px;height:31px;border-radius:50%;background:var(--surface2);flex-shrink:0;display:flex;align-items:center;justify-content:center;font-weight:700;font-size:11px;color:var(--muted2);border:1px solid var(--border);overflow:hidden;}
.user-avatar img{width:31px;height:31px;border-radius:50%;object-fit:cover;}
.review-meta{flex:1;min-width:0;}
.review-username{font-weight:700;font-size:12px;white-space:nowrap;overflow:hidden;text-overflow:ellipsis;}
.review-date{font-size:10px;color:var(--muted);margin-top:1px;}
.review-stars{display:flex;gap:1px;flex-shrink:0;}
.review-text{font-size:12px;color:#cbd5e1;line-height:1.6;}
.review-footer{padding:8px 15px;background:var(--bg);border-top:1px solid var(--border);display:flex;flex-wrap:wrap;gap:6px;align-items:center;}
.meta-pill{display:inline-flex;align-items:center;gap:4px;font-size:10px;font-weight:600;padding:3px 8px;border-radius:14px;border:1px solid var(--border);color:var(--muted2);background:var(--surface);}
.meta-pill svg{width:10px;height:10px;fill:none;stroke:currentColor;stroke-width:2.5;flex-shrink:0;}
.meta-pill.thumbs{color:var(--accent);border-color:rgba(59,130,246,.25);background:var(--accent-dim);}
.meta-pill.version{color:var(--purple);border-color:rgba(167,139,250,.25);background:var(--purple-dim);}
.meta-pill.replied{color:var(--green);border-color:rgba(34,197,94,.25);background:var(--green-dim);}
.dev-reply{margin:0 15px 12px;background:var(--surface2);border:1px solid var(--border);border-left:3px solid var(--green);border-radius:8px;padding:10px 12px;}
.dev-reply-header{font-size:10px;font-weight:700;color:var(--green);margin-bottom:5px;display:flex;align-items:center;gap:4px;}
.dev-reply-text{font-size:11px;color:var(--muted2);line-height:1.55;}
.dev-reply-date{font-size:9px;color:var(--muted);margin-top:4px;}
/* SEARCH GRID */
.search-results-grid{display:grid;grid-template-columns:repeat(auto-fill,minmax(250px,1fr));gap:11px;}
.search-app-card{background:var(--surface);border:1px solid var(--border);border-radius:12px;padding:13px;display:flex;gap:11px;cursor:pointer;transition:.18s;}
.search-app-card:hover{border-color:var(--accent);background:var(--surface2);transform:translateY(-2px);}
.search-app-card img{width:48px;height:48px;border-radius:10px;flex-shrink:0;}
.search-app-info{flex:1;min-width:0;display:flex;flex-direction:column;justify-content:space-between;}
.search-app-title{font-size:12px;font-weight:700;color:var(--text);overflow:hidden;text-overflow:ellipsis;white-space:nowrap;}
.search-app-dev{font-size:10px;color:var(--muted);margin-bottom:3px;}
.search-app-meta{display:flex;align-items:center;gap:6px;font-size:10px;font-weight:600;}
.search-app-score{color:var(--amber);}
.search-app-installs{color:var(--muted2);}
/* MISC */
.loader-overlay{position:absolute;inset:0;background:var(--bg);display:flex;flex-direction:column;align-items:center;justify-content:center;gap:14px;z-index:10;}
.spinner{width:34px;height:34px;border:3px solid var(--border);border-top-color:var(--accent);border-radius:50%;animation:spin .8s linear infinite;}
.site-overlay{position:absolute;inset:0;background:var(--bg);display:flex;flex-direction:column;align-items:center;justify-content:center;text-align:center;gap:14px;padding:40px;}
.site-overlay h3{font-size:17px;font-weight:700;}
.site-overlay p{color:var(--muted);font-size:13px;max-width:360px;}
.hidden{display:none!important;}
@keyframes spin{to{transform:rotate(360deg);}}
/* CHAT */
#chat-dialer{position:fixed;bottom:22px;right:22px;width:50px;height:50px;background:var(--accent);border-radius:50%;display:flex;align-items:center;justify-content:center;box-shadow:0 8px 28px rgba(59,130,246,.4);cursor:pointer;z-index:1000;transition:.3s cubic-bezier(.175,.885,.32,1.275);border:2px solid rgba(255,255,255,.1);}
#chat-dialer:hover{transform:scale(1.1) rotate(5deg);}
#chat-dialer svg{width:20px;height:20px;color:white;fill:none;stroke:currentColor;stroke-width:2.5;}
#chat-window{position:fixed;bottom:82px;right:22px;width:400px;height:560px;background:var(--surface);border:1px solid var(--border);border-radius:18px;display:flex;flex-direction:column;box-shadow:0 20px 50px rgba(0,0,0,.5);z-index:1001;overflow:hidden;transform:translateY(20px) scale(.95);opacity:0;pointer-events:none;transition:.3s cubic-bezier(.4,0,.2,1);}
#chat-window.open{transform:translateY(0) scale(1);opacity:1;pointer-events:auto;}
.chat-hdr{padding:12px 16px;background:var(--accent);color:white;display:flex;align-items:center;gap:11px;flex-shrink:0;}
.chat-hdr-info{flex:1;}
.chat-hdr-title{font-weight:800;font-size:13px;}
.chat-hdr-status{font-size:10px;opacity:.8;display:flex;align-items:center;gap:4px;}
.sdot{width:5px;height:5px;background:#22c55e;border-radius:50%;}
.chat-clr{background:rgba(255,255,255,.15);border:none;color:white;font-size:11px;padding:3px 8px;border-radius:7px;cursor:pointer;}
.chat-clr:hover{background:rgba(255,255,255,.25);}
.chat-msgs{flex:1;overflow-y:auto;padding:13px;display:flex;flex-direction:column;gap:9px;background-image:radial-gradient(var(--border) 1px,transparent 1px);background-size:20px 20px;}
.msg-row{display:flex;flex-direction:column;gap:4px;}
.msg-row.user{align-items:flex-end;}
.msg-row.bot{align-items:flex-start;}
.message{max-width:88%;padding:9px 13px;border-radius:14px;font-size:12px;line-height:1.6;}
.message.user{background:var(--accent);color:white;border-bottom-right-radius:4px;}
.message.bot{background:var(--surface2);color:var(--text);border:1px solid var(--border);border-bottom-left-radius:4px;white-space:pre-wrap;word-break:break-word;}
.msg-section{margin-top:9px;font-weight:700;font-size:10px;color:var(--accent);letter-spacing:.05em;text-transform:uppercase;}
.msg-item{display:flex;gap:7px;margin-top:4px;}
.msg-item-num,.msg-bullet{font-weight:700;color:var(--accent);min-width:14px;}
.ctw{max-width:100%;overflow-x:auto;border:1px solid var(--border);border-radius:9px;background:var(--surface2);margin-top:4px;}
.ctt{padding:6px 10px;font-size:10px;font-weight:700;color:var(--accent);border-bottom:1px solid var(--border);text-transform:uppercase;}
.ct{width:100%;border-collapse:collapse;font-size:11px;}
.ct th{padding:5px 9px;text-align:left;font-weight:700;font-size:10px;color:var(--muted2);background:var(--bg);border-bottom:1px solid var(--border);}
.ct td{padding:5px 9px;border-bottom:1px solid var(--border);color:var(--text);max-width:150px;overflow:hidden;text-overflow:ellipsis;white-space:nowrap;}
.ct tr:last-child td{border-bottom:none;}
.ct tr:hover td{background:var(--surface);}
.typing{display:flex;gap:3px;padding:9px 13px;background:var(--surface2);border:1px solid var(--border);border-radius:13px;width:fit-content;}
.dot{width:5px;height:5px;background:var(--muted);border-radius:50%;animation:bounce 1.4s infinite;}
.dot:nth-child(2){animation-delay:.2s;}
.dot:nth-child(3){animation-delay:.4s;}
@keyframes bounce{0%,80%,100%{transform:translateY(0)}40%{transform:translateY(-5px)}}
.chat-inp-area{padding:11px 13px;background:var(--surface);border-top:1px solid var(--border);display:flex;gap:7px;flex-shrink:0;}
#chat-input{flex:1;background:var(--bg);border:1px solid var(--border);color:white;padding:8px 11px;border-radius:9px;font-size:12px;outline:none;}
#chat-input:focus{border-color:var(--accent);}
.btn-send{width:36px;height:36px;background:var(--accent);color:white;border:none;border-radius:8px;display:flex;align-items:center;justify-content:center;cursor:pointer;flex-shrink:0;}
.btn-send:hover{opacity:.85;}
.btn-send svg{width:14px;height:14px;fill:none;stroke:currentColor;stroke-width:2.5;}
.chat-sugs{display:flex;flex-wrap:wrap;gap:4px;padding:0 13px 7px;}
.sug{font-size:10px;padding:3px 8px;border-radius:16px;background:var(--surface2);border:1px solid var(--border);color:var(--muted2);cursor:pointer;transition:.2s;}
.sug:hover{border-color:var(--accent);color:var(--accent);}
</style>
</head>
<body>
<div class="header">
<a href="/" class="logo">
<svg width="18" height="18" viewBox="0 0 24 24" fill="none" stroke="currentColor" stroke-width="3" stroke-linecap="round" stroke-linejoin="round"><path d="M12 2v20M17 5H9.5a3.5 3.5 0 0 0 0 7h5a3.5 3.5 0 0 1 0 7H6"/></svg>
PLAYPULSE
</a>
<nav>
<a href="/" class="nav-link">Home</a>
<a href="/scraper" class="nav-link active">Single Explorer</a>
<a href="/batch" class="nav-link">Batch Intelligence</a>
</nav>
<div style="flex:1"></div>
</div>
<div class="main" id="mainLayout">
<!-- SIDEBAR -->
<aside class="sidebar" id="sidebar">
<div class="sb-inner">
<div class="input-group">
<div class="label">App Identity</div>
<div class="search-wrap">
<input type="text" id="target" placeholder="App name or Play Store link" value="WhatsApp" autocomplete="off">
<div class="suggestions-box hidden" id="suggestionsBox"></div>
</div>
</div>
<div class="input-group">
<div class="label">Amount of Data</div>
<div class="toggle-grp">
<button class="toggle-btn" id="btnAll" onclick="setMode('all')">Fetch All</button>
<button class="toggle-btn active" id="btnLimit" onclick="setMode('limit')">Custom</button>
</div>
<input type="number" id="manualCount" value="200" placeholder="e.g. 500">
</div>
<div class="input-group">
<div class="label">Strategy</div>
<select id="sort">
<option value="MOST_RELEVANT">Most Relevant</option>
<option value="NEWEST">Newest</option>
<option value="RATING">Top Ratings</option>
</select>
</div>
<div class="input-group">
<div class="label">
<span>Star Filter</span>
<div style="display:flex;gap:4px">
<button class="qbtn" onclick="selectAllStars(true)">All</button>
<button class="qbtn" onclick="selectAllStars(false)">None</button>
</div>
</div>
<div class="star-filter-grid">
<label class="star-row"><input type="checkbox" class="star-cb" value="5" checked><span class="star-label"><span class="stars-on">β˜…β˜…β˜…β˜…β˜…</span>&nbsp;5 Stars</span></label>
<label class="star-row"><input type="checkbox" class="star-cb" value="4" checked><span class="star-label"><span class="stars-on">β˜…β˜…β˜…β˜…</span><span class="stars-off">β˜…</span>&nbsp;4 Stars</span></label>
<label class="star-row"><input type="checkbox" class="star-cb" value="3" checked><span class="star-label"><span class="stars-on">β˜…β˜…β˜…</span><span class="stars-off">β˜…β˜…</span>&nbsp;3 Stars</span></label>
<label class="star-row"><input type="checkbox" class="star-cb" value="2" checked><span class="star-label"><span class="stars-on">β˜…β˜…</span><span class="stars-off">β˜…β˜…β˜…</span>&nbsp;2 Stars</span></label>
<label class="star-row"><input type="checkbox" class="star-cb" value="1" checked><span class="star-label"><span class="stars-on">β˜…</span><span class="stars-off">β˜…β˜…β˜…β˜…</span>&nbsp;1 Star</span></label>
</div>
<div class="filter-chips" id="filterChips"></div>
</div>
<button class="btn-main" id="go" onclick="run()">
<svg width="16" height="16" viewBox="0 0 24 24" fill="none" stroke="currentColor" stroke-width="3"><polyline points="13 17 18 12 13 7"/><line x1="6" y1="17" x2="6" y2="7"/></svg>
START SCRAPING
</button>
<div class="input-group">
<div class="label">Recent Sessions</div>
<div id="recentList" style="display:flex;flex-direction:column;gap:6px;"></div>
</div>
</div>
</aside>
<div class="collapse-btn" id="collapseBtn" onclick="toggleSidebar()" title="Toggle sidebar">
<svg viewBox="0 0 24 24"><polyline points="15 18 9 12 15 6"/></svg>
</div>
<div class="resize-handle" id="resizeHandle"></div>
<!-- CONTENT -->
<div class="content">
<!-- PERSISTENT TOOLBAR β€” never re-rendered -->
<div class="toolbar">
<div class="tb-search">
<svg viewBox="0 0 24 24"><circle cx="11" cy="11" r="8"/><line x1="21" y1="21" x2="16.65" y2="16.65"/></svg>
<input type="text" id="tbSearch" placeholder="Search reviews…" oninput="applyFilters()">
</div>
<div class="tb-filters">
<select class="tb-sel" id="tbSort" onchange="applyFilters()">
<option value="default">Sort: Default</option>
<option value="score_desc">β˜… High β†’ Low</option>
<option value="score_asc">β˜… Low β†’ High</option>
<option value="helpful">Most Helpful</option>
<option value="date_desc">Newest First</option>
<option value="date_asc">Oldest First</option>
</select>
<select class="tb-sel" id="tbReplied" onchange="applyFilters()">
<option value="all">All Status</option>
<option value="yes">Replied</option>
<option value="no">Unreplied</option>
</select>
<select class="tb-sel" id="tbHelpful" onchange="applyFilters()">
<option value="all">Any Help</option>
<option value="10">10+ Likes</option>
<option value="50">50+ Likes</option>
<option value="100">100+ Likes</option>
</select>
<button class="tb-reset" onclick="resetFilters()" title="Reset Filters">
<svg width="12" height="12" viewBox="0 0 24 24" fill="none" stroke="currentColor" stroke-width="2.5" style="margin-right:4px"><path d="M3 12a9 9 0 1 0 9-9 9.75 9.75 0 0 0-6.74 2.74L3 8"/><path d="M3 3v5h5"/></svg>
Reset
</button>
</div>
<div class="tb-right">
<span class="tb-count" id="tbCount"></span>
<div class="view-sw">
<button class="vbtn active" id="vbtnList" onclick="setView('list')" title="List view">
<svg viewBox="0 0 24 24"><line x1="8" y1="6" x2="21" y2="6"/><line x1="8" y1="12" x2="21" y2="12"/><line x1="8" y1="18" x2="21" y2="18"/><circle cx="3" cy="6" r="1" fill="currentColor" stroke="none"/><circle cx="3" cy="12" r="1" fill="currentColor" stroke="none"/><circle cx="3" cy="18" r="1" fill="currentColor" stroke="none"/></svg>
</button>
<button class="vbtn" id="vbtnGrid" onclick="setView('grid')" title="Grid view">
<svg viewBox="0 0 24 24"><rect x="3" y="3" width="7" height="7" rx="1"/><rect x="14" y="3" width="7" height="7" rx="1"/><rect x="14" y="14" width="7" height="7" rx="1"/><rect x="3" y="14" width="7" height="7" rx="1"/></svg>
</button>
</div>
<div class="view-tabs-sw">
<button class="vtab active" id="tabData" onclick="switchPane('data')">Reviews</button>
<button class="vtab" id="tabSite" onclick="switchPane('site')">Live Store</button>
</div>
<div class="btn-icon" onclick="downloadCSV()" title="Export results to CSV">
<svg viewBox="0 0 24 24"><path d="M21 15v4a2 2 0 0 1-2 2H5a2 2 0 0 1-2-2v-4M7 10l5 5 5-5M12 15V3"/></svg>
</div>
</div>
</div>
<!-- DATA PANE -->
<div id="dataPane" class="scroll-view">
<div id="welcome" style="display:flex;flex-direction:column;align-items:center;justify-content:center;flex:1;color:var(--muted);gap:13px;text-align:center;padding:40px;">
<svg width="50" height="50" viewBox="0 0 24 24" fill="none" stroke="currentColor" stroke-width="1.2"><path d="M14.5 2H6a2 2 0 0 0-2 2v16a2 2 0 0 0 2 2h12a2 2 0 0 0 2-2V7.5L14.5 2z"/><polyline points="14 2 14 8 20 8"/></svg>
<div><p style="font-size:14px;font-weight:700;color:var(--muted2);margin-bottom:4px">No data loaded</p><p style="font-size:12px">Enter an app name or Play Store link and hit Start Scraping</p></div>
</div>
<div id="appHeader" class="hidden"></div>
<div id="summaryBar" class="hidden"></div>
<div id="reviewList" class="hidden"></div>
</div>
<!-- LOADER -->
<div id="loader" class="loader-overlay hidden">
<div class="spinner"></div>
<p style="color:var(--muted);font-size:13px" id="loaderMsg">Connecting to servers…</p>
</div>
<!-- SITE PANE -->
<div id="sitePane" class="hidden" style="flex:1;position:relative;">
<div class="site-overlay">
<h3>Web View Shielded</h3>
<p>Google Play Store blocks embedding. Open it directly below.</p>
<button class="btn-main" style="width:auto;padding:11px 22px" onclick="openTarget()">
Open on Google Play
<svg width="14" height="14" viewBox="0 0 24 24" fill="none" stroke="currentColor" stroke-width="2.5"><path d="M18 13v6a2 2 0 0 1-2 2H5a2 2 0 0 1-2-2V8a2 2 0 0 1 2-2h6M15 3h6v6M10 14L21 3"/></svg>
</button>
</div>
</div>
</div>
</div>
<!-- CHAT -->
<div id="chat-dialer" onclick="toggleChat()">
<svg viewBox="0 0 24 24"><path d="M21 15a2 2 0 0 1-2 2H7l-4 4V5a2 2 0 0 1 2-2h14a2 2 0 0 1 2 2z"/></svg>
</div>
<div id="chat-window">
<div class="chat-hdr">
<svg width="16" height="16" viewBox="0 0 24 24" fill="none" stroke="white" stroke-width="2.5"><circle cx="12" cy="12" r="3"/><path d="M12 1v4M12 19v4M4.22 4.22l2.83 2.83M16.95 16.95l2.83 2.83M1 12h4M19 12h4M4.22 19.78l2.83-2.83M16.95 7.05l2.83-2.83"/></svg>
<div class="chat-hdr-info">
<div class="chat-hdr-title">PlayPulse Intelligence</div>
<div class="chat-hdr-status"><span class="sdot"></span> Agent Online</div>
</div>
<div style="display:flex;gap:7px;align-items:center">
<button class="chat-clr" onclick="clearChat()">Clear</button>
<div style="cursor:pointer;opacity:.7" onclick="toggleChat()"><svg width="14" height="14" viewBox="0 0 24 24" fill="none" stroke="white" stroke-width="2.5"><line x1="18" y1="6" x2="6" y2="18"/><line x1="6" y1="6" x2="18" y2="18"/></svg></div>
</div>
</div>
<div class="chat-msgs" id="chat-messages">
<div class="msg-row bot"><div class="message bot">πŸ‘‹ Hi! I'm PlayPulse Intelligence. Ask me anything about the loaded reviews β€” issues, sentiment, or say <em>"show in table"</em>.</div></div>
</div>
<div class="chat-sugs">
<div class="sug" onclick="fillChat('What are the main issues?')">Main issues</div>
<div class="sug" onclick="fillChat('Show most helpful reviews')">Most helpful</div>
<div class="sug" onclick="fillChat('Show 1 star reviews in table')">1β˜… table</div>
<div class="sug" onclick="fillChat('Summarize overall sentiment')">Sentiment</div>
</div>
<div class="chat-inp-area">
<input type="text" id="chat-input" placeholder="Ask about the reviews…" onkeydown="if(event.key==='Enter') sendChat()">
<button class="btn-send" onclick="sendChat()"><svg viewBox="0 0 24 24"><line x1="22" y1="2" x2="11" y2="13"/><polygon points="22 2 15 22 11 13 2 9 22 2"/></svg></button>
</div>
</div>
<script>
// ══════════════════════════════════════
// STATE
// ══════════════════════════════════════
let mode = 'limit', currentData = null, baseReviews = [], viewMode = 'list';
// ══════════════════════════════════════
// SIDEBAR COLLAPSE + RESIZE
// ══════════════════════════════════════
const sidebar = document.getElementById('sidebar');
const colBtn = document.getElementById('collapseBtn');
const resizeH = document.getElementById('resizeHandle');
let sbW = 284;
function posColBtn() {
const col = sidebar.classList.contains('collapsed');
colBtn.style.left = (col ? 0 : sbW) + 'px';
colBtn.querySelector('svg').style.transform = col ? 'rotate(180deg)' : '';
}
function toggleSidebar() { sidebar.classList.toggle('collapsed'); posColBtn(); }
let rsOn = false, rsX0, rsW0;
resizeH.addEventListener('mousedown', e => {
rsOn = true; rsX0 = e.clientX; rsW0 = sidebar.getBoundingClientRect().width;
resizeH.classList.add('active'); document.body.style.cursor = 'col-resize'; document.body.style.userSelect = 'none'; e.preventDefault();
});
document.addEventListener('mousemove', e => {
if (!rsOn) return;
sbW = Math.max(220, Math.min(460, rsW0 + (e.clientX - rsX0)));
sidebar.style.width = sbW + 'px'; posColBtn();
});
document.addEventListener('mouseup', () => { if (!rsOn) return; rsOn = false; resizeH.classList.remove('active'); document.body.style.cursor = ''; document.body.style.userSelect = ''; });
posColBtn();
// ══════════════════════════════════════
// PANE + VIEW SWITCHING
// ══════════════════════════════════════
function switchPane(p) {
document.getElementById('dataPane').classList.toggle('hidden', p !== 'data');
document.getElementById('sitePane').classList.toggle('hidden', p !== 'site');
document.getElementById('tabData').classList.toggle('active', p === 'data');
document.getElementById('tabSite').classList.toggle('active', p === 'site');
}
function setView(v) {
viewMode = v;
document.getElementById('vbtnList').classList.toggle('active', v === 'list');
document.getElementById('vbtnGrid').classList.toggle('active', v === 'grid');
if (baseReviews.length) applyFilters();
}
function openTarget() {
const url = document.getElementById('target').value;
if (url.startsWith('http')) window.open(url, '_blank');
else if (currentData) window.open(`https://play.google.com/store/apps/details?id=${currentData.app_info.appId}`, '_blank');
}
// ══════════════════════════════════════
// SIDEBAR INPUTS
// ══════════════════════════════════════
function setMode(m) {
mode = m;
document.getElementById('btnAll').classList.toggle('active', m === 'all');
document.getElementById('btnLimit').classList.toggle('active', m === 'limit');
document.getElementById('manualCount').classList.toggle('hidden', m === 'all');
}
function selectAllStars(c) { document.querySelectorAll('.star-cb').forEach(cb => cb.checked = c); updateChips(); }
function updateChips() {
const sel = getSelectedStars();
document.getElementById('filterChips').innerHTML = (sel.length === 5 || !sel.length) ? '' : sel.map(s => `<span class="chip">${s}β˜…</span>`).join('');
}
function getSelectedStars() { return [...document.querySelectorAll('.star-cb:checked')].map(cb => parseInt(cb.value)); }
document.querySelectorAll('.star-cb').forEach(cb => cb.addEventListener('change', updateChips));
// ══════════════════════════════════════
// SCRAPE
// ══════════════════════════════════════
async function run() {
const query = document.getElementById('target').value.trim();
if (!query) return;
const stars = getSelectedStars();
if (!stars.length) { alert('Select at least one star rating.'); return; }
currentData = null; baseReviews = [];
document.getElementById('welcome').classList.add('hidden');
document.getElementById('appHeader').classList.add('hidden');
document.getElementById('summaryBar').classList.add('hidden');
document.getElementById('reviewList').classList.add('hidden');
document.getElementById('reviewList').innerHTML = '';
document.getElementById('tbCount').textContent = '';
document.getElementById('loader').classList.remove('hidden');
hideSuggestions();
document.getElementById('go').disabled = true;
const msgs = ['Connecting to servers…','Fetching app info…','Scraping reviews…','Processing data…'];
let mi = 0; document.getElementById('loaderMsg').textContent = msgs[0];
const mi2 = setInterval(() => { mi = (mi+1)%msgs.length; document.getElementById('loaderMsg').textContent = msgs[mi]; }, 2200);
try {
const res = await fetch('/scrape', { method: 'POST', headers: { 'Content-Type': 'application/json' }, body: JSON.stringify({ identifier: query, review_count_type: mode, review_count: parseInt(document.getElementById('manualCount').value)||200, sort_order: document.getElementById('sort').value, star_ratings: stars.length===5?'all':stars }) });
const data = await res.json();
if (!res.ok) throw new Error(data.error || 'Scraping failed');
currentData = data; baseReviews = data.reviews;
renderHeader(data, stars);
applyFilters();
save(data.app_info);
} catch (e) {
document.getElementById('appHeader').classList.remove('hidden');
document.getElementById('appHeader').innerHTML = `<div style="background:rgba(239,68,68,0.08);border:1px solid rgba(239,68,68,0.3);border-radius:12px;padding:18px 20px;"><div style="display:flex;align-items:center;gap:9px;margin-bottom:7px;"><svg width="16" height="16" viewBox="0 0 24 24" fill="none" stroke="#ef4444" stroke-width="2.5"><circle cx="12" cy="12" r="10"/><line x1="12" y1="8" x2="12" y2="12"/><line x1="12" y1="16" x2="12.01" y2="16"/></svg><span style="font-weight:700;color:#ef4444">Operation Failed</span></div><p style="color:var(--muted2);font-size:12px">${escH(e.message)}</p></div>`;
} finally {
clearInterval(mi2);
document.getElementById('loader').classList.add('hidden');
document.getElementById('go').disabled = false;
}
}
// ══════════════════════════════════════
// RENDER APP HEADER + SUMMARY BAR
// ══════════════════════════════════════
function renderHeader(data, stars) {
const info = data.app_info, rv = data.reviews;
const dist = {1:0,2:0,3:0,4:0,5:0};
rv.forEach(r => { if (r.score>=1&&r.score<=5) dist[r.score]++; });
const total = rv.length;
const replied = rv.filter(r => r.replyContent).length;
const avg = total ? (rv.reduce((a,r)=>a+(r.score||0),0)/total).toFixed(2) : 'β€”';
const likes = rv.reduce((a,r)=>a+(r.thumbsUpCount||0),0);
const fLabel = stars.length===5?'All Ratings':stars.sort((a,b)=>b-a).map(s=>`${s}β˜…`).join(', ');
const sdist = [5,4,3,2,1].map(s => { const p=total?Math.round((dist[s]/total)*100):0; return `<div class="star-bar-row"><span style="color:var(--amber);width:10px;text-align:right">${s}</span><div class="star-bar-track"><div class="star-bar-fill" style="width:${p}%"></div></div><span style="color:var(--muted);width:26px;text-align:right">${p}%</span></div>`; }).join('');
document.getElementById('appHeader').classList.remove('hidden');
document.getElementById('appHeader').innerHTML = `<div class="app-card">
<img src="${info.icon}" alt="" onerror="this.style.opacity='.3'">
<div style="flex:1;min-width:0">
<h2 style="font-size:18px;font-weight:800;margin-bottom:2px;overflow:hidden;text-overflow:ellipsis;white-space:nowrap">${escH(info.title)}</h2>
<div style="color:var(--accent);font-weight:700;font-size:10px;font-family:monospace;margin-bottom:8px">${escH(info.appId)}</div>
<div class="app-stats">
<div class="stat-pill"><span class="stat-val" style="color:var(--amber)">${(info.score||0).toFixed(1)}</span><span class="stat-key">Store Avg</span></div>
<div class="stat-pill"><span class="stat-val">${total.toLocaleString()}</span><span class="stat-key">Fetched</span></div>
<div class="stat-pill"><span class="stat-val" style="color:var(--green)">${replied}</span><span class="stat-key">Replied</span></div>
<div class="stat-pill"><span class="stat-val" style="color:var(--accent)">${fmtNum(likes)||'0'}</span><span class="stat-key">Likes</span></div>
</div>
</div></div>`;
document.getElementById('summaryBar').classList.remove('hidden');
document.getElementById('summaryBar').innerHTML = `<div class="summary-bar">
<div><div style="font-size:21px;font-weight:800;color:var(--amber)">${avg}</div><div style="font-size:9px;font-weight:700;text-transform:uppercase;color:var(--muted);letter-spacing:.4px;margin-top:2px">Avg Score</div></div>
<div class="star-dist">${sdist}</div>
<div style="font-size:11px;color:var(--muted);padding:5px 10px;border-radius:7px;background:var(--bg);border:1px solid var(--border);white-space:nowrap;flex-shrink:0">Filter:<br><strong style="color:var(--accent)">${fLabel}</strong></div>
</div>`;
}
// ══════════════════════════════════════
// FILTER ENGINE (reads toolbar β€” no re-render of toolbar itself)
// ══════════════════════════════════════
function applyFilters() {
if (!baseReviews.length) return;
let rv = [...baseReviews];
const q = (document.getElementById('tbSearch')?.value || '').toLowerCase();
const sort = document.getElementById('tbSort')?.value || 'default';
const rep = document.getElementById('tbReplied')?.value || 'all';
const hlp = document.getElementById('tbHelpful')?.value || 'all';
if (q) rv = rv.filter(r => (r.content||'').toLowerCase().includes(q) || (r.userName||'').toLowerCase().includes(q));
if (rep === 'yes') rv = rv.filter(r => r.replyContent?.trim());
if (rep === 'no') rv = rv.filter(r => !r.replyContent?.trim());
if (hlp !== 'all') { const n = parseInt(hlp); rv = rv.filter(r => (r.thumbsUpCount||0) >= n); }
if (sort === 'score_desc') rv.sort((a,b) => (b.score||0)-(a.score||0));
else if (sort === 'score_asc') rv.sort((a,b) => (a.score||0)-(b.score||0));
else if (sort === 'helpful') rv.sort((a,b) => (b.thumbsUpCount||0)-(a.thumbsUpCount||0));
else if (sort === 'date_desc') rv.sort((a,b) => (b.at||'').localeCompare(a.at||''));
else if (sort === 'date_asc') rv.sort((a,b) => (a.at||'').localeCompare(b.at||''));
document.getElementById('tbCount').textContent = `${rv.length.toLocaleString()} of ${baseReviews.length.toLocaleString()}`;
renderReviews(rv);
}
function resetFilters() {
document.getElementById('tbSearch').value = '';
document.getElementById('tbSort').value = 'default';
document.getElementById('tbReplied').value = 'all';
document.getElementById('tbHelpful').value = 'all';
applyFilters();
}
// ══════════════════════════════════════
// RENDER REVIEWS (list or grid)
// ══════════════════════════════════════
function renderReviews(reviews) {
const el = document.getElementById('reviewList');
el.classList.remove('hidden');
if (viewMode === 'grid') {
el.style.cssText = 'display:grid;grid-template-columns:repeat(auto-fill,minmax(310px,1fr));gap:11px;';
} else {
el.style.cssText = 'display:flex;flex-direction:column;gap:10px;';
}
if (!reviews.length) {
el.innerHTML = '<div style="text-align:center;padding:30px;color:var(--muted);font-size:13px">No reviews match the current filters</div>';
return;
}
el.innerHTML = reviews.map(r => {
const tl = fmtNum(r.thumbsUpCount), hr = !!(r.replyContent?.trim()), ver = r.reviewCreatedVersion;
const pills = [
tl ? `<span class="meta-pill thumbs"><svg viewBox="0 0 24 24"><path d="M14 9V5a3 3 0 0 0-3-3l-4 9v11h11.28a2 2 0 0 0 2-1.7l1.38-9a2 2 0 0 0-2-2.3H14z"/><path d="M7 22H4a2 2 0 0 1-2-2v-7a2 2 0 0 1 2-2h3"/></svg>${tl} helpful</span>` : '',
ver ? `<span class="meta-pill version"><svg viewBox="0 0 24 24"><circle cx="12" cy="12" r="10"/><path d="M12 8v4l3 3"/></svg>v${ver}</span>` : '',
hr ? `<span class="meta-pill replied"><svg viewBox="0 0 24 24"><path d="M21 15a2 2 0 0 1-2 2H7l-4 4V5a2 2 0 0 1 2-2h14a2 2 0 0 1 2 2z"/></svg>Dev replied</span>` : ''
].filter(Boolean).join('');
const replyH = hr ? `<div class="dev-reply"><div class="dev-reply-header"><svg width="10" height="10" viewBox="0 0 24 24" fill="none" stroke="currentColor" stroke-width="2.5"><path d="M21 15a2 2 0 0 1-2 2H7l-4 4V5a2 2 0 0 1 2-2h14a2 2 0 0 1 2 2z"/></svg>Developer Response</div><div class="dev-reply-text">${escH(r.replyContent)}</div>${r.repliedAt?`<div class="dev-reply-date">${fmtDate(r.repliedAt)}</div>`:''}</div>` : '';
const initials = (r.userName||'?').trim().split(/\s+/).map(w=>w[0]).join('').slice(0,2).toUpperCase();
const av = r.userImage ? `<div class="user-avatar"><img src="${r.userImage}" alt="" onerror="this.style.display='none';this.parentElement.textContent='${initials}'"></div>` : `<div class="user-avatar">${initials}</div>`;
const stars = [...Array(5)].map((_,i)=>`<span style="font-size:12px;color:${i<r.score?'var(--amber)':'var(--border)'}">β˜…</span>`).join('');
return `<div class="review-card">
<div class="review-main">
<div class="review-header">${av}<div class="review-meta"><div class="review-username">${escH(r.userName||'Anonymous')}</div><div class="review-date">${fmtDate(r.at)}</div></div><div class="review-stars">${stars}</div></div>
<div class="review-text">${escH(r.content||'')}</div>
</div>
${pills ? `<div class="review-footer">${pills}</div>` : ''}
${replyH}
</div>`;
}).join('');
}
// ══════════════════════════════════════
// UTILS
// ══════════════════════════════════════
function fmtDate(iso) { if(!iso)return''; return new Date(iso).toLocaleDateString('en-US',{year:'numeric',month:'short',day:'numeric'}); }
function fmtNum(n) { if(!n)return null; if(n>=1e6)return(n/1e6).toFixed(1)+'M'; if(n>=1000)return(n/1000).toFixed(1)+'k'; return String(n); }
function escH(s) { return String(s||'').replace(/&/g,'&amp;').replace(/</g,'&lt;').replace(/>/g,'&gt;').replace(/"/g,'&quot;'); }
function downloadCSV() {
if (!currentData) return;
const esc = v => `"${String(v||'').replace(/"/g,'""')}"`;
const hdr = ['Review ID','User','Score','Date','Content','Thumbs Up','App Version','Dev Reply','Dev Reply Date'];
const rows = currentData.reviews.map(r => [esc(r.reviewId||''),esc(r.userName||''),r.score||0,esc((r.at||'').slice(0,10)),esc(r.content||''),r.thumbsUpCount||0,esc(r.reviewCreatedVersion||''),esc(r.replyContent||''),esc((r.repliedAt||'').slice(0,10))].join(','));
const blob = new Blob([[hdr.join(','),...rows].join('\n')],{type:'text/csv'});
const a = Object.assign(document.createElement('a'),{href:URL.createObjectURL(blob),download:`${currentData.app_info.appId}_reviews.csv`});
a.click(); URL.revokeObjectURL(a.href);
}
function save(info) {
let list = JSON.parse(localStorage.getItem('scrapes')||'[]');
list = [info,...list.filter(x=>x.appId!==info.appId)].slice(0,5);
localStorage.setItem('scrapes',JSON.stringify(list)); loadRecent();
}
function loadRecent() {
const list = JSON.parse(localStorage.getItem('scrapes')||'[]');
document.getElementById('recentList').innerHTML = list.map(x=>`<div class="recent-item" onclick="document.getElementById('target').value='${escH(x.appId)}';run()"><img src="${x.icon}" style="width:22px;height:22px;border-radius:4px;flex-shrink:0" alt=""><span style="font-size:11px;font-weight:600;overflow:hidden;text-overflow:ellipsis;white-space:nowrap;flex:1">${escH(x.title)}</span><span style="font-size:10px;color:var(--muted);flex-shrink:0">${(x.score||0).toFixed(1)}β˜…</span></div>`).join('');
}
loadRecent();
// ══════════════════════════════════════
// LIVE SEARCH SUGGESTIONS
// ══════════════════════════════════════
let searchTimer = null;
const targetEl = document.getElementById('target');
const sugBox = document.getElementById('suggestionsBox');
function hideSuggestions() { sugBox.classList.add('hidden'); sugBox.innerHTML = ''; }
targetEl.addEventListener('input', () => {
clearTimeout(searchTimer);
const q = targetEl.value.trim();
if (q.startsWith('http') || /^[a-z][a-z0-9_]*(\.[a-z][a-z0-9_]*)+$/i.test(q)) { hideSuggestions(); return; }
if (q.length < 2) { hideSuggestions(); return; }
sugBox.classList.remove('hidden');
sugBox.innerHTML = '<div class="suggestion-loading"><div class="spinner-small"></div>Searching…</div>';
// show search area
document.getElementById('welcome').classList.add('hidden');
document.getElementById('summaryBar').classList.add('hidden');
document.getElementById('reviewList').classList.add('hidden');
document.getElementById('appHeader').classList.remove('hidden');
document.getElementById('appHeader').innerHTML = `<div style="margin-bottom:4px;font-size:15px;font-weight:800">Search Results: "${escH(q)}"</div><div class="search-results-grid" id="searchGrid"><div style="grid-column:1/-1;padding:28px;text-align:center;"><div class="spinner" style="margin:0 auto 12px"></div><p style="color:var(--muted);font-size:12px">Searching the Play Store…</p></div></div>`;
document.getElementById('tbCount').textContent = '';
searchTimer = setTimeout(async () => {
try {
const res = await fetch('/search-suggestions',{method:'POST',headers:{'Content-Type':'application/json'},body:JSON.stringify({query:q})});
const data = await res.json();
if (!data.results||!data.results.length) { sugBox.innerHTML='<div class="suggestion-loading">No results found</div>'; return; }
sugBox.innerHTML = data.results.map(r=>`<div class="suggestion-item" onclick="selectSuggestion('${escH(r.appId)}','${r.title.replace(/'/g,"\\'")}')"><img src="${r.icon}" alt=""><div class="suggestion-info"><div class="suggestion-title">${escH(r.title)}</div><div class="suggestion-sub">${escH(r.developer)} Β· ${r.installs}</div></div><div class="suggestion-score">${r.score>0?r.score+' β˜…':''}</div></div>`).join('');
const gridH = data.results.map(r=>`<div class="search-app-card" onclick="selectSuggestion('${escH(r.appId)}','${r.title.replace(/'/g,"\\'")}')"><img src="${r.icon}" alt=""><div class="search-app-info"><div><div class="search-app-title" title="${escH(r.title)}">${escH(r.title)}</div><div style="font-size:9px;color:var(--accent);font-family:monospace;margin-bottom:3px">${escH(r.appId)}</div><div class="search-app-dev">${escH(r.developer)}</div></div><div class="search-app-meta"><span class="search-app-score">${r.score>0?'β˜… '+r.score:'N/A'}</span><span class="search-app-installs">${r.installs}</span><a href="${r.storeUrl}" target="_blank" onclick="event.stopPropagation()" style="margin-left:auto;color:var(--muted);font-size:10px;text-decoration:none">Store β†—</a></div></div></div>`).join('');
const grid = document.getElementById('searchGrid'); if (grid) grid.innerHTML = gridH;
} catch(err) { hideSuggestions(); }
}, 400);
});
function selectSuggestion(appId, title) {
const valid = appId && appId!=='null' && appId!=='None' && !appId.includes('None');
targetEl.value = valid ? `https://play.google.com/store/apps/details?id=${appId}` : title;
hideSuggestions(); run();
}
document.addEventListener('click', e => { if (!targetEl.contains(e.target)&&!sugBox.contains(e.target)) hideSuggestions(); });
targetEl.addEventListener('keydown', e => { if (e.key==='Escape') hideSuggestions(); if (e.key==='Enter'){hideSuggestions();run();} });
// ══════════════════════════════════════
// CHAT
// ══════════════════════════════════════
const SESSION_ID = (()=>{let id=sessionStorage.getItem('pp_sid');if(!id){id='sess_'+Math.random().toString(36).slice(2);sessionStorage.setItem('pp_sid',id);}return id;})();
function toggleChat(){document.getElementById('chat-window').classList.toggle('open');}
function fillChat(t){const i=document.getElementById('chat-input');i.value=t;i.focus();}
async function clearChat(){
document.getElementById('chat-messages').innerHTML=`<div class="msg-row bot"><div class="message bot">Chat cleared. Ask me anything about the loaded reviews!</div></div>`;
await fetch('/chat/clear',{method:'POST',headers:{'Content-Type':'application/json'},body:JSON.stringify({session_id:SESSION_ID})}).catch(()=>{});
}
async function sendChat(){
const inp=document.getElementById('chat-input'),msg=inp.value.trim();if(!msg)return;
appendUserMsg(msg);inp.value='';
const c=document.getElementById('chat-messages');
const t=document.createElement('div');t.className='typing';t.innerHTML='<div class="dot"></div><div class="dot"></div><div class="dot"></div>';
c.appendChild(t);c.scrollTop=c.scrollHeight;
try{
const res=await fetch('/chat',{method:'POST',headers:{'Content-Type':'application/json'},body:JSON.stringify({message:msg,session_id:SESSION_ID,reviews:currentData?.reviews||[]})});
const data=await res.json();
if(t.parentNode)c.removeChild(t);
if(data.error){appendBotMsg('⚠️ '+data.error,null);return;}
appendBotMsg(data.reply||'',data.table||null);
if(data.type==='filter'&&data.filters)applyChatFilters(data.filters);
}catch(e){if(t.parentNode)c.removeChild(t);appendBotMsg('Connection error. Is the server running?',null);}
}
function appendUserMsg(text){const c=document.getElementById('chat-messages'),row=document.createElement('div');row.className='msg-row user';row.innerHTML=`<div class="message user">${escH(text)}</div>`;c.appendChild(row);c.scrollTop=c.scrollHeight;}
function appendBotMsg(text,table){
const c=document.getElementById('chat-messages'),row=document.createElement('div');row.className='msg-row bot';
if(text?.trim()){const b=document.createElement('div');b.className='message bot';b.innerHTML=renderMD(text);row.appendChild(b);}
if(table?.rows?.length)row.appendChild(buildChatTable(table));
c.appendChild(row);c.scrollTop=c.scrollHeight;
}
function renderMD(text){
const lines=text.split('\n');let html='',inList=false;
for(let raw of lines){
if(/^\*\*[^*]+\*\*:?$/.test(raw.trim())){if(inList){html+='</div>';inList=false;}html+=`<div class="msg-section">${escH(raw.trim().replace(/^\*\*/,'').replace(/\*\*:?$/,''))}</div>`;continue;}
const nm=raw.match(/^(\d+)\.\s+(.+)/);if(nm){if(!inList){html+='<div style="margin-top:5px">';inList=true;}html+=`<div class="msg-item"><span class="msg-item-num">${nm[1]}.</span><span>${iF(nm[2])}</span></div>`;continue;}
const bm=raw.match(/^[β€’\-\*]\s+(.+)/);if(bm){if(!inList){html+='<div style="margin-top:5px">';inList=true;}html+=`<div class="msg-item"><span class="msg-bullet">β€’</span><span>${iF(bm[1])}</span></div>`;continue;}
if(inList&&!raw.trim()){html+='</div>';inList=false;}
html+=raw.trim()?`<span>${iF(raw)}</span><br>`:'<br>';
}
if(inList)html+='</div>';return html;
}
function iF(t){return escH(t).replace(/\*\*(.+?)\*\*/g,'<strong>$1</strong>').replace(/_(.+?)_/g,'<em style="color:var(--muted2)">$1</em>');}
function buildChatTable(td){
const{title,columns,rows}=td,w=document.createElement('div');w.className='ctw';
let h='';if(title)h+=`<div class="ctt">${escH(title)}</div>`;
h+='<table class="ct"><thead><tr>';for(const c of columns)h+=`<th>${escH(c)}</th>`;h+='</tr></thead><tbody>';
for(const row of rows){h+='<tr>';for(const c of columns){const v=row[c]??'';h+=`<td title="${escH(String(v))}">${escH(String(v))}</td>`;}h+='</tr>';}
h+='</tbody></table>';w.innerHTML=h;return w;
}
function applyChatFilters(raw){
if(!currentData||!baseReviews.length)return;
try{
const f=typeof raw==='string'?JSON.parse(raw):raw;
let rv=[...baseReviews];
if(f.stars?.length)rv=rv.filter(r=>f.stars.includes(r.score));
if(f.app){const q=f.app.toLowerCase();rv=rv.filter(r=>(r.appTitle||'').toLowerCase().includes(q)||(r.appId||'').toLowerCase().includes(q));}
if(f.query){const q=f.query.toLowerCase();rv=rv.filter(r=>(r.content||'').toLowerCase().includes(q)||(r.userName||'').toLowerCase().includes(q));}
renderReviews(rv);
document.getElementById('tbCount').textContent=`${rv.length.toLocaleString()} of ${baseReviews.length.toLocaleString()}`;
}catch(e){console.error('Filter error',e);}
}
</script>
</body>
</html>