Spaces:
Sleeping
Sleeping
| <html lang="en"> | |
| <head> | |
| <meta charset="UTF-8"/> | |
| <meta name="viewport" content="width=device-width,initial-scale=1.0"/> | |
| <title>Batch Intelligence | PlayPulse</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.25);--green:#22c55e;--green-dim:rgba(34,197,94,0.12);--amber:#f59e0b;--amber-dim:rgba(245,158,11,0.12);--text:#f1f5f9;--muted:#64748b;--muted2:#94a3b8;} | |
| *{box-sizing:border-box;margin:0;padding:0;} | |
| ::-webkit-scrollbar{width:4px;height:4px;}::-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{height:52px;background:var(--surface);border-bottom:1px solid var(--border);display:flex;align-items:center;padding:0 18px;gap:14px;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:14px;} | |
| .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);} | |
| .main{flex:1;display:flex;overflow:hidden;} | |
| .sidebar{width:300px;min-width:220px;max-width:480px;background:var(--surface);border-right:1px solid var(--border);display:flex;flex-direction:column;flex-shrink:0;overflow:hidden;position:relative;transition:width .25s ease;} | |
| .sidebar.collapsed{width:36px;min-width:36px;} | |
| .sidebar-inner{flex:1;display:flex;flex-direction:column;overflow:hidden;min-width:0;} | |
| .sidebar.collapsed .sidebar-inner{display:none;} | |
| .resize-handle{position:absolute;right:-4px;top:0;bottom:0;width:8px;cursor:col-resize;z-index:20;} | |
| .resize-handle::after{content:'';position:absolute;left:3px;top:50%;transform:translateY(-50%);width:2px;height:40px;background:var(--border);border-radius:2px;transition:background .15s,height .15s;} | |
| .resize-handle:hover::after{background:var(--accent);height:60px;} | |
| .collapse-btn{position:absolute;right:-15px;top:50%;transform:translateY(-50%);width:26px;height:42px;background:var(--surface);border:1px solid var(--border);border-left:none;border-radius:0 8px 8px 0;display:flex;align-items:center;justify-content:center;cursor:pointer;z-index:25;transition:.2s;color:var(--muted);} | |
| .collapse-btn:hover{color:var(--accent);border-color:var(--accent);} | |
| .collapse-btn svg{width:12px;height:12px;fill:none;stroke:currentColor;stroke-width:2.5;transition:transform .25s;} | |
| .sidebar.collapsed .collapse-btn svg{transform:rotate(180deg);} | |
| .sidebar-tabs{display:flex;border-bottom:1px solid var(--border);flex-shrink:0;} | |
| .stab{flex:1;padding:10px 6px;text-align:center;font-size:11px;font-weight:700;color:var(--muted);cursor:pointer;border-bottom:2px solid transparent;transition:.15s;user-select:none;white-space:nowrap;} | |
| .stab.active{color:var(--accent);border-bottom-color:var(--accent);background:var(--accent-dim);} | |
| .stab:hover:not(.active){color:var(--text);} | |
| .spanel{flex:1;display:flex;flex-direction:column;overflow:hidden;padding:12px;} | |
| .spanel.hidden{display:none;} | |
| 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);} | |
| .find-row{display:flex;gap:7px;margin-bottom:8px;} | |
| .find-row input{flex:1;} | |
| .btn-find{background:var(--accent);border:none;color:white;padding:0 13px;border-radius:7px;cursor:pointer;font-weight:700;font-size:12px;white-space:nowrap;transition:.2s;} | |
| .btn-find:hover{opacity:.85;} | |
| .btn-find:disabled{opacity:.5;cursor:not-allowed;} | |
| .search-hint{font-size:10px;color:var(--muted);padding:4px 2px 6px;line-height:1.6;} | |
| .search-results-list{flex:1;overflow-y:auto;display:flex;flex-direction:column;gap:3px;padding-right:2px;min-height:0;} | |
| .sr-item{display:flex;align-items:center;gap:8px;padding:7px 8px;background:var(--bg);border-radius:7px;border:1.5px solid var(--border);cursor:grab;user-select:none;transition:border-color .15s,background .12s;} | |
| .sr-item:hover{border-color:rgba(59,130,246,.5);background:var(--accent-dim);} | |
| .sr-item.in-queue{border-color:var(--green);opacity:.6;} | |
| .sr-item.dragging-src{opacity:.25;} | |
| .sr-item img{width:28px;height:28px;border-radius:6px;object-fit:cover;flex-shrink:0;} | |
| .sr-info{flex:1;min-width:0;} | |
| .sr-title{font-size:11px;font-weight:700;white-space:nowrap;overflow:hidden;text-overflow:ellipsis;} | |
| .sr-dev{font-size:9px;color:var(--muted);white-space:nowrap;overflow:hidden;text-overflow:ellipsis;} | |
| .sr-score{font-size:9px;font-weight:700;color:var(--amber);background:var(--amber-dim);padding:1px 5px;border-radius:4px;flex-shrink:0;} | |
| .sr-add-btn{width:20px;height:20px;border-radius:5px;background:var(--accent-dim);border:1px solid rgba(59,130,246,.3);display:flex;align-items:center;justify-content:center;cursor:pointer;flex-shrink:0;color:var(--accent);transition:.15s;font-size:14px;font-weight:900;line-height:1;} | |
| .sr-add-btn:hover{background:var(--accent);color:white;} | |
| .sr-item.in-queue .sr-add-btn{background:var(--green-dim);border-color:rgba(34,197,94,.3);color:var(--green);} | |
| .sr-skeleton{display:flex;align-items:center;gap:8px;padding:7px 8px;border-radius:7px;border:1px solid var(--border);} | |
| .sk-icon{width:28px;height:28px;border-radius:6px;background:var(--surface2);animation:shimmer 1.2s infinite;} | |
| .sk-lines{flex:1;display:flex;flex-direction:column;gap:5px;} | |
| .sk-line{height:7px;border-radius:3px;background:var(--surface2);animation:shimmer 1.2s infinite;} | |
| .sk-line.short{width:55%;} | |
| @keyframes shimmer{0%,100%{opacity:.35}50%{opacity:.8}} | |
| .queue-section{display:flex;flex-direction:column;gap:6px;overflow:hidden;flex:1;min-height:0;} | |
| .queue-box{border:2px dashed var(--border);border-radius:9px;display:flex;flex-direction:column;overflow:hidden;transition:border-color .2s,background .2s;flex:1;min-height:80px;} | |
| .queue-box.drag-active{border-color:var(--accent);background:rgba(59,130,246,.04);} | |
| .queue-hdr{padding:7px 10px;background:var(--surface2);border-bottom:1px solid var(--border);display:flex;align-items:center;justify-content:space-between;flex-shrink:0;} | |
| .queue-htitle{font-size:9px;font-weight:800;text-transform:uppercase;letter-spacing:.7px;color:var(--muted2);} | |
| .queue-empty{flex:1;display:flex;flex-direction:column;align-items:center;justify-content:center;color:var(--muted);gap:5px;padding:14px;text-align:center;} | |
| .queue-empty p{font-size:11px;line-height:1.5;} | |
| .queue-list{flex:1;overflow-y:auto;display:flex;flex-direction:column;gap:3px;padding:5px;min-height:0;} | |
| .q-item{display:flex;align-items:center;gap:7px;padding:6px 7px;background:var(--bg);border-radius:6px;border:1.5px solid var(--border);cursor:grab;user-select:none;transition:border-color .12s,opacity .12s;} | |
| .q-item:active{cursor:grabbing;} | |
| .q-item:hover{border-color:rgba(59,130,246,.4);} | |
| .q-item.drag-over{border-color:var(--accent);background:var(--accent-dim);} | |
| .q-item.q-dragging{opacity:.3;} | |
| .q-handle{display:flex;flex-direction:column;gap:2px;color:var(--muted);flex-shrink:0;padding:1px;} | |
| .q-handle span{display:block;width:10px;height:1.5px;background:currentColor;border-radius:2px;} | |
| .q-item img{width:22px;height:22px;border-radius:5px;object-fit:cover;flex-shrink:0;} | |
| .q-info{flex:1;min-width:0;} | |
| .q-title{font-size:10px;font-weight:700;white-space:nowrap;overflow:hidden;text-overflow:ellipsis;} | |
| .q-score{font-size:9px;color:var(--amber);} | |
| .q-rm{width:16px;height:16px;border-radius:4px;background:transparent;border:none;color:var(--muted);cursor:pointer;display:flex;align-items:center;justify-content:center;font-size:12px;transition:.15s;flex-shrink:0;line-height:1;} | |
| .q-rm:hover{color:#ef4444;background:rgba(239,68,68,.1);} | |
| .quick-btn{font-size:10px;font-weight:700;color:var(--muted2);cursor:pointer;padding:2px 6px;border-radius:5px;border:1px solid var(--border);background:var(--surface2);transition:.15s;} | |
| .quick-btn:hover{color:white;border-color:var(--accent);} | |
| .mode-toggle{display:grid;grid-template-columns:1fr 1fr;background:var(--bg);padding:3px;border-radius:8px;border:1px solid var(--border);margin-bottom:6px;} | |
| .mode-btn{padding:6px;border-radius:5px;text-align:center;cursor:pointer;font-size:11px;font-weight:700;color:var(--muted);transition:.2s;} | |
| .mode-btn.active{background:var(--surface2);color:white;box-shadow:0 2px 4px rgba(0,0,0,.3);} | |
| .star-filter-grid{display:flex;flex-direction:column;gap:4px;} | |
| .star-row{display:flex;align-items:center;gap:8px;padding:6px 9px;border-radius:6px;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;padding:0;border:none;flex-shrink:0;} | |
| .star-label{display:flex;align-items:center;gap:4px;font-size:11px;font-weight:600;flex:1;} | |
| .stars-on{color:var(--amber);letter-spacing:-1px;} | |
| .stars-off{color:var(--border);letter-spacing:-1px;} | |
| .btn-main{background:var(--accent);color:white;border:none;padding:11px;border-radius:9px;font-weight:800;font-size:12px;cursor:pointer;display:flex;align-items:center;justify-content:center;gap:7px;transition:.2s;width:100%;border-bottom:3px solid rgba(0,0,0,.2);flex-shrink:0;margin-top:10px;} | |
| .btn-main:hover{transform:translateY(-1px);box-shadow:0 4px 16px var(--accent-glow);} | |
| .btn-main:disabled{opacity:.5;cursor:not-allowed;transform:none;box-shadow:none;} | |
| .sec-lbl{font-size:9px;font-weight:800;text-transform:uppercase;color:var(--muted);letter-spacing:.8px;margin-bottom:5px;display:flex;align-items:center;justify-content:space-between;} | |
| .sec-lbl span{color:var(--accent);font-size:9px;text-transform:none;letter-spacing:0;font-weight:700;} | |
| .igrp{display:flex;flex-direction:column;gap:5px;margin-bottom:8px;} | |
| .divider{height:1px;background:var(--border);margin:7px 0;flex-shrink:0;} | |
| #rubber-band{position:fixed;border:1.5px dashed var(--accent);background:rgba(59,130,246,0.06);border-radius:3px;pointer-events:none;display:none;z-index:9999;} | |
| .content{flex:1;background:var(--bg);position:relative;display:flex;flex-direction:column;overflow:hidden;min-width:0;} | |
| .toolbar{display:flex;align-items:center;gap:8px;padding:9px 16px;background:var(--surface);border-bottom:1px solid var(--border);flex-shrink:0;flex-wrap:wrap;} | |
| .sbox{display:flex;align-items:center;gap:6px;background:var(--bg);border:1px solid var(--border);border-radius:7px;padding:6px 10px;flex:1;max-width:260px;transition:border-color .15s;} | |
| .sbox:focus-within{border-color:var(--accent);} | |
| .sbox input{background:transparent;border:none;color:var(--text);font-size:12px;outline:none;width:100%;} | |
| .sbox svg{color:var(--muted);flex-shrink:0;width:12px;height:12px;fill:none;stroke:currentColor;stroke-width:2.5;} | |
| .chips-row{display:flex;gap:4px;flex-wrap:wrap;flex:1;min-width:0;} | |
| .a-chip{display:inline-flex;align-items:center;gap:4px;font-size:10px;font-weight:700;padding:3px 7px;border-radius:14px;background:var(--accent-dim);color:var(--accent);border:1px solid rgba(59,130,246,.3);cursor:pointer;transition:.15s;white-space:nowrap;} | |
| .a-chip:hover{background:rgba(239,68,68,.1);color:#ef4444;border-color:rgba(239,68,68,.3);} | |
| .tb-right{display:flex;align-items:center;gap:7px;flex-shrink:0;} | |
| .res-count{font-size:11px;color:var(--muted);white-space:nowrap;} | |
| .vs{display:flex;gap:2px;background:var(--bg);padding:3px;border-radius:7px;border:1px solid var(--border);} | |
| .vb{width:27px;height:25px;display:flex;align-items:center;justify-content:center;border-radius:5px;cursor:pointer;color:var(--muted);transition:.15s;border:none;background:transparent;} | |
| .vb.active{background:var(--surface2);color:white;} | |
| .vb svg{width:13px;height:13px;fill:none;stroke:currentColor;stroke-width:2;} | |
| .btn-exp{background:var(--surface2);border:1px solid var(--border);color:var(--muted2);padding:6px 11px;border-radius:7px;cursor:pointer;font-size:11px;font-weight:700;transition:.15s;display:flex;align-items:center;gap:5px;} | |
| .btn-exp:hover{border-color:var(--accent);color:var(--text);} | |
| .btn-exp svg{width:12px;height:12px;fill:none;stroke:currentColor;stroke-width:2.5;} | |
| .scroll-view{flex:1;overflow-y:auto;padding:16px 18px;display:flex;flex-direction:column;gap:14px;} | |
| .batch-summary{background:var(--surface);border:1px solid var(--border);border-radius:12px;padding:14px 16px;display:flex;flex-direction:column;gap:10px;} | |
| .apps-grid{display:grid;grid-template-columns:repeat(auto-fill,minmax(175px,1fr));gap:8px;} | |
| .app-mini-card{background:var(--surface2);border:1px solid var(--border);border-radius:9px;padding:9px 11px;display:flex;align-items:center;gap:9px;} | |
| .app-mini-card img{width:34px;height:34px;border-radius:7px;} | |
| .app-mini-info{flex:1;min-width:0;} | |
| .app-mini-title{font-size:11px;font-weight:700;white-space:nowrap;overflow:hidden;text-overflow:ellipsis;} | |
| .app-mini-score{font-size:10px;color:var(--amber);margin-top:1px;} | |
| .app-mini-ct{font-size:9px;color:var(--muted);margin-top:1px;} | |
| .table-container{background:var(--surface);border:1px solid var(--border);border-radius:12px;overflow:hidden;} | |
| table{width:100%;border-collapse:collapse;font-size:12px;} | |
| th{text-align:left;background:var(--surface2);padding:9px 13px;color:var(--muted2);font-weight:700;font-size:10px;text-transform:uppercase;border-bottom:1px solid var(--border);letter-spacing:.4px;} | |
| td{padding:11px 13px;border-bottom:1px solid var(--border);vertical-align:top;} | |
| tr:last-child td{border-bottom:none;} | |
| tr:hover td{background:rgba(255,255,255,.012);} | |
| .app-tag{display:inline-flex;align-items:center;gap:5px;background:var(--accent-dim);color:var(--accent);padding:2px 7px;border-radius:5px;font-weight:700;font-size:10px;margin-bottom:4px;border:1px solid rgba(59,130,246,.2);} | |
| .score-stars{color:var(--amber);white-space:nowrap;letter-spacing:1px;} | |
| .rev-content{color:#cbd5e1;line-height:1.55;max-width:460px;word-wrap:break-word;font-size:12px;} | |
| .dev-reply-cell{margin-top:7px;padding:7px 9px;background:rgba(34,197,94,.05);border-left:2px solid var(--green);border-radius:0 5px 5px 0;font-size:11px;color:var(--muted2);} | |
| .dev-reply-lbl{font-weight:700;color:var(--green);font-size:9px;text-transform:uppercase;margin-bottom:3px;display:block;} | |
| .hpill{display:inline-flex;align-items:center;gap:4px;background:var(--surface2);padding:3px 7px;border-radius:9px;font-size:11px;color:var(--muted2);border:1px solid var(--border);} | |
| .hpill svg{width:10px;height:10px;fill:none;stroke:var(--accent);stroke-width:2.5;} | |
| .th-wrap{position:relative;} | |
| .th-inner{display:flex;align-items:center;gap:4px;cursor:pointer;user-select:none;white-space:nowrap;} | |
| .sa{font-size:10px;color:var(--accent);} | |
| .fi{width:11px;height:11px;fill:none;stroke:currentColor;stroke-width:2.5;opacity:.3;transition:opacity .15s;flex-shrink:0;} | |
| .fi.on{opacity:1;stroke:var(--accent);} | |
| .th-inner:hover .fi{opacity:.65;} | |
| .filter-dd{position:absolute;top:calc(100% + 3px);left:0;min-width:190px;max-width:250px;background:var(--surface2);border:1px solid var(--border);border-radius:9px;box-shadow:0 12px 36px rgba(0,0,0,.65);z-index:600;overflow:hidden;} | |
| .fdd-search{padding:6px 7px;border-bottom:1px solid var(--border);} | |
| .fdd-search input{padding:5px 9px;font-size:11px;border-radius:6px;} | |
| .fdd-list{max-height:200px;overflow-y:auto;padding:3px;} | |
| .fdd-opt{display:flex;align-items:center;gap:7px;padding:5px 7px;border-radius:5px;cursor:pointer;font-size:11px;transition:.1s;} | |
| .fdd-opt:hover{background:var(--accent-dim);} | |
| .fdd-opt input[type=checkbox]{width:12px;height:12px;accent-color:var(--accent);cursor:pointer;flex-shrink:0;} | |
| .fdd-opt-lbl{flex:1;overflow:hidden;text-overflow:ellipsis;white-space:nowrap;} | |
| .fdd-opt-ct{font-size:9px;color:var(--muted);flex-shrink:0;} | |
| .fdd-acts{display:flex;gap:5px;padding:7px;border-top:1px solid var(--border);} | |
| .fdd-btn{flex:1;padding:5px;border-radius:6px;border:none;cursor:pointer;font-size:11px;font-weight:700;transition:.15s;} | |
| .fdd-btn.clr{background:var(--surface);color:var(--muted2);} | |
| .fdd-btn.clr:hover{color:white;} | |
| .fdd-btn.apl{background:var(--accent);color:white;} | |
| .cards-view{display:grid;grid-template-columns:repeat(auto-fill,minmax(320px,1fr));gap:12px;} | |
| .rcb{background:var(--surface);border:1px solid var(--border);border-radius:12px;overflow:hidden;transition:border-color .15s;} | |
| .rcb:hover{border-color:#2d3a4f;} | |
| .rcb-main{padding:13px 15px;} | |
| .rcb-header{display:flex;align-items:center;gap:9px;margin-bottom:8px;} | |
| .rcb-avatar{width:32px;height:32px;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;} | |
| .rcb-avatar img{width:32px;height:32px;border-radius:50%;object-fit:cover;} | |
| .rcb-meta{flex:1;min-width:0;} | |
| .rcb-user{font-weight:700;font-size:12px;white-space:nowrap;overflow:hidden;text-overflow:ellipsis;} | |
| .rcb-date{font-size:10px;color:var(--muted);margin-top:1px;} | |
| .rcb-text{font-size:12px;color:#cbd5e1;line-height:1.6;} | |
| .rcb-footer{padding:8px 15px;background:var(--bg);border-top:1px solid var(--border);display:flex;gap:5px;flex-wrap:wrap;align-items:center;} | |
| .mpill{display:inline-flex;align-items:center;gap:3px;font-size:10px;font-weight:600;padding:2px 7px;border-radius:11px;border:1px solid var(--border);color:var(--muted2);} | |
| .mpill.app{color:var(--accent);border-color:rgba(59,130,246,.25);background:var(--accent-dim);} | |
| .mpill.replied{color:var(--green);border-color:rgba(34,197,94,.25);background:var(--green-dim);} | |
| .rcb-dev{margin:0 15px 11px;background:var(--surface2);border:1px solid var(--border);border-left:2.5px solid var(--green);border-radius:6px;padding:8px 10px;} | |
| .rcb-dev-hdr{font-size:9px;font-weight:700;color:var(--green);margin-bottom:4px;} | |
| .rcb-dev-text{font-size:11px;color:var(--muted2);line-height:1.5;} | |
| .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;} | |
| @keyframes spin{to{transform:rotate(360deg);}} | |
| .hidden{display:none;} | |
| #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:21px;height:21px;color:white;fill:none;stroke:currentColor;stroke-width:2.5;} | |
| #chat-window{position:fixed;bottom:82px;right:22px;width:390px;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-header{padding:12px 16px;background:var(--accent);color:white;display:flex;align-items:center;gap:11px;flex-shrink:0;} | |
| .chat-header-info{flex:1;}.chat-header-title{font-weight:800;font-size:13px;}.chat-header-status{font-size:10px;opacity:.8;display:flex;align-items:center;gap:4px;}.status-dot{width:5px;height:5px;background:#22c55e;border-radius:50%;} | |
| .chat-clear-btn{background:rgba(255,255,255,.15);border:none;color:white;font-size:11px;padding:3px 8px;border-radius:6px;cursor:pointer;} | |
| .chat-clear-btn:hover{background:rgba(255,255,255,.25);} | |
| .chat-messages{flex:1;overflow-y:auto;padding:12px;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;} | |
| .chat-table-wrap{max-width:100%;overflow-x:auto;border:1px solid var(--border);border-radius:9px;background:var(--surface2);margin-top:4px;} | |
| .chat-table-title{padding:6px 10px;font-size:10px;font-weight:700;color:var(--accent);border-bottom:1px solid var(--border);text-transform:uppercase;} | |
| .chat-table{width:100%;border-collapse:collapse;font-size:11px;} | |
| .chat-table 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);white-space:nowrap;} | |
| .chat-table td{padding:5px 9px;border-bottom:1px solid var(--border);color:var(--text);max-width:150px;overflow:hidden;text-overflow:ellipsis;white-space:nowrap;} | |
| .chat-table tr:last-child td{border-bottom:none;}.chat-table tr:hover td{background:var(--surface);} | |
| .typing-indicator{display:flex;gap:4px;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-input-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;transition:.2s;flex-shrink:0;} | |
| .btn-send:hover{transform:scale(1.05);}.btn-send svg{width:15px;height:15px;fill:none;stroke:currentColor;stroke-width:2.5;} | |
| .chat-suggestions{display:flex;flex-wrap:wrap;gap:4px;padding:0 13px 7px;} | |
| .sug-chip{font-size:10px;padding:4px 8px;border-radius:16px;background:var(--surface2);border:1px solid var(--border);color:var(--muted2);cursor:pointer;transition:.2s;} | |
| .sug-chip:hover{border-color:var(--accent);color:var(--accent);} | |
| </style> | |
| </head> | |
| <body> | |
| <div id="rubber-band"></div> | |
| <div class="header"> | |
| <a href="/" class="logo"><svg width="19" height="19" 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">Single Explorer</a> | |
| <a href="/batch" class="nav-link active">Batch Intelligence</a> | |
| </nav> | |
| <div style="flex:1"></div> | |
| </div> | |
| <div class="main"> | |
| <aside class="sidebar" id="sidebarEl"> | |
| <div class="sidebar-inner"> | |
| <div class="sidebar-tabs"> | |
| <div class="stab active" id="tabFind" onclick="switchTab('find')">🔍 Find Apps</div> | |
| <div class="stab" id="tabQueue" onclick="switchTab('queue')">⚙️ Queue & Run</div> | |
| </div> | |
| <!-- FIND PANEL --> | |
| <div class="spanel" id="panelFind" style="gap:0"> | |
| <div style="font-size:10px;color:var(--muted2);font-weight:600;padding:0 0 7px">Search & drag apps to the Queue tab →</div> | |
| <div class="find-row"> | |
| <input type="text" id="query" placeholder="Search games, apps…" value="Multiplayer Games" onkeydown="if(event.key==='Enter')findApps()"> | |
| <button onclick="findApps()" id="btnFind" class="btn-find">Find</button> | |
| </div> | |
| <div style="display:flex;gap:6px;align-items:center;margin-bottom:8px;"> | |
| <span style="font-size:10px;color:var(--muted);white-space:nowrap">Max results</span> | |
| <input type="number" id="app_count" value="10" min="1" max="50" style="width:60px;padding:5px 8px;font-size:11px;"> | |
| </div> | |
| <div class="search-hint" id="searchHint">Search for apps above. Click <strong style="color:var(--accent)">+</strong> or drag into the Queue tab to build your batch.</div> | |
| <div class="search-results-list" id="searchResultsList"></div> | |
| </div> | |
| <!-- QUEUE + SETTINGS PANEL --> | |
| <div class="spanel hidden" id="panelQueue" style="gap:0;overflow-y:auto;"> | |
| <div class="sec-lbl">Batch Queue <span id="queueCountLbl">0 apps</span></div> | |
| <div class="queue-box" id="queueBox" ondragover="qBoxOver(event)" ondrop="qBoxDrop(event)" ondragleave="qBoxLeave(event)"> | |
| <div class="queue-hdr"> | |
| <span class="queue-htitle">Selected Apps — drag to reorder</span> | |
| <button class="quick-btn" onclick="clearQueue()">Clear all</button> | |
| </div> | |
| <div class="queue-empty" id="queueEmpty"> | |
| <svg width="26" height="26" viewBox="0 0 24 24" fill="none" stroke="currentColor" stroke-width="1.5"><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> | |
| <p>Drag apps from 🔍 Find Apps<br>or click <strong style="color:var(--accent)">+</strong> to add</p> | |
| </div> | |
| <div class="queue-list hidden" id="queueList"></div> | |
| </div> | |
| <div class="divider"></div> | |
| <div class="sec-lbl">Scrape Settings</div> | |
| <div class="igrp"> | |
| <div style="font-size:10px;color:var(--muted2);font-weight:600;">Reviews Per App</div> | |
| <div class="mode-toggle"> | |
| <div class="mode-btn active" id="btn-fixed" onclick="setMode('fixed')">Custom</div> | |
| <div class="mode-btn" id="btn-all" onclick="setMode('all')">Fetch All</div> | |
| </div> | |
| <input type="number" id="reviews_per_app" value="50" min="10" step="10"> | |
| </div> | |
| <div class="igrp"> | |
| <div style="font-size:10px;color:var(--muted2);font-weight:600;margin-bottom:4px">Sort Method</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="igrp"> | |
| <div style="display:flex;align-items:center;justify-content:space-between;margin-bottom:5px;"> | |
| <span style="font-size:10px;color:var(--muted2);font-weight:600;">Star Filter</span> | |
| <div style="display:flex;gap:3px"><button class="quick-btn" onclick="selectAllStars(true)">All</button><button class="quick-btn" 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> 5</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> 4</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> 3</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> 2</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> 1</span></label> | |
| </div> | |
| </div> | |
| <button class="btn-main" id="go" onclick="runBatch()" disabled> | |
| <svg width="14" height="14" viewBox="0 0 24 24" fill="none" stroke="currentColor" stroke-width="3"><rect x="3" y="3" width="7" height="7"/><rect x="14" y="3" width="7" height="7"/><rect x="14" y="14" width="7" height="7"/><rect x="3" y="14" width="7" height="7"/></svg> | |
| RUN BATCH ANALYSIS | |
| </button> | |
| </div> | |
| </div> | |
| <div class="resize-handle" id="resizeHandle"></div> | |
| <div class="collapse-btn" id="collapseBtn" onclick="toggleSidebar()"> | |
| <svg viewBox="0 0 24 24"><polyline points="15 18 9 12 15 6"/></svg> | |
| </div> | |
| </aside> | |
| <div class="content"> | |
| <div class="toolbar" id="toolbarEl" style="display:none"> | |
| <div class="sbox"><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="globalSearch" placeholder="Search reviews…" oninput="applyAllFilters()"></div> | |
| <div class="chips-row" id="activeFiltersRow"></div> | |
| <div class="tb-right"> | |
| <span class="res-count" id="resultStats"></span> | |
| <div class="vs"> | |
| <button class="vb active" id="vbtnTable" onclick="switchViewMode('table')" title="Table"><svg viewBox="0 0 24 24"><rect x="3" y="3" width="18" height="18" rx="2"/><line x1="3" y1="9" x2="21" y2="9"/><line x1="3" y1="15" x2="21" y2="15"/><line x1="9" y1="9" x2="9" y2="21"/></svg></button> | |
| <button class="vb" id="vbtnCards" onclick="switchViewMode('cards')" title="Cards"><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> | |
| <button class="btn-exp" onclick="downloadCSV()"><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>Export CSV</button> | |
| </div> | |
| </div> | |
| <div id="dataView" class="scroll-view"> | |
| <div id="welcome" style="display:flex;flex-direction:column;align-items:center;justify-content:center;flex:1;color:var(--muted);gap:14px;text-align:center;padding:40px;"> | |
| <svg width="52" height="52" viewBox="0 0 24 24" fill="none" stroke="currentColor" stroke-width="1.2"><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> | |
| <div><p style="font-size:15px;font-weight:700;color:var(--muted2);margin-bottom:6px">No batch data yet</p> | |
| <p style="font-size:12px;line-height:1.8">1. Use <strong style="color:var(--text)">Find Apps</strong> to search by genre, name…<br>2. Click <strong style="color:var(--accent)">+</strong> or drag into the queue<br>3. Switch to <strong style="color:var(--text)">Queue & Run</strong> and run</p></div> | |
| </div> | |
| <div id="results" class="hidden" style="display:flex;flex-direction:column;gap:14px;"></div> | |
| </div> | |
| <div id="loader" class="loader-overlay hidden"> | |
| <div class="spinner"></div> | |
| <p style="color:var(--muted);font-size:13px" id="loaderMsg">Running batch analysis…</p> | |
| </div> | |
| </div> | |
| </div> | |
| <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-header"> | |
| <svg width="17" height="17" 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-header-info"><div class="chat-header-title">PlayPulse Intelligence</div><div class="chat-header-status"><span class="status-dot"></span> Agent Online</div></div> | |
| <div style="display:flex;gap:7px;align-items:center"><button class="chat-clear-btn" onclick="clearChat()">Clear</button><div style="cursor:pointer;opacity:.7" onclick="toggleChat()"><svg width="15" height="15" 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-messages" id="chat-messages"><div class="msg-row bot"><div class="message bot">👋 Hi! Search apps, build your queue, run batch — then ask me to compare, find issues, or show tables!</div></div></div> | |
| <div class="chat-suggestions"> | |
| <div class="sug-chip" onclick="fillChat('Compare all apps by rating')">Compare apps</div> | |
| <div class="sug-chip" onclick="fillChat('Which app has the most complaints?')">Most complaints</div> | |
| <div class="sug-chip" onclick="fillChat('Show 1 star reviews in table')">1★ table</div> | |
| <div class="sug-chip" onclick="fillChat('What are common issues?')">Common issues</div> | |
| </div> | |
| <div class="chat-input-area"> | |
| <input type="text" id="chat-input" placeholder="Ask about batch analysis…" onkeydown="if(event.key==='Enter') sendChatMessage()"> | |
| <button class="btn-send" onclick="sendChatMessage()"><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 currentData=null,currentMode='fixed',viewMode='table'; | |
| let searchResults=[],queue=[]; | |
| let colFilters={},sortCol=null,sortDir=1,openDd=null; | |
| let draggedApp=null,qDragSrc=null; | |
| // SIDEBAR TABS | |
| function switchTab(t){ | |
| document.getElementById('tabFind').classList.toggle('active',t==='find'); | |
| document.getElementById('tabQueue').classList.toggle('active',t==='queue'); | |
| document.getElementById('panelFind').classList.toggle('hidden',t!=='find'); | |
| document.getElementById('panelQueue').classList.toggle('hidden',t!=='queue'); | |
| } | |
| // SIDEBAR COLLAPSE + RESIZE | |
| let sbCollapsed=false; | |
| function toggleSidebar(){ | |
| sbCollapsed=!sbCollapsed; | |
| const sb=document.getElementById('sidebarEl'); | |
| sb.classList.toggle('collapsed',sbCollapsed); | |
| } | |
| const rh=document.getElementById('resizeHandle'),sb=document.getElementById('sidebarEl'); | |
| let resizing=false,rsX=0,rsW=0; | |
| rh.addEventListener('mousedown',e=>{resizing=true;rsX=e.clientX;rsW=sb.offsetWidth;document.body.style.cursor='col-resize';document.body.style.userSelect='none';e.preventDefault();}); | |
| document.addEventListener('mousemove',e=>{if(!resizing)return;const nw=Math.max(220,Math.min(500,rsW+(e.clientX-rsX)));sb.style.width=nw+'px';}); | |
| document.addEventListener('mouseup',()=>{if(resizing){resizing=false;document.body.style.cursor='';document.body.style.userSelect='';}}); | |
| // FIND APPS | |
| async function findApps(){ | |
| const q=document.getElementById('query').value.trim();if(!q)return; | |
| const btn=document.getElementById('btnFind');btn.disabled=true;btn.textContent='...'; | |
| const hint=document.getElementById('searchHint'); | |
| hint.innerHTML='Searching...';hint.style.display='block'; | |
| const list=document.getElementById('searchResultsList'); | |
| list.innerHTML=Array(6).fill('').map(()=>'<div class="sr-skeleton"><div class="sk-icon"></div><div class="sk-lines"><div class="sk-line"></div><div class="sk-line short"></div></div></div>').join(''); | |
| try{ | |
| const res=await fetch('/find-apps',{method:'POST',headers:{'Content-Type':'application/json'},body:JSON.stringify({query:q,app_count:document.getElementById('app_count').value})}); | |
| const data=await res.json();if(!res.ok)throw new Error(data.error||'Discovery failed'); | |
| searchResults=data.results;hint.style.display='none';renderSearchResults(); | |
| }catch(e){list.innerHTML='';hint.textContent='Error: '+e.message;} | |
| finally{btn.disabled=false;btn.textContent='Find';} | |
| } | |
| function renderSearchResults(){ | |
| const list=document.getElementById('searchResultsList'); | |
| if(!searchResults.length){list.innerHTML='<div style="text-align:center;padding:20px;color:var(--muted);font-size:11px">No results found</div>';return;} | |
| const qIds=new Set(queue.map(a=>a.appId)); | |
| list.innerHTML=searchResults.map((a,i)=>{ | |
| const inQ=qIds.has(a.appId); | |
| return `<div class="sr-item${inQ?' in-queue':''}" data-idx="${i}" draggable="true" ondragstart="srDragStart(event,${i})" ondragend="srDragEnd(event)"> | |
| <img src="${escH(a.icon)}" alt="" onerror="this.src='data:image/svg+xml,%3Csvg xmlns=%22http://www.w3.org/2000/svg%22 viewBox=%220 0 24 24%22%3E%3Crect fill=%22%231c2333%22 width=%2224%22 height=%2224%22/%3E%3C/svg%3E'"> | |
| <div class="sr-info"><div class="sr-title">${escH(a.title)}</div><div class="sr-dev">${escH(a.developer||'')}</div></div> | |
| ${a.score>0?`<div class="sr-score">${a.score}☆</div>`:''} | |
| <div class="sr-add-btn" onclick="toggleInQueue(${i})" title="${inQ?'Remove from queue':'Add to queue'}">${inQ?'✓':'+'}</div> | |
| </div>`; | |
| }).join(''); | |
| } | |
| // DRAG FROM SEARCH LIST | |
| function srDragStart(e,idx){draggedApp=searchResults[idx];e.dataTransfer.effectAllowed='copy';requestAnimationFrame(()=>e.target.classList.add('dragging-src'));} | |
| function srDragEnd(e){draggedApp=null;document.querySelectorAll('.sr-item').forEach(el=>el.classList.remove('dragging-src'));document.getElementById('queueBox').classList.remove('drag-active');} | |
| // QUEUE DROP ZONE | |
| function qBoxOver(e){e.preventDefault();e.dataTransfer.dropEffect='copy';document.getElementById('queueBox').classList.add('drag-active');} | |
| function qBoxLeave(e){if(!e.currentTarget.contains(e.relatedTarget))document.getElementById('queueBox').classList.remove('drag-active');} | |
| function qBoxDrop(e){ | |
| e.preventDefault();document.getElementById('queueBox').classList.remove('drag-active'); | |
| if(draggedApp){addToQueue(draggedApp);draggedApp=null;} | |
| } | |
| function addToQueue(app){ | |
| if(queue.find(a=>a.appId===app.appId))return; | |
| queue.push({appId:app.appId,title:app.title,icon:app.icon,score:app.score,developer:app.developer}); | |
| renderQueue();renderSearchResults();updateRunBtn(); | |
| } | |
| function removeFromQueue(appId){queue=queue.filter(a=>a.appId!==appId);renderQueue();renderSearchResults();updateRunBtn();} | |
| function clearQueue(){queue=[];renderQueue();renderSearchResults();updateRunBtn();} | |
| function toggleInQueue(idx){ | |
| const app=searchResults[idx]; | |
| if(queue.find(a=>a.appId===app.appId))removeFromQueue(app.appId);else addToQueue(app); | |
| } | |
| // QUEUE REORDER | |
| function qiDragStart(e,i){qDragSrc=i;e.dataTransfer.effectAllowed='move';requestAnimationFrame(()=>document.querySelectorAll('.q-item')[i]?.classList.add('q-dragging'));} | |
| function qiDragOver(e,i){e.preventDefault();document.querySelectorAll('.q-item').forEach((el,j)=>el.classList.toggle('drag-over',j===i&&j!==qDragSrc));} | |
| function qiDrop(e,i){e.preventDefault();if(qDragSrc===null||qDragSrc===i)return;const m=queue.splice(qDragSrc,1)[0];queue.splice(i,0,m);renderQueue();} | |
| function qiDragEnd(){qDragSrc=null;document.querySelectorAll('.q-item').forEach(el=>el.classList.remove('q-dragging','drag-over'));} | |
| function renderQueue(){ | |
| const empty=document.getElementById('queueEmpty'),list=document.getElementById('queueList'); | |
| const ct=document.getElementById('queueCountLbl'); | |
| ct.textContent=queue.length?`${queue.length} app${queue.length>1?'s':''}`:'0 apps'; | |
| if(!queue.length){empty.classList.remove('hidden');list.classList.add('hidden');return;} | |
| empty.classList.add('hidden');list.classList.remove('hidden'); | |
| list.innerHTML=queue.map((a,i)=>`<div class="q-item" draggable="true" ondragstart="qiDragStart(event,${i})" ondragover="qiDragOver(event,${i})" ondrop="qiDrop(event,${i})" ondragend="qiDragEnd()"> | |
| <div class="q-handle"><span></span><span></span><span></span></div> | |
| <img src="${escH(a.icon)}" alt="" onerror="this.src='data:image/svg+xml,%3Csvg xmlns=%22http://www.w3.org/2000/svg%22 viewBox=%220 0 24 24%22%3E%3Crect fill=%22%231c2333%22 width=%2224%22 height=%2224%22/%3E%3C/svg%3E'"> | |
| <div class="q-info"><div class="q-title">${escH(a.title)}</div>${a.score>0?`<div class="q-score">${a.score}☆</div>`:''}</div> | |
| <button class="q-rm" onclick="removeFromQueue('${a.appId}')" title="Remove">×</button> | |
| </div>`).join(''); | |
| } | |
| function updateRunBtn(){document.getElementById('go').disabled=queue.length===0;} | |
| // SCRAPE SETTINGS | |
| function setMode(m){currentMode=m;document.querySelectorAll('.mode-btn').forEach(b=>b.classList.remove('active'));document.getElementById('btn-'+m).classList.add('active');document.getElementById('reviews_per_app').classList.toggle('hidden',m==='all');} | |
| function selectAllStars(c){document.querySelectorAll('.star-cb').forEach(cb=>cb.checked=c);} | |
| // RUN BATCH | |
| async function runBatch(){ | |
| if(!queue.length)return alert('Add at least one app to the queue'); | |
| const stars=[...document.querySelectorAll('.star-cb:checked')].map(cb=>parseInt(cb.value)); | |
| if(!stars.length)return alert('Select at least one star rating'); | |
| document.getElementById('welcome').classList.add('hidden'); | |
| document.getElementById('results').classList.add('hidden'); | |
| document.getElementById('loader').classList.remove('hidden'); | |
| document.getElementById('go').disabled=true; | |
| colFilters={};sortCol=null; | |
| const msgs=['Connecting...','Fetching app data...','Scraping reviews...','Compiling results...']; | |
| let mi=0;document.getElementById('loaderMsg').textContent=msgs[0]; | |
| const msgInt=setInterval(()=>{mi=(mi+1)%msgs.length;document.getElementById('loaderMsg').textContent=msgs[mi];},2000); | |
| try{ | |
| const res=await fetch('/scrape-batch',{method:'POST',headers:{'Content-Type':'application/json'},body:JSON.stringify({app_ids:queue.map(a=>a.appId),review_count_type:currentMode,reviews_per_app:document.getElementById('reviews_per_app').value,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||'Batch failed'); | |
| currentData=data;document.getElementById('toolbarEl').style.display='flex';render(data); | |
| }catch(e){document.getElementById('welcome').classList.remove('hidden');alert(e.message);} | |
| finally{clearInterval(msgInt);document.getElementById('loader').classList.add('hidden');document.getElementById('go').disabled=queue.length===0;} | |
| } | |
| // VIEW MODE | |
| function switchViewMode(m){viewMode=m;document.getElementById('vbtnTable').classList.toggle('active',m==='table');document.getElementById('vbtnCards').classList.toggle('active',m==='cards');if(currentData)applyAllFilters();} | |
| // FILTER ENGINE | |
| function filtered(){ | |
| if(!currentData)return[]; | |
| let rv=[...currentData.reviews]; | |
| const q=(document.getElementById('globalSearch')?.value||'').toLowerCase(); | |
| if(q)rv=rv.filter(r=>(r.content||'').toLowerCase().includes(q)||(r.userName||'').toLowerCase().includes(q)); | |
| if(colFilters.app?.size)rv=rv.filter(r=>{const a=currentData.apps.find(x=>x.appId===r.appId);return colFilters.app.has(a?.title||r.appTitle||'');}); | |
| if(colFilters.score?.size)rv=rv.filter(r=>colFilters.score.has(String(r.score))); | |
| if(colFilters.replied?.size)rv=rv.filter(r=>colFilters.replied.has(r.replyContent?.trim()?'Yes':'No')); | |
| if(colFilters.date?.size)rv=rv.filter(r=>colFilters.date.has(r.at?String(new Date(r.at).getFullYear()):'Unknown')); | |
| if(sortCol)rv.sort((a,b)=>{let va,vb; | |
| if(sortCol==='score'){va=a.score||0;vb=b.score||0;} | |
| else if(sortCol==='date'){va=a.at||'';vb=b.at||'';} | |
| else if(sortCol==='helpful'){va=a.thumbsUpCount||0;vb=b.thumbsUpCount||0;} | |
| else if(sortCol==='app'){va=(currentData.apps.find(x=>x.appId===a.appId)||{}).title||'';vb=(currentData.apps.find(x=>x.appId===b.appId)||{}).title||'';} | |
| return va<vb?-sortDir:va>vb?sortDir:0; | |
| }); | |
| return rv; | |
| } | |
| function applyAllFilters(){if(!currentData)return;renderResults(currentData,filtered());renderChips();} | |
| function renderChips(){ | |
| const row=document.getElementById('activeFiltersRow'); | |
| row.innerHTML=Object.entries(colFilters).filter(([,v])=>v?.size).map(([col,vals])=>{ | |
| const vStr=[...vals].slice(0,2).join(', ')+(vals.size>2?` +${vals.size-2}`:''); | |
| return `<div class="a-chip" onclick="clearF('${col}')" title="Remove">${col.charAt(0).toUpperCase()+col.slice(1)}: ${escH(vStr)} ✕</div>`; | |
| }).join(''); | |
| } | |
| function clearF(col){delete colFilters[col];applyAllFilters();} | |
| // COLUMN FILTER DROPDOWN | |
| function openColFilter(colKey,triggerEl){ | |
| if(openDd){openDd.remove();openDd=null;} | |
| if(!currentData)return; | |
| const rv=currentData.reviews;const counts={}; | |
| rv.forEach(r=>{let v; | |
| if(colKey==='app'){const a=currentData.apps.find(x=>x.appId===r.appId);v=a?.title||r.appTitle||'Unknown';} | |
| else if(colKey==='score')v=String(r.score); | |
| else if(colKey==='replied')v=r.replyContent?.trim()?'Yes':'No'; | |
| else if(colKey==='date')v=r.at?String(new Date(r.at).getFullYear()):'Unknown'; | |
| if(v!==undefined)counts[v]=(counts[v]||0)+1; | |
| }); | |
| const allVals=Object.keys(counts).sort(); | |
| const tempSel=new Set(colFilters[colKey]||allVals); | |
| const dd=document.createElement('div');dd.className='filter-dd'; | |
| triggerEl.closest('.th-wrap').appendChild(dd);openDd=dd; | |
| function buildDd(q){ | |
| const show=allVals.filter(v=>v.toLowerCase().includes(q)); | |
| dd.innerHTML=`<div class="fdd-search"><input type="text" placeholder="Search..." id="fddQ_${colKey}" value="${escH(q)}" oninput="rebuildFdd('${colKey}',this.value)"></div> | |
| <div class="fdd-list"> | |
| <div class="fdd-opt" onclick="fddToggleAll('${colKey}','${q}')"><input type="checkbox" ${show.every(v=>tempSel.has(v))?'checked':''}><span class="fdd-opt-lbl" style="font-weight:700;color:var(--text)">(Select All)</span></div> | |
| ${show.map(v=>`<div class="fdd-opt" onclick="fddToggleVal('${colKey}','${v.replace(/'/g,"\\'")}','${q}')"><input type="checkbox" ${tempSel.has(v)?'checked':''}><span class="fdd-opt-lbl">${escH(v)}</span><span class="fdd-opt-ct">${counts[v]}</span></div>`).join('')} | |
| </div> | |
| <div class="fdd-acts"><button class="fdd-btn clr" onclick="fddClear('${colKey}')">Clear</button><button class="fdd-btn apl" onclick="fddApply('${colKey}')">Apply</button></div>`; | |
| } | |
| window['_ts_'+colKey]=tempSel;window['_av_'+colKey]=allVals; | |
| window.rebuildFdd=(k,q)=>buildDd(q); | |
| window.fddToggleVal=(k,v,q)=>{window['_ts_'+k].has(v)?window['_ts_'+k].delete(v):window['_ts_'+k].add(v);buildDd(q);}; | |
| window.fddToggleAll=(k,q)=>{const s=window['_ts_'+k];const sv=window['_av_'+k].filter(v=>v.toLowerCase().includes(q));sv.every(v=>s.has(v))?sv.forEach(v=>s.delete(v)):sv.forEach(v=>s.add(v));buildDd(q);}; | |
| window.fddClear=(k)=>{delete colFilters[k];dd.remove();openDd=null;applyAllFilters();}; | |
| window.fddApply=(k)=>{const s=window['_ts_'+k],av=window['_av_'+k];if(s.size===av.length)delete colFilters[k];else colFilters[k]=new Set(s);dd.remove();openDd=null;applyAllFilters();}; | |
| buildDd(''); | |
| } | |
| document.addEventListener('click',e=>{if(openDd&&!openDd.contains(e.target)&&!e.target.closest('.th-inner')){openDd.remove();openDd=null;}}); | |
| function thClick(key,e){ | |
| const svgEl=e.target.tagName==='svg'||e.target.tagName==='polyline'||e.target.tagName==='path'||e.target.tagName==='polygon'||!!e.target.closest('svg'); | |
| if(svgEl)openColFilter(key,e.currentTarget); | |
| else{if(sortCol===key)sortDir=-sortDir;else{sortCol=key;sortDir=1;}applyAllFilters();} | |
| } | |
| // RENDER | |
| function render(data){document.getElementById('welcome').classList.add('hidden');document.getElementById('results').classList.remove('hidden');applyAllFilters();} | |
| function renderResults(data,rv){ | |
| document.getElementById('resultStats').textContent=`${rv.length.toLocaleString()} reviews`; | |
| const summaryHTML=`<div class="batch-summary"><div style="font-size:9px;font-weight:800;text-transform:uppercase;color:var(--muted);letter-spacing:.8px;margin-bottom:2px">Comparing Apps</div><div class="apps-grid">${data.apps.map(a=>`<div class="app-mini-card"><img src="${escH(a.icon)}" alt=""><div class="app-mini-info"><div class="app-mini-title">${escH(a.title)}</div><div class="app-mini-score">${(a.score||0).toFixed(1)} ★</div><div class="app-mini-ct">${data.reviews.filter(r=>r.appId===a.appId).length} reviews</div></div></div>`).join('')}</div></div>`; | |
| const bodyHTML=viewMode==='table'?renderTable(data,rv):renderCards(data,rv); | |
| document.getElementById('results').innerHTML=summaryHTML+bodyHTML; | |
| } | |
| function sa(k){return sortCol===k?(sortDir===1?'↑':'↓'):''} | |
| function fiSvg(k){const on=colFilters[k]?.size;return `<svg class="fi${on?' on':''}" viewBox="0 0 24 24"><polygon points="22 3 2 3 10 12.46 10 19 14 21 14 12.46 22 3"/></svg>`} | |
| function renderTable(data,rv){ | |
| const cols=[{key:'app',label:'App / User',w:'160px'},{key:'score',label:'Score',w:'85px'},{key:null,label:'Review & Response'},{key:'replied',label:'Reply',w:'68px'},{key:'helpful',label:'Helpful',w:'80px'},{key:'date',label:'Date',w:'100px'}]; | |
| const ths=cols.map(c=>`<th class="th-wrap" ${c.w?`style="width:${c.w}"`:''}>${c.key?`<div class="th-inner" onclick="thClick('${c.key}',event)">${c.label}<span class="sa">${sa(c.key)}</span>${fiSvg(c.key)}</div>`:`<div class="th-inner">${c.label}</div>`}</th>`).join(''); | |
| const rows=rv.map(r=>{ | |
| const app=data.apps.find(a=>a.appId===r.appId)||{title:r.appTitle||''}; | |
| const hasReply=!!(r.replyContent?.trim()); | |
| const stars='★'.repeat(r.score)+`<span style="color:var(--border)">${'★'.repeat(5-r.score)}</span>`; | |
| const replyHtml=hasReply?`<div class="dev-reply-cell"><span class="dev-reply-lbl">Dev Reply</span>${escH(r.replyContent)}</div>`:''; | |
| return `<tr><td><div class="app-tag">${escH(app.title)}</div><div style="font-size:11px;font-weight:700">${escH(r.userName||'Anonymous')}</div></td><td><div class="score-stars">${stars}</div><div style="font-size:9px;color:var(--muted);margin-top:3px">${r.score}/5</div></td><td><div class="rev-content">${escH(r.content||'')}</div>${replyHtml}</td><td>${hasReply?'<span style="color:var(--green);font-size:11px;font-weight:700">✓</span>':'<span style="color:var(--muted);font-size:11px">—</span>'}</td><td><div class="hpill"><svg viewBox="0 0 24 24" fill="none" stroke="currentColor" stroke-width="2.5" width="10" height="10"><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.3z"/><path d="M7 22H4a2 2 0 0 1-2-2v-7a2 2 0 0 1 2-2h3"/></svg>${r.thumbsUpCount||0}</div></td><td><div style="color:var(--muted);font-size:11px">${fmtDate(r.at)}</div></td></tr>`; | |
| }).join(''); | |
| return `<div class="table-container"><table><thead><tr>${ths}</tr></thead><tbody>${rows||'<tr><td colspan="6" style="text-align:center;padding:30px;color:var(--muted)">No reviews match the current filters</td></tr>'}</tbody></table></div>`; | |
| } | |
| function renderCards(data,rv){ | |
| const cards=rv.map(r=>{ | |
| const app=data.apps.find(a=>a.appId===r.appId)||{title:r.appTitle||''}; | |
| const hasReply=!!(r.replyContent?.trim()); | |
| const initials=(r.userName||'?').trim().split(/\s+/).map(w=>w[0]).join('').slice(0,2).toUpperCase(); | |
| const avatar=r.userImage?`<div class="rcb-avatar"><img src="${r.userImage}" alt="" onerror="this.style.display='none';this.parentElement.textContent='${initials}'"></div>`:`<div class="rcb-avatar">${initials}</div>`; | |
| const stars=[...Array(5)].map((_,i)=>`<span style="font-size:12px;color:${i<r.score?'var(--amber)':'var(--border)'}">★</span>`).join(''); | |
| const replyHtml=hasReply?`<div class="rcb-dev"><div class="rcb-dev-hdr">💚 Dev Response</div><div class="rcb-dev-text">${escH(r.replyContent)}</div></div>`:''; | |
| const thumb=r.thumbsUpCount?`<span class="mpill">👍 ${r.thumbsUpCount}</span>`:''; | |
| return `<div class="rcb"><div class="rcb-main"><div class="rcb-header">${avatar}<div class="rcb-meta"><div class="rcb-user">${escH(r.userName||'Anonymous')}</div><div class="rcb-date">${fmtDate(r.at)}</div></div><div>${stars}</div></div><div class="rcb-text">${escH(r.content||'')}</div></div>${replyHtml}<div class="rcb-footer"><span class="mpill app">${escH(app.title)}</span>${thumb}${hasReply?'<span class="mpill replied">💬 Dev replied</span>':''}</div></div>`; | |
| }).join(''); | |
| return `<div class="cards-view">${cards||'<div style="grid-column:1/-1;text-align:center;padding:40px;color:var(--muted)">No reviews match filters</div>'}</div>`; | |
| } | |
| // UTILS | |
| function fmtDate(iso){if(!iso)return'';return new Date(iso).toLocaleDateString('en-US',{year:'numeric',month:'short',day:'numeric'});} | |
| function escH(s){return String(s||'').replace(/&/g,'&').replace(/</g,'<').replace(/>/g,'>').replace(/"/g,'"');} | |
| function downloadCSV(){ | |
| if(!currentData)return; | |
| const esc=v=>`"${String(v||'').replace(/"/g,'""')}"`; | |
| const hdr=['App Name','App ID','User','Score','Date','Content','Thumbs Up','Developer Reply']; | |
| const rows=currentData.reviews.map(r=>[esc(r.appTitle),esc(r.appId),esc(r.userName),r.score,esc((r.at||'').slice(0,10)),esc(r.content),r.thumbsUpCount,esc(r.replyContent)].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:`batch_${Date.now()}.csv`}); | |
| a.click();URL.revokeObjectURL(a.href); | |
| } | |
| // 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!</div></div>';await fetch('/chat/clear',{method:'POST',headers:{'Content-Type':'application/json'},body:JSON.stringify({session_id:SESSION_ID})});} | |
| async function sendChatMessage(){ | |
| const input=document.getElementById('chat-input'),msg=input.value.trim();if(!msg)return; | |
| appendUserMsg(msg);input.value=''; | |
| const c=document.getElementById('chat-messages'); | |
| const t=document.createElement('div');t.className='typing-indicator';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)chatFilter(data.filters); | |
| }catch(e){if(t.parentNode)c.removeChild(t);appendBotMsg('Connection error.',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(buildTable(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:6px">';inList=true;}html+=`<div class="msg-item"><span class="msg-item-num">${nm[1]}.</span><span>${inlineF(nm[2])}</span></div>`;continue;} | |
| const bm=raw.match(/^[•\-\*]\s+(.+)/);if(bm){if(!inList){html+='<div style="margin-top:6px">';inList=true;}html+=`<div class="msg-item"><span class="msg-bullet">•</span><span>${inlineF(bm[1])}</span></div>`;continue;} | |
| if(inList&&!raw.trim()){html+='</div>';inList=false;} | |
| html+=raw.trim()?`<span>${inlineF(raw)}</span><br>`:'<br>'; | |
| } | |
| if(inList)html+='</div>';return html; | |
| } | |
| function inlineF(t){return escH(t).replace(/\*\*(.+?)\*\*/g,'<strong>$1</strong>').replace(/_(.+?)_/g,'<em style="color:var(--muted2)">$1</em>');} | |
| function buildTable(td){ | |
| const{title,columns,rows}=td,w=document.createElement('div');w.className='chat-table-wrap'; | |
| let h='';if(title)h+=`<div class="chat-table-title">${escH(title)}</div>`; | |
| h+='<table class="chat-table"><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 chatFilter(raw){ | |
| if(!currentData)return; | |
| try{ | |
| const f=typeof raw==='string'?JSON.parse(raw):raw;let rv=currentData.reviews; | |
| 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=>{const a=currentData.apps.find(x=>x.appId===r.appId)||{};return(a.title||'').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));} | |
| renderResults(currentData,rv); | |
| }catch(e){console.error(e);} | |
| } | |
| </script> | |
| </body> | |
| </html> |