Spaces:
Running
Running
| <html lang="id"> | |
| <head> | |
| <meta charset="UTF-8"> | |
| <meta name="viewport" content="width=device-width, initial-scale=1.0"> | |
| <title>Tokiva SKU Scanner — POS Kasir Pintar</title> | |
| <script src="https://cdn.jsdelivr.net/npm/@tensorflow/tfjs@4.20.0/dist/tf.min.js"></script> | |
| <link rel="preconnect" href="https://fonts.googleapis.com"> | |
| <link rel="preconnect" href="https://fonts.gstatic.com" crossorigin> | |
| <link href="https://fonts.googleapis.com/css2?family=Outfit:wght@300;400;500;600;700;800&display=swap" rel="stylesheet"> | |
| <style> | |
| :root { | |
| --bg-dark: #07080e; | |
| --bg-card: rgba(16, 18, 27, 0.7); | |
| --border-card: rgba(255, 255, 255, 0.07); | |
| --primary: #3b82f6; | |
| --primary-glow: rgba(59, 130, 246, 0.35); | |
| --success: #10b981; | |
| --success-glow: rgba(16, 185, 129, 0.35); | |
| --warning: #fbbf24; | |
| --danger: #ef4444; | |
| --text-main: #f3f4f6; | |
| --text-muted: #9ca3af; | |
| --font-family: 'Outfit', -apple-system, sans-serif; | |
| } | |
| * { | |
| box-sizing: border-box; | |
| margin: 0; | |
| padding: 0; | |
| } | |
| body { | |
| font-family: var(--font-family); | |
| background-color: var(--bg-dark); | |
| background-image: | |
| radial-gradient(circle at top right, rgba(59, 130, 246, 0.1), transparent 450px), | |
| radial-gradient(circle at bottom left, rgba(16, 185, 129, 0.08), transparent 450px); | |
| background-attachment: fixed; | |
| color: var(--text-main); | |
| padding: 24px; | |
| min-height: 100vh; | |
| display: flex; | |
| flex-direction: column; | |
| justify-content: flex-start; | |
| align-items: center; | |
| } | |
| /* Header Section */ | |
| header { | |
| width: 100%; | |
| max-width: 1200px; | |
| margin-bottom: 24px; | |
| display: flex; | |
| justify-content: space-between; | |
| align-items: center; | |
| padding-bottom: 16px; | |
| border-bottom: 1px solid var(--border-card); | |
| } | |
| .logo-container { | |
| display: flex; | |
| align-items: center; | |
| gap: 10px; | |
| } | |
| .logo-icon { | |
| font-size: 28px; | |
| background: linear-gradient(135deg, var(--primary), var(--success)); | |
| -webkit-background-clip: text; | |
| -webkit-text-fill-color: transparent; | |
| font-weight: 800; | |
| } | |
| .logo-title { | |
| font-size: 22px; | |
| font-weight: 700; | |
| letter-spacing: -0.5px; | |
| } | |
| .logo-title span { | |
| color: var(--success); | |
| font-weight: 400; | |
| } | |
| .status-badge { | |
| display: flex; | |
| align-items: center; | |
| gap: 8px; | |
| background: rgba(255, 255, 255, 0.03); | |
| border: 1px solid var(--border-card); | |
| padding: 6px 14px; | |
| border-radius: 20px; | |
| font-size: 13px; | |
| font-weight: 500; | |
| } | |
| .status-dot { | |
| width: 8px; | |
| height: 8px; | |
| border-radius: 50%; | |
| background-color: var(--warning); | |
| box-shadow: 0 0 8px var(--warning); | |
| } | |
| .status-dot.ready { | |
| background-color: var(--success); | |
| box-shadow: 0 0 10px var(--success); | |
| } | |
| .status-dot.loading { | |
| background-color: var(--warning); | |
| box-shadow: 0 0 10px var(--warning); | |
| animation: pulse 1.5s infinite alternate; | |
| } | |
| .status-dot.error { | |
| background-color: var(--danger); | |
| box-shadow: 0 0 10px var(--danger); | |
| } | |
| /* Main Dashboard Grid */ | |
| .dashboard-grid { | |
| width: 100%; | |
| max-width: 1200px; | |
| display: grid; | |
| grid-template-columns: 1.2fr 1fr; | |
| gap: 24px; | |
| } | |
| @media (max-width: 900px) { | |
| .dashboard-grid { | |
| grid-template-columns: 1fr; | |
| } | |
| } | |
| /* Glassmorphic Cards */ | |
| .card { | |
| background: var(--bg-card); | |
| backdrop-filter: blur(12px); | |
| -webkit-backdrop-filter: blur(12px); | |
| border: 1px solid var(--border-card); | |
| border-radius: 16px; | |
| padding: 20px; | |
| box-shadow: 0 8px 32px 0 rgba(0, 0, 0, 0.3); | |
| margin-bottom: 24px; | |
| } | |
| .card-title { | |
| font-size: 16px; | |
| font-weight: 600; | |
| margin-bottom: 16px; | |
| display: flex; | |
| align-items: center; | |
| gap: 8px; | |
| border-bottom: 1px solid rgba(255, 255, 255, 0.05); | |
| padding-bottom: 8px; | |
| } | |
| .card-title svg { | |
| color: var(--primary); | |
| } | |
| /* Left Panel: Camera & Viewport */ | |
| .viewport-container { | |
| position: relative; | |
| width: 100%; | |
| aspect-ratio: 1/1; | |
| max-height: 400px; | |
| margin: 0 auto 16px auto; | |
| border-radius: 12px; | |
| overflow: hidden; | |
| background: #000; | |
| border: 2px solid rgba(255, 255, 255, 0.05); | |
| box-shadow: inset 0 0 30px rgba(0,0,0,0.8); | |
| display: flex; | |
| align-items: center; | |
| justify-content: center; | |
| } | |
| #video, #previewImg { | |
| width: 100%; | |
| height: 100%; | |
| object-fit: cover; | |
| border-radius: 10px; | |
| transition: transform 0.3s ease; | |
| } | |
| #canvas { | |
| display: none; | |
| } | |
| /* Cyberpunk brackets / scanner decoration */ | |
| .scanner-overlay { | |
| position: absolute; | |
| top: 0; | |
| left: 0; | |
| width: 100%; | |
| height: 100%; | |
| pointer-events: none; | |
| z-index: 10; | |
| } | |
| .corner-bracket { | |
| position: absolute; | |
| width: 24px; | |
| height: 24px; | |
| border-color: rgba(255, 255, 255, 0.3); | |
| border-style: solid; | |
| transition: border-color 0.3s; | |
| } | |
| .corner-bracket.top-left { top: 16px; left: 16px; border-width: 3px 0 0 3px; border-top-left-radius: 4px; } | |
| .corner-bracket.top-right { top: 16px; right: 16px; border-width: 3px 3px 0 0; border-top-right-radius: 4px; } | |
| .corner-bracket.bottom-left { bottom: 16px; left: 16px; border-width: 0 0 3px 3px; border-bottom-left-radius: 4px; } | |
| .corner-bracket.bottom-right { bottom: 16px; right: 16px; border-width: 0 3px 3px 0; border-bottom-right-radius: 4px; } | |
| /* Pulse scan animation when active */ | |
| .scanner-laser { | |
| position: absolute; | |
| left: 0; | |
| right: 0; | |
| height: 3px; | |
| background: linear-gradient(to right, transparent, var(--success), transparent); | |
| box-shadow: 0 0 10px var(--success), 0 0 20px var(--success); | |
| opacity: 0; | |
| pointer-events: none; | |
| z-index: 11; | |
| } | |
| .scanner-active .scanner-laser { | |
| animation: laser-sweep 2s linear infinite; | |
| opacity: 1; | |
| } | |
| .scanner-active .corner-bracket { | |
| border-color: var(--success); | |
| } | |
| /* Flash overlay for successful detection */ | |
| .scanner-flash { | |
| position: absolute; | |
| top: 0; | |
| left: 0; | |
| width: 100%; | |
| height: 100%; | |
| background: var(--success); | |
| opacity: 0; | |
| z-index: 12; | |
| pointer-events: none; | |
| transition: opacity 0.1s ease-out; | |
| } | |
| .scanner-flash.flash-active { | |
| opacity: 0.25; | |
| transition: none; | |
| } | |
| /* Controls and sliders */ | |
| .controls-row { | |
| display: grid; | |
| grid-template-columns: repeat(auto-fit, minmax(120px, 1fr)); | |
| gap: 10px; | |
| margin-bottom: 16px; | |
| } | |
| button, .file-btn-label { | |
| display: inline-flex; | |
| align-items: center; | |
| justify-content: center; | |
| gap: 8px; | |
| padding: 10px 16px; | |
| border-radius: 10px; | |
| font-size: 13.5px; | |
| font-weight: 600; | |
| cursor: pointer; | |
| transition: all 0.2s cubic-bezier(0.4, 0, 0.2, 1); | |
| color: white; | |
| border: 1px solid rgba(255, 255, 255, 0.05); | |
| background: rgba(255, 255, 255, 0.05); | |
| } | |
| button:hover, .file-btn-label:hover { | |
| background: rgba(255, 255, 255, 0.1); | |
| transform: translateY(-1px); | |
| } | |
| button:active, .file-btn-label:active { | |
| transform: translateY(1px); | |
| } | |
| button.btn-primary { | |
| background: linear-gradient(135deg, var(--primary), #2563eb); | |
| box-shadow: 0 4px 14px var(--primary-glow); | |
| } | |
| button.btn-primary:hover { | |
| background: linear-gradient(135deg, #2563eb, #1d4ed8); | |
| box-shadow: 0 6px 18px var(--primary-glow); | |
| } | |
| button.btn-success { | |
| background: linear-gradient(135deg, var(--success), #059669); | |
| box-shadow: 0 4px 14px var(--success-glow); | |
| } | |
| button.btn-success:hover { | |
| background: linear-gradient(135deg, #059669, #047857); | |
| box-shadow: 0 6px 18px var(--success-glow); | |
| } | |
| button.btn-danger { | |
| background: linear-gradient(135deg, var(--danger), #dc2626); | |
| box-shadow: 0 4px 14px rgba(239, 68, 68, 0.3); | |
| } | |
| button.btn-danger:hover { | |
| background: linear-gradient(135deg, #dc2626, #b91c1c); | |
| box-shadow: 0 6px 18px rgba(239, 68, 68, 0.35); | |
| } | |
| button:disabled, .file-btn-label.disabled { | |
| opacity: 0.4; | |
| cursor: not-allowed; | |
| transform: none ; | |
| box-shadow: none ; | |
| } | |
| /* File Input styling */ | |
| input[type="file"] { | |
| display: none; | |
| } | |
| .upload-zone { | |
| border: 2px dashed rgba(255, 255, 255, 0.12); | |
| border-radius: 12px; | |
| padding: 16px; | |
| text-align: center; | |
| background: rgba(255, 255, 255, 0.01); | |
| transition: all 0.2s; | |
| cursor: pointer; | |
| display: flex; | |
| flex-direction: column; | |
| align-items: center; | |
| gap: 6px; | |
| } | |
| .upload-zone:hover, .upload-zone.dragover { | |
| border-color: var(--primary); | |
| background: rgba(59, 130, 246, 0.04); | |
| } | |
| .upload-zone svg { | |
| color: var(--text-muted); | |
| transition: color 0.2s; | |
| } | |
| .upload-zone:hover svg, .upload-zone.dragover svg { | |
| color: var(--primary); | |
| } | |
| .upload-zone span { | |
| font-size: 13px; | |
| font-weight: 500; | |
| color: var(--text-muted); | |
| } | |
| /* Threshold Control Slider */ | |
| .slider-container { | |
| background: rgba(255, 255, 255, 0.02); | |
| border: 1px solid rgba(255, 255, 255, 0.04); | |
| padding: 12px; | |
| border-radius: 10px; | |
| margin-top: 14px; | |
| } | |
| .slider-label-row { | |
| display: flex; | |
| justify-content: space-between; | |
| font-size: 13px; | |
| font-weight: 500; | |
| margin-bottom: 8px; | |
| color: var(--text-muted); | |
| } | |
| .slider-value { | |
| color: var(--primary); | |
| font-weight: 700; | |
| } | |
| .range-slider { | |
| width: 100%; | |
| -webkit-appearance: none; | |
| appearance: none; | |
| height: 6px; | |
| border-radius: 3px; | |
| background: rgba(255, 255, 255, 0.1); | |
| outline: none; | |
| } | |
| .range-slider::-webkit-slider-thumb { | |
| -webkit-appearance: none; | |
| appearance: none; | |
| width: 16px; | |
| height: 16px; | |
| border-radius: 50%; | |
| background: var(--primary); | |
| cursor: pointer; | |
| box-shadow: 0 0 8px var(--primary-glow); | |
| transition: transform 0.1s; | |
| } | |
| .range-slider::-webkit-slider-thumb:hover { | |
| transform: scale(1.2); | |
| } | |
| /* Right Panel: Results */ | |
| .prediction-display { | |
| background: rgba(255, 255, 255, 0.02); | |
| border-radius: 12px; | |
| border: 1px solid rgba(255, 255, 255, 0.04); | |
| padding: 16px; | |
| margin-bottom: 16px; | |
| position: relative; | |
| overflow: hidden; | |
| } | |
| .pred-label { | |
| font-size: 11px; | |
| text-transform: uppercase; | |
| letter-spacing: 1px; | |
| color: var(--text-muted); | |
| font-weight: 600; | |
| margin-bottom: 6px; | |
| } | |
| .pred-main-name { | |
| font-size: 20px; | |
| font-weight: 700; | |
| color: var(--success); | |
| margin-bottom: 4px; | |
| white-space: nowrap; | |
| overflow: hidden; | |
| text-overflow: ellipsis; | |
| } | |
| .pred-meta-row { | |
| display: flex; | |
| justify-content: space-between; | |
| align-items: center; | |
| font-size: 12.5px; | |
| color: var(--text-muted); | |
| } | |
| .badge { | |
| padding: 3px 8px; | |
| border-radius: 4px; | |
| font-size: 11px; | |
| font-weight: 600; | |
| background: rgba(255, 255, 255, 0.06); | |
| } | |
| /* Top 5 list */ | |
| .top-list { | |
| display: flex; | |
| flex-direction: column; | |
| gap: 8px; | |
| } | |
| .top-item { | |
| display: flex; | |
| align-items: center; | |
| gap: 12px; | |
| font-size: 13px; | |
| } | |
| .top-item-name { | |
| width: 140px; | |
| text-align: right; | |
| white-space: nowrap; | |
| overflow: hidden; | |
| text-overflow: ellipsis; | |
| color: var(--text-muted); | |
| font-weight: 500; | |
| } | |
| .top-item-bar-track { | |
| flex: 1; | |
| background: rgba(255, 255, 255, 0.05); | |
| height: 12px; | |
| border-radius: 6px; | |
| overflow: hidden; | |
| border: 1px solid rgba(255, 255, 255, 0.02); | |
| } | |
| .top-item-bar-fill { | |
| height: 100%; | |
| background: var(--primary); | |
| border-radius: 6px; | |
| transition: width 0.3s ease-out; | |
| } | |
| .top-item-bar-fill.rank-success { | |
| background: linear-gradient(90deg, var(--primary), var(--success)); | |
| } | |
| .top-item-bar-fill.rank-warning { | |
| background: linear-gradient(90deg, var(--primary), var(--warning)); | |
| } | |
| .top-item-bar-fill.rank-danger { | |
| background: linear-gradient(90deg, var(--primary), var(--danger)); | |
| } | |
| .top-item-pct { | |
| width: 50px; | |
| text-align: left; | |
| font-weight: 600; | |
| color: var(--text-muted); | |
| } | |
| .warning-box { | |
| margin-top: 14px; | |
| background: rgba(251, 191, 36, 0.08); | |
| border: 1px solid rgba(251, 191, 36, 0.2); | |
| padding: 10px; | |
| border-radius: 8px; | |
| color: var(--warning); | |
| font-size: 12.5px; | |
| display: flex; | |
| align-items: center; | |
| gap: 8px; | |
| } | |
| /* POS Shopping Cart & History Section */ | |
| .pos-container { | |
| display: grid; | |
| grid-template-columns: 1fr; | |
| gap: 20px; | |
| } | |
| .cart-list { | |
| max-height: 180px; | |
| overflow-y: auto; | |
| display: flex; | |
| flex-direction: column; | |
| gap: 8px; | |
| margin-bottom: 12px; | |
| padding-right: 4px; | |
| } | |
| /* scrollbar */ | |
| .cart-list::-webkit-scrollbar, .history-list::-webkit-scrollbar { | |
| width: 6px; | |
| } | |
| .cart-list::-webkit-scrollbar-thumb, .history-list::-webkit-scrollbar-thumb { | |
| background: rgba(255, 255, 255, 0.1); | |
| border-radius: 3px; | |
| } | |
| .cart-item { | |
| display: flex; | |
| justify-content: space-between; | |
| align-items: center; | |
| background: rgba(255, 255, 255, 0.02); | |
| border: 1px solid rgba(255, 255, 255, 0.04); | |
| padding: 10px 12px; | |
| border-radius: 8px; | |
| font-size: 13px; | |
| } | |
| .cart-item-info { | |
| flex: 1; | |
| overflow: hidden; | |
| padding-right: 8px; | |
| } | |
| .cart-item-name { | |
| font-weight: 600; | |
| white-space: nowrap; | |
| overflow: hidden; | |
| text-overflow: ellipsis; | |
| } | |
| .cart-item-price { | |
| font-size: 12px; | |
| color: var(--text-muted); | |
| } | |
| .cart-item-actions { | |
| display: flex; | |
| align-items: center; | |
| gap: 12px; | |
| } | |
| .cart-item-qty { | |
| font-weight: 700; | |
| color: var(--primary); | |
| } | |
| .cart-item-total { | |
| font-weight: 700; | |
| } | |
| .btn-remove { | |
| background: none; | |
| border: none; | |
| color: var(--text-muted); | |
| cursor: pointer; | |
| font-size: 16px; | |
| padding: 2px 6px; | |
| border-radius: 4px; | |
| transition: all 0.2s; | |
| } | |
| .btn-remove:hover { | |
| color: var(--danger); | |
| background: rgba(239, 68, 68, 0.15); | |
| } | |
| .cart-empty { | |
| text-align: center; | |
| padding: 24px; | |
| color: var(--text-muted); | |
| font-size: 13px; | |
| font-style: italic; | |
| } | |
| .cart-summary { | |
| border-top: 1px solid rgba(255, 255, 255, 0.05); | |
| padding-top: 12px; | |
| display: flex; | |
| justify-content: space-between; | |
| align-items: center; | |
| margin-bottom: 12px; | |
| } | |
| .summary-label { | |
| font-weight: 600; | |
| font-size: 14px; | |
| } | |
| .summary-total { | |
| font-size: 18px; | |
| font-weight: 800; | |
| color: var(--success); | |
| } | |
| /* History list */ | |
| .history-list { | |
| max-height: 140px; | |
| overflow-y: auto; | |
| display: flex; | |
| flex-direction: column; | |
| gap: 6px; | |
| padding-right: 4px; | |
| } | |
| .history-item { | |
| display: flex; | |
| justify-content: space-between; | |
| align-items: center; | |
| padding: 6px 10px; | |
| background: rgba(255, 255, 255, 0.01); | |
| border-radius: 6px; | |
| font-size: 12px; | |
| border-left: 3px solid var(--primary); | |
| } | |
| .history-item.high-conf { | |
| border-left-color: var(--success); | |
| } | |
| .history-item-name { | |
| font-weight: 500; | |
| white-space: nowrap; | |
| overflow: hidden; | |
| text-overflow: ellipsis; | |
| max-width: 180px; | |
| } | |
| .history-item-time { | |
| color: var(--text-muted); | |
| font-size: 11px; | |
| } | |
| .history-empty { | |
| text-align: center; | |
| padding: 12px; | |
| color: var(--text-muted); | |
| font-size: 12px; | |
| font-style: italic; | |
| } | |
| @keyframes pulse { | |
| 0% { transform: scale(1); opacity: 0.7; } | |
| 100% { transform: scale(1.2); opacity: 1; } | |
| } | |
| @keyframes laser-sweep { | |
| 0% { top: 0%; } | |
| 50% { top: 100%; } | |
| 100% { top: 0%; } | |
| } | |
| </style> | |
| </head> | |
| <body> | |
| <header> | |
| <div class="logo-container"> | |
| <div class="logo-icon">▲</div> | |
| <h1 class="logo-title">Tokiva <span>SKU Scanner</span></h1> | |
| </div> | |
| <div class="status-badge"> | |
| <div id="statusDot" class="status-dot loading"></div> | |
| <span id="statusText">Memuat modul deteksi...</span> | |
| </div> | |
| </header> | |
| <div class="dashboard-grid"> | |
| <!-- Left Panel: Scanning Feed & Controls --> | |
| <div> | |
| <div class="card"> | |
| <div class="card-title"> | |
| <svg width="18" height="18" fill="none" stroke="currentColor" viewBox="0 0 24 24"><path stroke-linecap="round" stroke-linejoin="round" stroke-width="2" d="M15 10l4.553-2.276A1 1 0 0121 8.618v6.764a1 1 0 01-1.447.894L15 14M5 18h8a2 2 0 002-2V8a2 2 0 00-2-2H5a2 2 0 00-2 2v8a2 2 0 002 2z"></path></svg> | |
| <span>Pemindai Kamera & Input</span> | |
| </div> | |
| <div id="viewportContainer" class="viewport-container"> | |
| <!-- Decoration brackets for target area --> | |
| <div class="scanner-overlay"> | |
| <div class="corner-bracket top-left"></div> | |
| <div class="corner-bracket top-right"></div> | |
| <div class="corner-bracket bottom-left"></div> | |
| <div class="corner-bracket bottom-right"></div> | |
| <div id="scannerLaser" class="scanner-laser"></div> | |
| <div id="scannerFlash" class="scanner-flash"></div> | |
| </div> | |
| <video id="video" autoplay playsinline muted style="display:none;"></video> | |
| <img id="previewImg" style="display:none;"> | |
| <canvas id="canvas" width="224" height="224"></canvas> | |
| <div id="viewportPlaceholder" style="color: var(--text-muted); font-size: 13.5px; font-weight: 500;"> | |
| Kamera tidak aktif | |
| </div> | |
| </div> | |
| <div class="controls-row"> | |
| <button id="btnCamera" class="btn-primary" onclick="startCamera()"> | |
| <svg width="14" height="14" fill="none" stroke="currentColor" viewBox="0 0 24 24"><path stroke-linecap="round" stroke-linejoin="round" stroke-width="2" d="M15 13a3 3 0 11-6 0 3 3 0 016 0z"></path><path stroke-linecap="round" stroke-linejoin="round" stroke-width="2" d="M2.458 12C3.732 7.943 7.523 5 12 5c4.478 0 8.268 2.943 9.542 7-1.274 4.057-5.064 7-9.542 7-4.477 0-8.268-2.943-9.542-7z"></path></svg> | |
| Nyalakan | |
| </button> | |
| <button id="btnToggleCamera" onclick="switchCameraFacing()" disabled> | |
| <svg width="14" height="14" fill="none" stroke="currentColor" viewBox="0 0 24 24"><path stroke-linecap="round" stroke-linejoin="round" stroke-width="2" d="M4 4v5h.582m15.356 2A8.001 8.001 0 1121.21 7.89"></path></svg> | |
| Putar Kamera | |
| </button> | |
| <button id="btnRealtime" class="btn-success" onclick="toggleRealtime()" disabled> | |
| Mulai Scan | |
| </button> | |
| </div> | |
| <div class="upload-zone" id="dropzone" onclick="document.getElementById('upload').click()"> | |
| <svg width="24" height="24" fill="none" stroke="currentColor" viewBox="0 0 24 24"><path stroke-linecap="round" stroke-linejoin="round" stroke-width="2" d="M4 16v1a3 3 0 003 3h10a3 3 0 003-3v-1m-4-8l-4-4m0 0L8 8m4-4v12"></path></svg> | |
| <span>Klik atau seret file gambar produk di sini</span> | |
| <input type="file" id="upload" accept="image/*" onchange="handleUpload(event)"> | |
| </div> | |
| <div class="slider-container"> | |
| <div class="slider-label-row"> | |
| <span>Confidence Threshold (Kasir)</span> | |
| <span class="slider-value" id="sliderValDisplay">60%</span> | |
| </div> | |
| <input type="range" class="range-slider" id="thresholdSlider" min="10" max="95" value="60" oninput="updateThreshold(this.value)"> | |
| </div> | |
| </div> | |
| </div> | |
| <!-- Right Panel: Results & POS --> | |
| <div class="pos-container"> | |
| <!-- Live Results Card --> | |
| <div class="card"> | |
| <div class="card-title"> | |
| <svg width="18" height="18" fill="none" stroke="currentColor" viewBox="0 0 24 24"><path stroke-linecap="round" stroke-linejoin="round" stroke-width="2" d="M9 5H7a2 2 0 00-2 2v12a2 2 0 002 2h10a2 2 0 002-2V7a2 2 0 00-2-2h-2M9 5a2 2 0 002 2h2a2 2 0 002-2M9 5a2 2 0 012-2h2a2 2 0 012 2m-3 7h3m-3 4h3m-6-4h.01M9 16h.01"></path></svg> | |
| <span>Hasil Deteksi Model</span> | |
| </div> | |
| <div id="resultBox" style="display:none;"> | |
| <div class="prediction-display"> | |
| <div class="pred-label">Prediksi SKU Tertinggi</div> | |
| <div id="mainPrediction" class="pred-main-name">Silakan scan produk...</div> | |
| <div class="pred-meta-row"> | |
| <span id="inferenceTime" class="badge">⚡ - ms</span> | |
| <span id="accuracyLabel">Confidence: -</span> | |
| </div> | |
| </div> | |
| <div class="pred-label" style="margin-bottom: 8px;">Top-5 Kandidat Kecocokan</div> | |
| <div class="top-list" id="top5Container"> | |
| <!-- items dynamically filled --> | |
| </div> | |
| <div id="lowConfidenceWarning" class="warning-box" style="display:none;"> | |
| <span>⚠️</span> | |
| <span id="lowConfidenceWarningText">Batas confidence rendah.</span> | |
| </div> | |
| </div> | |
| <div id="resultPlaceholder" style="text-align: center; padding: 24px; color: var(--text-muted); font-size: 13.5px; font-style: italic;"> | |
| Belum ada gambar yang dipindai. | |
| </div> | |
| </div> | |
| <!-- POS Kasir (Simulasi) Card --> | |
| <div class="card"> | |
| <div class="card-title"> | |
| <svg width="18" height="18" fill="none" stroke="currentColor" viewBox="0 0 24 24"><path stroke-linecap="round" stroke-linejoin="round" stroke-width="2" d="M3 3h2l.4 2M7 13h10l4-8H5.4M7 13L5.4 5M7 13l-2.293 2.293c-.63.63-.184 1.707.707 1.707H17m0 0a2 2 0 100 4 2 2 0 000-4zm-8 2a2 2 0 11-4 0 2 2 0 014 0z"></path></svg> | |
| <span>POS Kasir Tokiva</span> | |
| </div> | |
| <div class="cart-list" id="cartList"> | |
| <div class="cart-empty">Belum ada item belanja. Scan produk untuk memasukkan ke kasir.</div> | |
| </div> | |
| <div class="cart-summary"> | |
| <div class="summary-label">Total Belanja</div> | |
| <div class="summary-total" id="cartTotal">Rp 0</div> | |
| </div> | |
| <button id="btnCheckout" class="btn-success" style="width: 100%; display: flex; justify-content: center; gap: 8px;" onclick="checkoutCart()"> | |
| <svg width="16" height="16" fill="none" stroke="currentColor" viewBox="0 0 24 24"><path stroke-linecap="round" stroke-linejoin="round" stroke-width="2" d="M9 5H7a2 2 0 00-2 2v12a2 2 0 002 2h10a2 2 0 002-2V7a2 2 0 00-2-2h-2M9 5a2 2 0 002 2h2a2 2 0 002-2M9 5a2 2 0 012-2h2a2 2 0 012 2m-6 9l2 2 4-4"></path></svg> | |
| Bayar & Cetak Struk | |
| </button> | |
| </div> | |
| <!-- Scan History Card --> | |
| <div class="card"> | |
| <div class="card-title"> | |
| <svg width="18" height="18" fill="none" stroke="currentColor" viewBox="0 0 24 24"><path stroke-linecap="round" stroke-linejoin="round" stroke-width="2" d="M12 8v4l3 3m6-3a9 9 0 11-18 0 9 9 0 0118 0z"></path></svg> | |
| <span>Riwayat Scan Terakhir</span> | |
| </div> | |
| <div class="history-list" id="historyList"> | |
| <div class="history-empty">Belum ada riwayat pemindaian.</div> | |
| </div> | |
| </div> | |
| </div> | |
| </div> | |
| <script> | |
| const IMG_SIZE = 224; | |
| const REALTIME_INTERVAL_MS = 750; // jeda scan realtime (ms) | |
| const COOLDOWN_MS = 3000; // 3 detik cooldown per item agar tidak spamming audio & cart | |
| // Mock database harga produk berdasarkan SKU names | |
| const PRODUCT_CATALOG = { | |
| "frisian-flag-fullcrm-250ml": { name: "Frisian Flag Full Cream 250ml", price: 7500 }, | |
| "frisian-flag-strwbry-250ml": { name: "Frisian Flag Strawberry 250ml", price: 7500 }, | |
| "gaga-100-grg-jalapeto": { name: "Mie Gaga 100 Goreng Jalapeno", price: 4500 }, | |
| "gaga-100-kuah-jalapeto": { name: "Mie Gaga 100 Kuah Jalapeno", price: 4500 }, | |
| "indomie-grg-cb-ijo": { name: "Indomie Goreng Cabe Ijo", price: 3500 }, | |
| "indomie-grg-cb-ijo-jumbo": { name: "Indomie Goreng Cabe Ijo Jumbo", price: 4500 }, | |
| "klik-crackers-keju": { name: "Klik Crackers Keju", price: 6000 }, | |
| "nabati-siip-keju": { name: "Nabati Siip Keju", price: 2000 }, | |
| "pepsodent-72g": { name: "Pepsodent 72g", price: 12500 }, | |
| "sarimi-aym-bwng": { name: "Sarimi Ayam Bawang", price: 3200 }, | |
| "sarimi-gls-baso-pds": { name: "Sarimi Gelas Baso Pedas", price: 2500 }, | |
| "sedaap-grng": { name: "Mie Sedaap Goreng", price: 3500 }, | |
| "sedaap-kuah-aym-bwg": { name: "Mie Sedaap Kuah Ayam Bawang", price: 3300 }, | |
| "sedaap-sg-laksa": { name: "Mie Sedaap Singapore Laksa", price: 3600 }, | |
| "siplah-mineral-220ml": { name: "Siplah Air Mineral Gelas 220ml", price: 1500 }, | |
| "soffell-bunga": { name: "Soffell Bunga Kulit", price: 15000 }, | |
| "soffell-jeruk": { name: "Soffell Jeruk Kulit", price: 15000 }, | |
| "ultramlk-fullcrm-200ml": { name: "Ultra Milk Full Cream 200ml", price: 6800 }, | |
| "vica-600ml": { name: "Vica Air Mineral 600ml", price: 4000 } | |
| }; | |
| let model = null; | |
| let classNames = []; | |
| let realtimeActive = false; | |
| let realtimeTimer = null; | |
| let cameraStream = null; | |
| let currentFacingMode = 'environment'; // environment = back camera, user = front camera | |
| let CONFIDENCE_THRESHOLD = 0.60; | |
| // State Cooldown | |
| let lastScannedItem = ''; | |
| let lastScanTime = 0; | |
| // POS State & History State | |
| const cart = {}; | |
| const scanHistory = []; | |
| // Audio Object | |
| const beepAudio = new Audio('beep.mp3'); | |
| // Elements | |
| const statusDot = document.getElementById('statusDot'); | |
| const statusText = document.getElementById('statusText'); | |
| const videoEl = document.getElementById('video'); | |
| const previewImgEl = document.getElementById('previewImg'); | |
| const canvasEl = document.getElementById('canvas'); | |
| const btnCamera = document.getElementById('btnCamera'); | |
| const btnToggleCamera = document.getElementById('btnToggleCamera'); | |
| const btnRealtime = document.getElementById('btnRealtime'); | |
| const viewportPlaceholder = document.getElementById('viewportPlaceholder'); | |
| const viewportContainer = document.getElementById('viewportContainer'); | |
| const scannerFlash = document.getElementById('scannerFlash'); | |
| const sliderValDisplay = document.getElementById('sliderValDisplay'); | |
| const thresholdSlider = document.getElementById('thresholdSlider'); | |
| const resultBox = document.getElementById('resultBox'); | |
| const resultPlaceholder = document.getElementById('resultPlaceholder'); | |
| const mainPredictionEl = document.getElementById('mainPrediction'); | |
| const inferenceTimeEl = document.getElementById('inferenceTime'); | |
| const accuracyLabel = document.getElementById('accuracyLabel'); | |
| const top5ContainerEl = document.getElementById('top5Container'); | |
| const lowConfidenceWarningEl = document.getElementById('lowConfidenceWarning'); | |
| const lowConfidenceWarningText = document.getElementById('lowConfidenceWarningText'); | |
| const cartListEl = document.getElementById('cartList'); | |
| const cartTotalEl = document.getElementById('cartTotal'); | |
| const historyListEl = document.getElementById('historyList'); | |
| // --------------------------------------------------------- | |
| // Load model + daftar kelas | |
| // --------------------------------------------------------- | |
| async function loadModel() { | |
| try { | |
| statusDot.className = 'status-dot loading'; | |
| statusText.textContent = 'Memuat model TensorFlow.js...'; | |
| model = await tf.loadGraphModel('model/model.json'); | |
| const res = await fetch('model/class_names.json'); | |
| classNames = await res.json(); | |
| statusDot.className = 'status-dot ready'; | |
| statusText.textContent = `Model Siap (${classNames.length} SKU). Nyalakan kamera atau unggah foto.`; | |
| } catch (err) { | |
| statusDot.className = 'status-dot error'; | |
| statusText.textContent = 'Gagal memuat model: ' + err.message; | |
| console.error(err); | |
| } | |
| } | |
| loadModel(); | |
| // --------------------------------------------------------- | |
| // Kamera controls | |
| // --------------------------------------------------------- | |
| async function startCamera() { | |
| stopCamera(); // Pastikan kamera lama dimatikan sebelum membuat koneksi baru | |
| try { | |
| statusText.textContent = 'Mengakses kamera...'; | |
| cameraStream = await navigator.mediaDevices.getUserMedia({ | |
| video: { | |
| facingMode: currentFacingMode, | |
| width: { ideal: 640 }, | |
| height: { ideal: 640 } | |
| } | |
| }); | |
| videoEl.srcObject = cameraStream; | |
| videoEl.style.display = 'block'; | |
| previewImgEl.style.display = 'none'; | |
| viewportPlaceholder.style.display = 'none'; | |
| btnToggleCamera.disabled = false; | |
| btnRealtime.disabled = false; | |
| btnCamera.textContent = 'Matikan Kamera'; | |
| btnCamera.className = 'btn-danger'; | |
| btnCamera.setAttribute('onclick', 'stopCameraUI()'); | |
| statusText.textContent = 'Kamera aktif. Klik "Mulai Scan" untuk mendeteksi otomatis.'; | |
| } catch (err) { | |
| statusText.textContent = 'Gagal mengakses kamera: ' + err.message; | |
| statusDot.className = 'status-dot error'; | |
| } | |
| } | |
| // Stop Camera stream | |
| function stopCamera() { | |
| if (realtimeActive) toggleRealtime(); | |
| if (cameraStream) { | |
| cameraStream.getTracks().forEach(track => track.stop()); | |
| cameraStream = null; | |
| } | |
| videoEl.srcObject = null; | |
| } | |
| function stopCameraUI() { | |
| stopCamera(); | |
| videoEl.style.display = 'none'; | |
| viewportPlaceholder.style.display = 'block'; | |
| btnToggleCamera.disabled = true; | |
| btnRealtime.disabled = true; | |
| btnCamera.textContent = 'Nyalakan Kamera'; | |
| btnCamera.className = 'btn-primary'; | |
| btnCamera.setAttribute('onclick', 'startCamera()'); | |
| statusText.textContent = 'Kamera dinonaktifkan.'; | |
| if (model) { | |
| statusDot.className = 'status-dot ready'; | |
| } | |
| } | |
| async function switchCameraFacing() { | |
| currentFacingMode = currentFacingMode === 'environment' ? 'user' : 'environment'; | |
| await startCamera(); | |
| } | |
| function toggleRealtime() { | |
| realtimeActive = !realtimeActive; | |
| btnRealtime.textContent = realtimeActive ? 'Stop Scan' : 'Mulai Scan'; | |
| btnRealtime.className = realtimeActive ? 'btn-danger' : 'btn-success'; | |
| if (realtimeActive) { | |
| viewportContainer.classList.add('scanner-active'); | |
| realtimeLoop(); | |
| } else { | |
| viewportContainer.classList.remove('scanner-active'); | |
| if (realtimeTimer) clearTimeout(realtimeTimer); | |
| } | |
| } | |
| async function realtimeLoop() { | |
| if (!realtimeActive) return; | |
| await predictFromVideoFrame(); | |
| realtimeTimer = setTimeout(realtimeLoop, REALTIME_INTERVAL_MS); | |
| } | |
| async function predictFromVideoFrame() { | |
| const ctx = canvasEl.getContext('2d'); | |
| ctx.drawImage(videoEl, 0, 0, IMG_SIZE, IMG_SIZE); | |
| await runPrediction(); | |
| } | |
| // --------------------------------------------------------- | |
| // Upload & Drag-Drop Gambar | |
| // --------------------------------------------------------- | |
| function handleUpload(event) { | |
| const file = event.target.files[0]; | |
| processUploadedFile(file); | |
| } | |
| function processUploadedFile(file) { | |
| if (!file) return; | |
| if (realtimeActive) toggleRealtime(); | |
| const img = new Image(); | |
| img.onload = async () => { | |
| previewImgEl.src = img.src; | |
| previewImgEl.style.display = 'block'; | |
| videoEl.style.display = 'none'; | |
| viewportPlaceholder.style.display = 'none'; | |
| // Matikan stream kamera di belakang layar agar hemat daya | |
| if (cameraStream) { | |
| stopCameraUI(); | |
| } | |
| const ctx = canvasEl.getContext('2d'); | |
| ctx.drawImage(img, 0, 0, IMG_SIZE, IMG_SIZE); | |
| await runPrediction(); | |
| }; | |
| img.src = URL.createObjectURL(file); | |
| } | |
| // Drag & Drop event handlers | |
| const dropzone = document.getElementById('dropzone'); | |
| ['dragenter', 'dragover'].forEach(eventName => { | |
| dropzone.addEventListener(eventName, e => { | |
| e.preventDefault(); | |
| dropzone.classList.add('dragover'); | |
| }, false); | |
| }); | |
| ['dragleave', 'drop'].forEach(eventName => { | |
| dropzone.addEventListener(eventName, e => { | |
| e.preventDefault(); | |
| dropzone.classList.remove('dragover'); | |
| }, false); | |
| }); | |
| dropzone.addEventListener('drop', e => { | |
| const dt = e.dataTransfer; | |
| const files = dt.files; | |
| if (files.length > 0) { | |
| processUploadedFile(files[0]); | |
| } | |
| }); | |
| // --------------------------------------------------------- | |
| // Threshold slider control | |
| // --------------------------------------------------------- | |
| function updateThreshold(val) { | |
| CONFIDENCE_THRESHOLD = val / 100; | |
| sliderValDisplay.textContent = val + '%'; | |
| } | |
| // --------------------------------------------------------- | |
| // Model Prediction | |
| // --------------------------------------------------------- | |
| async function runPrediction() { | |
| if (!model) return; | |
| const startTime = performance.now(); | |
| const inputTensor = tf.tidy(() => { | |
| return tf.browser.fromPixels(canvasEl) | |
| .resizeBilinear([IMG_SIZE, IMG_SIZE]) | |
| .toFloat() | |
| .expandDims(0); | |
| }); | |
| const outputTensor = model.predict(inputTensor); | |
| const probs = await outputTensor.data(); | |
| const inferenceTimeMs = (performance.now() - startTime).toFixed(1); | |
| inputTensor.dispose(); | |
| outputTensor.dispose(); | |
| displayResult(probs, inferenceTimeMs); | |
| } | |
| // Format IDR Price format | |
| function formatIDR(price) { | |
| return 'Rp ' + price.toLocaleString('id-ID'); | |
| } | |
| // Get SKU Friendly Name from database Catalog | |
| function getFriendlySKUName(sku) { | |
| return PRODUCT_CATALOG[sku] ? PRODUCT_CATALOG[sku].name : sku; | |
| } | |
| function getProductPrice(sku) { | |
| return PRODUCT_CATALOG[sku] ? PRODUCT_CATALOG[sku].price : 5000; | |
| } | |
| function triggerCameraFlash() { | |
| scannerFlash.classList.add('flash-active'); | |
| setTimeout(() => { | |
| scannerFlash.classList.remove('flash-active'); | |
| }, 150); | |
| } | |
| function displayResult(probs, inferenceTimeMs) { | |
| const indexed = Array.from(probs).map((p, i) => ({ name: classNames[i], prob: p })); | |
| indexed.sort((a, b) => b.prob - a.prob); | |
| const top5 = indexed.slice(0, 5); | |
| const bestMatch = top5[0]; | |
| resultPlaceholder.style.display = 'none'; | |
| resultBox.style.display = 'block'; | |
| // Display Main Prediction | |
| const friendlyName = getFriendlySKUName(bestMatch.name); | |
| const bestProbPct = (bestMatch.prob * 100).toFixed(2); | |
| inferenceTimeEl.textContent = `⚡ ${inferenceTimeMs} ms`; | |
| // Validasi Objek Tidak Terdeteksi / Tidak Dikenali | |
| // Jika confidence di bawah 30%, kita anggap tidak terdeteksi sama sekali. | |
| const UNKNOWN_THRESHOLD = 0.30; | |
| if (bestMatch.prob < UNKNOWN_THRESHOLD) { | |
| mainPredictionEl.textContent = `⚠️ Objek Tidak Dikenali / Tidak Terdeteksi`; | |
| mainPredictionEl.style.color = 'var(--danger)'; | |
| accuracyLabel.textContent = `Confidence: ${(bestMatch.prob * 100).toFixed(1)}% (Di bawah batas minimum)`; | |
| lowConfidenceWarningEl.style.display = 'flex'; | |
| lowConfidenceWarningText.textContent = `Produk tidak terdeteksi dengan jelas. Posisikan produk lebih dekat ke kamera atau perbaiki pencahayaan.`; | |
| } | |
| // Jika confidence di antara 30% dan threshold kasir (e.g. 60%) | |
| else if (bestMatch.prob < CONFIDENCE_THRESHOLD) { | |
| mainPredictionEl.textContent = `⚠️ ${friendlyName} (${bestProbPct}%)`; | |
| mainPredictionEl.style.color = 'var(--warning)'; | |
| accuracyLabel.textContent = `Confidence: ${(bestMatch.prob * 100).toFixed(1)}%`; | |
| lowConfidenceWarningEl.style.display = 'flex'; | |
| lowConfidenceWarningText.textContent = `Confidence rendah (< ${(CONFIDENCE_THRESHOLD * 100).toFixed(0)}%). Produk tidak otomatis dimasukkan ke kasir.`; | |
| } | |
| // Jika confidence tinggi >= threshold kasir | |
| else { | |
| mainPredictionEl.textContent = `${friendlyName} (${bestProbPct}%)`; | |
| mainPredictionEl.style.color = 'var(--success)'; | |
| accuracyLabel.textContent = `Confidence: ${(bestMatch.prob * 100).toFixed(1)}%`; | |
| lowConfidenceWarningEl.style.display = 'none'; | |
| // SUCCESS SCANNED: handle POS auto cart & beep (with cooldown) | |
| const currentTime = Date.now(); | |
| if (bestMatch.name !== lastScannedItem || (currentTime - lastScanTime) > COOLDOWN_MS) { | |
| lastScannedItem = bestMatch.name; | |
| lastScanTime = currentTime; | |
| // Play Beep sound | |
| beepAudio.play().catch(e => console.log('Audio playback delayed until user interaction.')); | |
| // Trigger camera border flash effect | |
| triggerCameraFlash(); | |
| // Add to Cart State | |
| addToCart(bestMatch.name); | |
| } | |
| } | |
| // Display History logs (hanya jika confidence > UNKNOWN_THRESHOLD) | |
| if (bestMatch.prob >= UNKNOWN_THRESHOLD) { | |
| addToHistory(bestMatch.name, bestMatch.prob); | |
| } | |
| // Top-5 visualization | |
| top5ContainerEl.innerHTML = top5.map((item, idx) => { | |
| const pct = (item.prob * 100).toFixed(2); | |
| let barClass = ''; | |
| if (idx === 0) { | |
| if (item.prob >= CONFIDENCE_THRESHOLD) barClass = 'rank-success'; | |
| else if (item.prob >= UNKNOWN_THRESHOLD) barClass = 'rank-warning'; | |
| else barClass = 'rank-danger'; | |
| } | |
| return ` | |
| <div class="top-item"> | |
| <div class="top-item-name" title="${getFriendlySKUName(item.name)}">${getFriendlySKUName(item.name)}</div> | |
| <div class="top-item-bar-track"> | |
| <div class="top-item-bar-fill ${barClass}" style="width: ${pct}%"></div> | |
| </div> | |
| <div class="top-item-pct">${pct}%</div> | |
| </div> | |
| `; | |
| }).join(''); | |
| } | |
| // --------------------------------------------------------- | |
| // POS Shopping Cart simulation | |
| // --------------------------------------------------------- | |
| function addToCart(sku) { | |
| if (cart[sku]) { | |
| cart[sku].qty += 1; | |
| } else { | |
| const catalogItem = PRODUCT_CATALOG[sku] || { name: sku, price: 5000 }; | |
| cart[sku] = { | |
| name: catalogItem.name, | |
| price: catalogItem.price, | |
| qty: 1 | |
| }; | |
| } | |
| renderCart(); | |
| } | |
| function removeFromCart(sku) { | |
| delete cart[sku]; | |
| renderCart(); | |
| } | |
| function changeQty(sku, delta) { | |
| if (!cart[sku]) return; | |
| cart[sku].qty += delta; | |
| if (cart[sku].qty <= 0) { | |
| removeFromCart(sku); | |
| } else { | |
| renderCart(); | |
| } | |
| } | |
| function renderCart() { | |
| const skus = Object.keys(cart); | |
| if (skus.length === 0) { | |
| cartListEl.innerHTML = '<div class="cart-empty">Belum ada item belanja. Scan produk untuk memasukkan ke kasir.</div>'; | |
| cartTotalEl.textContent = 'Rp 0'; | |
| return; | |
| } | |
| let total = 0; | |
| cartListEl.innerHTML = skus.map(sku => { | |
| const item = cart[sku]; | |
| const subtotal = item.price * item.qty; | |
| total += subtotal; | |
| return ` | |
| <div class="cart-item"> | |
| <div class="cart-item-info"> | |
| <div class="cart-item-name" title="${item.name}">${item.name}</div> | |
| <div class="cart-item-price">${formatIDR(item.price)}</div> | |
| </div> | |
| <div class="cart-item-actions"> | |
| <button class="btn-remove" style="padding: 2px 8px;" onclick="changeQty('${sku}', -1)">-</button> | |
| <span class="cart-item-qty">${item.qty}</span> | |
| <button class="btn-remove" style="padding: 2px 8px;" onclick="changeQty('${sku}', 1)">+</button> | |
| <span class="cart-item-total">${formatIDR(subtotal)}</span> | |
| <button class="btn-remove" onclick="removeFromCart('${sku}')">×</button> | |
| </div> | |
| </div> | |
| `; | |
| }).join(''); | |
| cartTotalEl.textContent = formatIDR(total); | |
| } | |
| function checkoutCart() { | |
| const skus = Object.keys(cart); | |
| if (skus.length === 0) { | |
| alert("Keranjang belanja masih kosong!"); | |
| return; | |
| } | |
| let summary = "--- STRUK TRANSAKSI TOKIVA ---\n\n"; | |
| let total = 0; | |
| skus.forEach(sku => { | |
| const item = cart[sku]; | |
| const subtotal = item.price * item.qty; | |
| total += subtotal; | |
| summary += `${item.name}\n ${item.qty} x ${formatIDR(item.price)} = ${formatIDR(subtotal)}\n`; | |
| }); | |
| summary += `\n------------------------------\n`; | |
| summary += `TOTAL BELANJA: ${formatIDR(total)}\n\nTerima kasih telah berbelanja di Tokiva!`; | |
| alert(summary); | |
| // Clear cart | |
| for (const sku in cart) { | |
| delete cart[sku]; | |
| } | |
| renderCart(); | |
| } | |
| // --------------------------------------------------------- | |
| // Scan History Log | |
| // --------------------------------------------------------- | |
| function addToHistory(sku, confidence) { | |
| const now = new Date(); | |
| const timeStr = now.toTimeString().split(' ')[0]; // HH:MM:SS | |
| const friendlyName = getFriendlySKUName(sku); | |
| const isHighConf = confidence >= CONFIDENCE_THRESHOLD; | |
| // Insert to the front of history | |
| scanHistory.unshift({ | |
| name: friendlyName, | |
| time: timeStr, | |
| highConf: isHighConf, | |
| pct: (confidence * 100).toFixed(0) + '%' | |
| }); | |
| // Limit history items to last 8 | |
| if (scanHistory.length > 8) { | |
| scanHistory.pop(); | |
| } | |
| renderHistory(); | |
| } | |
| function renderHistory() { | |
| if (scanHistory.length === 0) { | |
| historyListEl.innerHTML = '<div class="history-empty">Belum ada riwayat pemindaian.</div>'; | |
| return; | |
| } | |
| historyListEl.innerHTML = scanHistory.map(item => { | |
| const confClass = item.highConf ? 'high-conf' : ''; | |
| return ` | |
| <div class="history-item ${confClass}"> | |
| <span class="history-item-name" title="${item.name}">${item.name}</span> | |
| <div style="display: flex; gap: 8px; align-items: center;"> | |
| <span class="badge" style="background: rgba(255,255,255,0.03);">${item.pct}</span> | |
| <span class="history-item-time">${item.time}</span> | |
| </div> | |
| </div> | |
| `; | |
| }).join(''); | |
| } | |
| </script> | |
| </body> | |
| </html> | |