Download frontend/index.html from tktejask/leafscan: direct link, hf CLI and curl.
- Browser
- Download file 32.2 kB
-
https://huggingface.co/spaces/tktejask/leafscan/resolve/main/frontend/index.html
- Command line
-
hf download hf://spaces/tktejask/leafscan/frontend/index.html
-
curl -L -o index.html https://huggingface.co/spaces/tktejask/leafscan/resolve/main/frontend/index.html
32.2 kB
| <html lang="en"> | |
| <head> | |
| <meta charset="UTF-8"/> | |
| <meta name="viewport" content="width=device-width,initial-scale=1.0"/> | |
| <title>LeafScan β Plant Disease AI</title> | |
| <link href="https://fonts.googleapis.com/css2?family=Instrument+Serif:ital@0;1&family=Inter:wght@300;400;500&display=swap" rel="stylesheet"/> | |
| <style> | |
| *,*::before,*::after{box-sizing:border-box;margin:0;padding:0} | |
| :root{ | |
| --g1:#0d1f12;--g2:#132918;--g3:#1a3620; | |
| --leaf:#2d6a4f;--leaf-l:#52b788;--leaf-xl:#95d5b2; | |
| --gold:#d4a017;--rust:#c0392b;--cream:#f8f4ec; | |
| --text:#e8f0e9;--muted:#7a9e82;--border:rgba(82,183,136,0.18); | |
| --r:14px;--r-lg:22px; | |
| } | |
| html{font-size:16px;scroll-behavior:smooth} | |
| body{ | |
| font-family:'Inter',sans-serif; | |
| background:var(--g1); | |
| color:var(--text); | |
| min-height:100vh; | |
| overflow-x:hidden; | |
| } | |
| /* ββ Animated background ββ */ | |
| .bg-canvas{ | |
| position:fixed;inset:0;z-index:0; | |
| background: | |
| radial-gradient(ellipse 60% 50% at 15% 25%,rgba(45,106,79,0.25) 0%,transparent 60%), | |
| radial-gradient(ellipse 50% 40% at 85% 70%,rgba(82,183,136,0.12) 0%,transparent 60%), | |
| linear-gradient(160deg,var(--g1) 0%,var(--g2) 50%,var(--g3) 100%); | |
| } | |
| .particle{ | |
| position:fixed;border-radius:50%;pointer-events:none;z-index:0; | |
| animation:float linear infinite; | |
| background:rgba(82,183,136,0.15); | |
| } | |
| @keyframes float{ | |
| 0%{transform:translateY(100vh) rotate(0deg);opacity:0} | |
| 10%{opacity:1} | |
| 90%{opacity:0.6} | |
| 100%{transform:translateY(-20vh) rotate(720deg);opacity:0} | |
| } | |
| /* ββ Layout ββ */ | |
| .wrap{position:relative;z-index:1;max-width:860px;margin:0 auto;padding:0 1.5rem 5rem} | |
| /* ββ Header ββ */ | |
| header{text-align:center;padding:4rem 1rem 3rem} | |
| .logo-wrap{ | |
| display:inline-flex;align-items:center;gap:12px; | |
| margin-bottom:1.5rem; | |
| animation:fadeDown 0.8s ease both; | |
| } | |
| .logo-icon{ | |
| width:52px;height:52px; | |
| background:linear-gradient(135deg,var(--leaf),var(--leaf-l)); | |
| border-radius:14px; | |
| display:flex;align-items:center;justify-content:center; | |
| font-size:26px; | |
| animation:pulse-glow 3s ease-in-out infinite; | |
| } | |
| @keyframes pulse-glow{ | |
| 0%,100%{box-shadow:0 0 0 0 rgba(82,183,136,0)} | |
| 50%{box-shadow:0 0 28px 6px rgba(82,183,136,0.3)} | |
| } | |
| .logo-text{ | |
| font-family:'Instrument Serif',serif; | |
| font-size:2.2rem; | |
| color:var(--leaf-xl); | |
| letter-spacing:-0.01em; | |
| } | |
| h1{ | |
| font-family:'Instrument Serif',serif; | |
| font-size:clamp(2.4rem,6vw,4rem); | |
| font-weight:400; | |
| line-height:1.1; | |
| letter-spacing:-0.02em; | |
| animation:fadeDown 0.8s 0.1s ease both; | |
| } | |
| h1 em{color:var(--leaf-l);font-style:italic} | |
| .tagline{ | |
| margin-top:1rem; | |
| font-size:1rem; | |
| font-weight:300; | |
| color:var(--muted); | |
| letter-spacing:0.04em; | |
| animation:fadeDown 0.8s 0.2s ease both; | |
| } | |
| .stats-row{ | |
| display:flex;gap:1.5rem;justify-content:center;flex-wrap:wrap; | |
| margin-top:2rem; | |
| animation:fadeDown 0.8s 0.3s ease both; | |
| } | |
| .stat{ | |
| display:flex;flex-direction:column;align-items:center; | |
| padding:0.75rem 1.4rem; | |
| border:0.5px solid var(--border); | |
| border-radius:100px; | |
| background:rgba(45,106,79,0.08); | |
| backdrop-filter:blur(8px); | |
| } | |
| .stat-n{font-size:1.3rem;font-weight:500;color:var(--leaf-l)} | |
| .stat-l{font-size:0.7rem;letter-spacing:0.08em;text-transform:uppercase;color:var(--muted);margin-top:2px} | |
| @keyframes fadeDown{ | |
| from{opacity:0;transform:translateY(-18px)} | |
| to{opacity:1;transform:translateY(0)} | |
| } | |
| /* ββ Upload zone ββ */ | |
| #drop-zone{ | |
| border:1.5px dashed var(--border); | |
| border-radius:var(--r-lg); | |
| padding:3.5rem 2rem; | |
| text-align:center; | |
| cursor:pointer; | |
| transition:all 0.35s cubic-bezier(0.25,0.8,0.25,1); | |
| background:rgba(19,41,24,0.6); | |
| backdrop-filter:blur(12px); | |
| position:relative;overflow:hidden; | |
| animation:fadeUp 0.8s 0.4s ease both; | |
| } | |
| #drop-zone::before{ | |
| content:'';position:absolute;inset:0; | |
| background:radial-gradient(ellipse 80% 60% at 50% 50%,rgba(82,183,136,0.06),transparent); | |
| opacity:0;transition:opacity 0.3s; | |
| } | |
| #drop-zone:hover,#drop-zone.drag-over{ | |
| border-color:var(--leaf-l); | |
| transform:translateY(-3px); | |
| box-shadow:0 20px 60px rgba(45,106,79,0.25),0 0 0 4px rgba(82,183,136,0.08); | |
| } | |
| #drop-zone:hover::before,#drop-zone.drag-over::before{opacity:1} | |
| #drop-zone.drag-over{background:rgba(45,106,79,0.15)} | |
| .upload-icon-wrap{ | |
| width:80px;height:80px; | |
| margin:0 auto 1.5rem; | |
| border-radius:50%; | |
| background:rgba(82,183,136,0.1); | |
| border:1px solid var(--border); | |
| display:flex;align-items:center;justify-content:center; | |
| font-size:2.2rem; | |
| transition:transform 0.3s; | |
| } | |
| #drop-zone:hover .upload-icon-wrap{transform:scale(1.1) rotate(-5deg)} | |
| .drop-title{ | |
| font-family:'Instrument Serif',serif; | |
| font-size:1.5rem;color:var(--text); | |
| margin-bottom:0.5rem; | |
| } | |
| .drop-sub{font-size:0.88rem;color:var(--muted);margin-bottom:1.8rem;font-weight:300} | |
| .pick-btn{ | |
| display:inline-flex;align-items:center;gap:8px; | |
| background:linear-gradient(135deg,var(--leaf),#1a5c3e); | |
| color:#d4f5e2;border:none; | |
| padding:0.75rem 2.2rem; | |
| border-radius:100px; | |
| font-family:'Inter',sans-serif; | |
| font-size:0.9rem;font-weight:500; | |
| cursor:pointer; | |
| transition:all 0.25s; | |
| box-shadow:0 4px 20px rgba(45,106,79,0.4); | |
| } | |
| .pick-btn:hover{transform:translateY(-2px);box-shadow:0 8px 28px rgba(45,106,79,0.5)} | |
| .pick-btn:active{transform:translateY(0)} | |
| #fileInput{display:none} | |
| /* ββ URL row ββ */ | |
| .url-row{ | |
| display:flex;gap:10px;margin-top:1rem; | |
| animation:fadeUp 0.8s 0.5s ease both; | |
| } | |
| .url-input{ | |
| flex:1; | |
| background:rgba(19,41,24,0.7); | |
| border:0.5px solid var(--border); | |
| border-radius:100px; | |
| padding:0.75rem 1.4rem; | |
| font-family:'Inter',sans-serif; | |
| font-size:0.88rem; | |
| color:var(--text); | |
| outline:none; | |
| transition:all 0.2s; | |
| } | |
| .url-input::placeholder{color:var(--muted)} | |
| .url-input:focus{border-color:var(--leaf-l);box-shadow:0 0 0 3px rgba(82,183,136,0.12)} | |
| .url-btn{ | |
| background:rgba(45,106,79,0.15); | |
| border:0.5px solid var(--border); | |
| color:var(--leaf-xl); | |
| padding:0.75rem 1.4rem; | |
| border-radius:100px; | |
| font-family:'Inter',sans-serif; | |
| font-size:0.88rem;font-weight:500; | |
| cursor:pointer; | |
| transition:all 0.2s; | |
| white-space:nowrap; | |
| } | |
| .url-btn:hover{background:rgba(45,106,79,0.3);border-color:var(--leaf-l)} | |
| /* ββ Preview ββ */ | |
| #preview-section{display:none;margin-top:1.5rem;text-align:center} | |
| .preview-frame{ | |
| display:inline-block;position:relative; | |
| border-radius:var(--r);overflow:hidden; | |
| border:1px solid var(--border); | |
| box-shadow:0 16px 48px rgba(0,0,0,0.4); | |
| animation:scaleIn 0.4s cubic-bezier(0.34,1.56,0.64,1); | |
| } | |
| @keyframes scaleIn{from{opacity:0;transform:scale(0.85)}to{opacity:1;transform:scale(1)}} | |
| #preview-img{max-height:340px;max-width:100%;display:block;object-fit:cover} | |
| .remove-btn{ | |
| position:absolute;top:10px;right:10px; | |
| background:rgba(0,0,0,0.55); | |
| color:#fff;border:none; | |
| width:32px;height:32px;border-radius:50%; | |
| cursor:pointer;font-size:16px; | |
| display:flex;align-items:center;justify-content:center; | |
| transition:background 0.2s;backdrop-filter:blur(4px); | |
| } | |
| .remove-btn:hover{background:rgba(192,57,43,0.75)} | |
| /* ββ Analyze btn ββ */ | |
| #analyze-btn{ | |
| display:none;width:100%;margin-top:1.2rem; | |
| background:linear-gradient(135deg,var(--leaf-l),var(--leaf)); | |
| color:#fff;border:none; | |
| padding:1.1rem;border-radius:var(--r); | |
| font-family:'Instrument Serif',serif; | |
| font-size:1.25rem;letter-spacing:0.01em; | |
| cursor:pointer; | |
| transition:all 0.3s; | |
| position:relative;overflow:hidden; | |
| box-shadow:0 8px 30px rgba(45,106,79,0.45); | |
| } | |
| #analyze-btn::after{ | |
| content:'';position:absolute; | |
| top:0;left:-100%;width:60%;height:100%; | |
| background:linear-gradient(90deg,transparent,rgba(255,255,255,0.15),transparent); | |
| transform:skewX(-20deg); | |
| transition:left 0.5s; | |
| } | |
| #analyze-btn:hover::after{left:150%} | |
| #analyze-btn:hover{transform:translateY(-2px);box-shadow:0 14px 40px rgba(45,106,79,0.6)} | |
| #analyze-btn:disabled{opacity:0.6;cursor:not-allowed;transform:none} | |
| /* ββ Loading ββ */ | |
| #loading{display:none;text-align:center;padding:3rem 1rem} | |
| .scan-ring{ | |
| width:72px;height:72px; | |
| border:2px solid rgba(82,183,136,0.15); | |
| border-top-color:var(--leaf-l); | |
| border-radius:50%; | |
| margin:0 auto 1.4rem; | |
| animation:spin 0.8s linear infinite; | |
| } | |
| @keyframes spin{to{transform:rotate(360deg)}} | |
| .scan-text{ | |
| font-family:'Instrument Serif',serif; | |
| font-size:1.1rem;color:var(--leaf-xl);font-style:italic; | |
| } | |
| .scan-sub{font-size:0.8rem;color:var(--muted);margin-top:6px} | |
| .scan-dots{display:inline-block;animation:dots 1.4s steps(4,end) infinite} | |
| @keyframes dots{0%{content:''}25%{content:'.'}50%{content:'..'}75%{content:'...'}} | |
| /* ββ Error ββ */ | |
| #error-box{ | |
| display:none; | |
| background:rgba(192,57,43,0.12); | |
| border:0.5px solid rgba(192,57,43,0.4); | |
| border-radius:var(--r); | |
| padding:1rem 1.25rem; | |
| font-size:0.88rem; | |
| color:#f19e8e; | |
| margin-top:1rem; | |
| animation:fadeUp 0.3s ease; | |
| } | |
| /* ββ Result card ββ */ | |
| #result-section{display:none} | |
| .result-main{ | |
| background:rgba(13,31,18,0.85); | |
| border:0.5px solid var(--border); | |
| border-radius:var(--r-lg); | |
| overflow:hidden; | |
| backdrop-filter:blur(16px); | |
| animation:fadeUp 0.5s cubic-bezier(0.25,0.8,0.25,1); | |
| margin-top:1.5rem; | |
| } | |
| @keyframes fadeUp{ | |
| from{opacity:0;transform:translateY(24px)} | |
| to{opacity:1;transform:translateY(0)} | |
| } | |
| /* Not-a-leaf */ | |
| .not-leaf-banner{ | |
| background:rgba(192,57,43,0.1); | |
| border-left:3px solid var(--rust); | |
| padding:2.2rem; | |
| display:flex;align-items:flex-start;gap:1.4rem; | |
| } | |
| .nl-icon{ | |
| width:52px;height:52px;flex-shrink:0; | |
| background:rgba(192,57,43,0.15); | |
| border-radius:50%; | |
| display:flex;align-items:center;justify-content:center; | |
| font-size:1.5rem; | |
| } | |
| .nl-title{ | |
| font-family:'Instrument Serif',serif; | |
| font-size:1.6rem;color:#e8857a;margin-bottom:0.4rem; | |
| } | |
| .nl-msg{font-size:0.9rem;color:#b07068;line-height:1.7} | |
| /* Disease result */ | |
| .result-hero{ | |
| padding:2rem 2rem 1.5rem; | |
| border-bottom:0.5px solid var(--border); | |
| display:flex;align-items:flex-start;gap:1.2rem; | |
| } | |
| .sev-pill{ | |
| display:inline-flex;align-items:center;gap:6px; | |
| padding:5px 14px;border-radius:100px; | |
| font-size:0.72rem;font-weight:500; | |
| text-transform:uppercase;letter-spacing:0.06em; | |
| flex-shrink:0;margin-top:6px; | |
| } | |
| .sev-none{background:rgba(37,99,235,0.12);color:#60a5fa} | |
| .sev-mod{background:rgba(234,179,8,0.12);color:#fbbf24} | |
| .sev-high{background:rgba(234,88,12,0.15);color:#fb923c} | |
| .sev-sev{background:rgba(192,57,43,0.15);color:#f87171} | |
| .sev-na{background:rgba(82,183,136,0.1);color:var(--muted)} | |
| .result-titles{flex:1} | |
| .plant-label{ | |
| font-size:0.72rem;text-transform:uppercase;letter-spacing:0.1em; | |
| color:var(--muted);margin-bottom:4px; | |
| } | |
| .disease-title{ | |
| font-family:'Instrument Serif',serif; | |
| font-size:2rem;line-height:1.15;color:var(--text); | |
| margin-bottom:0.8rem; | |
| } | |
| .conf-row{display:flex;align-items:center;gap:12px} | |
| .conf-track{ | |
| flex:1;height:5px; | |
| background:rgba(82,183,136,0.12); | |
| border-radius:3px;overflow:hidden; | |
| } | |
| /* FIX: confidence bar changes color based on confidence level */ | |
| .conf-fill{ | |
| height:100%;border-radius:3px; | |
| width:0%;transition:width 1.2s cubic-bezier(0.25,0.8,0.25,1),background 0.5s; | |
| } | |
| .conf-fill.conf-high{background:linear-gradient(90deg,var(--leaf),var(--leaf-l))} | |
| .conf-fill.conf-mid{background:linear-gradient(90deg,#b45309,#fbbf24)} | |
| .conf-fill.conf-low{background:linear-gradient(90deg,var(--rust),#f87171)} | |
| .conf-pct{ | |
| font-size:0.88rem;font-weight:500; | |
| color:var(--leaf-xl);white-space:nowrap; | |
| } | |
| /* FIX: warning banner inside result */ | |
| .warn-banner{ | |
| background:rgba(234,179,8,0.08); | |
| border-left:3px solid #fbbf24; | |
| padding:0.9rem 1.4rem; | |
| font-size:0.85rem; | |
| color:#fbbf24; | |
| line-height:1.6; | |
| display:flex;gap:10px;align-items:flex-start; | |
| } | |
| .warn-banner .warn-icon{flex-shrink:0;font-size:1rem;margin-top:1px} | |
| /* Info grid */ | |
| .info-grid{ | |
| display:grid;grid-template-columns:1fr 1fr; | |
| } | |
| .info-cell{ | |
| padding:1.4rem 2rem; | |
| border-right:0.5px solid var(--border); | |
| border-bottom:0.5px solid var(--border); | |
| } | |
| .info-cell:nth-child(2n){border-right:none} | |
| .info-cell.span2{grid-column:1/-1;border-right:none} | |
| .info-lbl{ | |
| font-size:0.68rem;text-transform:uppercase; | |
| letter-spacing:0.1em;color:var(--muted); | |
| font-weight:500;margin-bottom:0.5rem; | |
| } | |
| .info-val{font-size:0.9rem;color:var(--text);line-height:1.65} | |
| /* Top 5 */ | |
| .top5-card{ | |
| background:rgba(13,31,18,0.85); | |
| border:0.5px solid var(--border); | |
| border-radius:var(--r-lg); | |
| padding:1.6rem 2rem; | |
| margin-top:1rem; | |
| backdrop-filter:blur(16px); | |
| animation:fadeUp 0.5s 0.1s cubic-bezier(0.25,0.8,0.25,1) both; | |
| } | |
| .top5-head{ | |
| font-size:0.72rem;text-transform:uppercase; | |
| letter-spacing:0.1em;color:var(--muted); | |
| margin-bottom:1.2rem;font-weight:500; | |
| } | |
| .t5-row{display:flex;align-items:center;gap:12px;margin-bottom:10px} | |
| .t5-rank{ | |
| width:22px;height:22px;border-radius:50%; | |
| background:rgba(82,183,136,0.12); | |
| color:var(--muted); | |
| display:flex;align-items:center;justify-content:center; | |
| font-size:0.7rem;font-weight:500;flex-shrink:0; | |
| } | |
| .t5-rank.gold{background:rgba(212,160,23,0.15);color:var(--gold)} | |
| .t5-name{ | |
| flex:1;font-size:0.82rem;color:var(--text); | |
| white-space:nowrap;overflow:hidden;text-overflow:ellipsis; | |
| } | |
| .t5-track{ | |
| width:110px;height:4px; | |
| background:rgba(82,183,136,0.1); | |
| border-radius:2px;overflow:hidden;flex-shrink:0; | |
| } | |
| .t5-bar{ | |
| height:100%;border-radius:2px; | |
| background:var(--leaf-l); | |
| width:0%;transition:width 1s ease; | |
| } | |
| .t5-pct{ | |
| width:42px;text-align:right; | |
| font-size:0.78rem;font-weight:500;color:var(--muted); | |
| } | |
| /* Reset btn */ | |
| .reset-btn{ | |
| display:flex;align-items:center;justify-content:center;gap:8px; | |
| width:100%;margin-top:1rem; | |
| background:transparent; | |
| border:0.5px solid var(--border); | |
| color:var(--leaf-xl); | |
| padding:0.9rem;border-radius:var(--r); | |
| font-family:'Inter',sans-serif; | |
| font-size:0.9rem;font-weight:500; | |
| cursor:pointer; | |
| transition:all 0.2s; | |
| animation:fadeUp 0.5s 0.2s ease both; | |
| } | |
| .reset-btn:hover{background:rgba(45,106,79,0.1);border-color:var(--leaf-l)} | |
| /* ββ History (NEW) ββ */ | |
| .history-actions{ | |
| display:flex; | |
| gap:10px; | |
| margin-top:1rem; | |
| } | |
| .history-btn{ | |
| flex:1; | |
| display:flex;align-items:center;justify-content:center;gap:8px; | |
| background:rgba(45,106,79,0.15); | |
| border:0.5px solid var(--border); | |
| color:var(--leaf-xl); | |
| padding:0.9rem;border-radius:var(--r); | |
| font-family:'Inter',sans-serif; | |
| font-size:0.9rem;font-weight:500; | |
| cursor:pointer; | |
| transition:all 0.2s; | |
| animation:fadeUp 0.5s 0.25s ease both; | |
| } | |
| .history-btn:hover{background:rgba(45,106,79,0.3);border-color:var(--leaf-l)} | |
| .history-btn:disabled{opacity:0.55;cursor:not-allowed} | |
| #history-section{ | |
| margin-top:2.2rem; | |
| padding-top:1.2rem; | |
| } | |
| .history-card{ | |
| background:rgba(13,31,18,0.85); | |
| border:0.5px solid var(--border); | |
| border-radius:var(--r-lg); | |
| backdrop-filter:blur(16px); | |
| overflow:hidden; | |
| margin-top:1rem; | |
| } | |
| .history-head{ | |
| padding:1.2rem 1.4rem; | |
| border-bottom:0.5px solid var(--border); | |
| display:flex;align-items:center;justify-content:space-between;gap:12px; | |
| } | |
| .history-title{ | |
| font-family:'Instrument Serif',serif; | |
| font-size:1.4rem; | |
| color:var(--leaf-xl); | |
| } | |
| .history-sub{ | |
| font-size:0.8rem; | |
| color:var(--muted); | |
| margin-top:4px; | |
| } | |
| .history-clear{ | |
| background:transparent; | |
| border:0.5px solid var(--border); | |
| color:var(--muted); | |
| padding:0.55rem 0.9rem; | |
| border-radius:100px; | |
| cursor:pointer; | |
| transition:all 0.2s; | |
| font-size:0.82rem; | |
| } | |
| .history-clear:hover{border-color:var(--leaf-l);color:var(--leaf-xl);background:rgba(45,106,79,0.1)} | |
| .history-list{padding:1rem 1.4rem 1.4rem} | |
| .hitem{ | |
| display:flex; | |
| gap:12px; | |
| padding:0.9rem 0; | |
| border-bottom:0.5px solid var(--border); | |
| } | |
| .hitem:last-child{border-bottom:none;padding-bottom:0} | |
| .hthumb{ | |
| width:64px;height:64px; | |
| border-radius:12px; | |
| border:0.5px solid var(--border); | |
| object-fit:cover; | |
| flex-shrink:0; | |
| box-shadow:0 10px 25px rgba(0,0,0,0.35); | |
| } | |
| .hmeta{flex:1;min-width:0} | |
| .htitle{ | |
| font-size:0.92rem; | |
| color:var(--text); | |
| font-weight:500; | |
| white-space:nowrap;overflow:hidden;text-overflow:ellipsis; | |
| } | |
| .hrow{display:flex;flex-wrap:wrap;gap:10px;margin-top:6px} | |
| .hpill{ | |
| font-size:0.72rem; | |
| letter-spacing:0.02em; | |
| color:var(--muted); | |
| border:0.5px solid var(--border); | |
| padding:4px 10px; | |
| border-radius:100px; | |
| background:rgba(45,106,79,0.08); | |
| } | |
| .hactions{display:flex;flex-direction:column;gap:8px;flex-shrink:0} | |
| .hbtn{ | |
| background:rgba(45,106,79,0.15); | |
| border:0.5px solid var(--border); | |
| color:var(--leaf-xl); | |
| padding:0.55rem 0.9rem; | |
| border-radius:12px; | |
| cursor:pointer; | |
| transition:all 0.2s; | |
| font-size:0.82rem; | |
| white-space:nowrap; | |
| } | |
| .hbtn:hover{background:rgba(45,106,79,0.3);border-color:var(--leaf-l)} | |
| /* ββ Responsive ββ */ | |
| @media(max-width:540px){ | |
| .info-grid{grid-template-columns:1fr} | |
| .info-cell{border-right:none} | |
| .info-cell.span2{grid-column:1} | |
| .result-hero{flex-direction:column;gap:0.8rem} | |
| .url-row{flex-direction:column} | |
| .stats-row{gap:0.8rem} | |
| .history-actions{flex-direction:column} | |
| .hactions{flex-direction:row} | |
| } | |
| </style> | |
| </head> | |
| <body> | |
| <!-- Particles --> | |
| <div class="bg-canvas"></div> | |
| <div id="particles"></div> | |
| <div class="wrap"> | |
| <!-- Header --> | |
| <header> | |
| <div class="logo-wrap"> | |
| <div class="logo-icon">πΏ</div> | |
| <span class="logo-text">LeafScan</span> | |
| </div> | |
| <h1>Detect leaf disease<br>with <em>AI precision</em></h1> | |
| <p class="tagline">EfficientNetB3 Β· 38 disease classes Β· TTA-enhanced inference</p> | |
| <div class="stats-row"> | |
| <div class="stat"><span class="stat-n">38</span><span class="stat-l">Diseases</span></div> | |
| <div class="stat"><span class="stat-n">54k+</span><span class="stat-l">Training images</span></div> | |
| <div class="stat"><span class="stat-n">~96%</span><span class="stat-l">Accuracy</span></div> | |
| <div class="stat"><span class="stat-n">TTAΓ6</span><span class="stat-l">Augmented</span></div> | |
| </div> | |
| </header> | |
| <!-- Error --> | |
| <div id="error-box"></div> | |
| <!-- Upload zone --> | |
| <div id="drop-zone"> | |
| <div class="upload-icon-wrap">π±</div> | |
| <div class="drop-title">Drop a leaf image here</div> | |
| <p class="drop-sub">JPG Β· PNG Β· WebP Β· up to 10 MB Β· any camera Β· any angle</p> | |
| <button type="button" class="pick-btn" id="pick-btn" style="display:inline-flex;align-items:center;gap:8px;"> | |
| π· Choose image | |
| </button> | |
| <input type="file" id="fileInput" accept="image/*" style="display:none"/> | |
| </div> | |
| <!-- URL --> | |
| <div class="url-row"> | |
| <input type="url" class="url-input" id="url-input" placeholder="Or paste an image URL: https://..."/> | |
| <button class="url-btn" id="url-btn">Analyze URL</button> | |
| </div> | |
| <!-- Preview --> | |
| <div id="preview-section"> | |
| <div class="preview-frame"> | |
| <img id="preview-img" src="" alt="Preview"/> | |
| <button class="remove-btn" onclick="resetAll()" title="Remove">β</button> | |
| </div> | |
| </div> | |
| <!-- Analyze --> | |
| <button id="analyze-btn" onclick="analyze()">π¬ Analyze this leaf</button> | |
| <!-- Loading --> | |
| <div id="loading"> | |
| <div class="scan-ring"></div> | |
| <div class="scan-text">Scanning leaf<span class="scan-dots"></span></div> | |
| <div class="scan-sub">Running EfficientNetB3 Β· TTA Γ6 inference</div> | |
| </div> | |
| <!-- Result --> | |
| <div id="result-section"> | |
| <div class="result-main" id="result-main"></div> | |
| <div class="top5-card" id="top5-card"></div> | |
| <!-- NEW: history buttons --> | |
| <div class="history-actions"> | |
| <button class="history-btn" id="view-history-btn" type="button" onclick="scrollToHistory()">π View past results</button> | |
| <button class="history-btn" id="save-hint-btn" type="button" disabled title="History saves automatically after each analysis.">β Saved to history</button> | |
| </div> | |
| <button class="reset-btn" onclick="resetAll()">πΏ Analyze another leaf</button> | |
| </div> | |
| <!-- NEW: History section --> | |
| <section id="history-section"> | |
| <div class="history-card"> | |
| <div class="history-head"> | |
| <div> | |
| <div class="history-title">Past results</div> | |
| <div class="history-sub">Stored in this browser (localStorage). Click βReviewβ to re-open an older result.</div> | |
| </div> | |
| <button class="history-clear" type="button" onclick="clearHistory()">Clear</button> | |
| </div> | |
| <div class="history-list" id="history-list"> | |
| <!-- injected --> | |
| </div> | |
| </div> | |
| </section> | |
| </div><!-- /wrap --> | |
| <script> | |
| const API = window.location.origin; | |
| let blob = null, imgUrl = null; | |
| /* ββ History storage (NEW) ββ */ | |
| const HISTORY_KEY = 'leafscan_history_v1'; | |
| const HISTORY_MAX = 25; | |
| function loadHistory(){ | |
| try{ | |
| const raw = localStorage.getItem(HISTORY_KEY); | |
| const arr = raw ? JSON.parse(raw) : []; | |
| return Array.isArray(arr) ? arr : []; | |
| }catch{ | |
| return []; | |
| } | |
| } | |
| function saveHistory(arr){ | |
| try{ | |
| localStorage.setItem(HISTORY_KEY, JSON.stringify(arr)); | |
| }catch(e){ | |
| // ignore quota errors | |
| } | |
| } | |
| function addToHistory(entry){ | |
| const hist = loadHistory(); | |
| hist.unshift(entry); | |
| const trimmed = hist.slice(0, HISTORY_MAX); | |
| saveHistory(trimmed); | |
| renderHistory(); | |
| } | |
| function clearHistory(){ | |
| localStorage.removeItem(HISTORY_KEY); | |
| renderHistory(); | |
| } | |
| function scrollToHistory(){ | |
| const el = document.getElementById('history-section'); | |
| if(el) el.scrollIntoView({behavior:'smooth', block:'start'}); | |
| } | |
| function renderHistory(){ | |
| const list = document.getElementById('history-list'); | |
| const hist = loadHistory(); | |
| // disable view button if empty | |
| const viewBtn = document.getElementById('view-history-btn'); | |
| if(viewBtn) viewBtn.disabled = hist.length === 0; | |
| if(!hist.length){ | |
| list.innerHTML = `<div class="info-val" style="padding:0.6rem 0;color:var(--muted);"> | |
| No past results yet. Run an analysis and it will appear here. | |
| </div>`; | |
| return; | |
| } | |
| list.innerHTML = hist.map((h, idx) => { | |
| const title = h.is_leaf | |
| ? `${h.plant} β ${h.disease}` | |
| : `Not a leaf`; | |
| const when = new Date(h.ts).toLocaleString(); | |
| const conf = (h.confidence !== undefined && h.confidence !== null) | |
| ? `${Math.round(h.confidence*100)}%` | |
| : ''; | |
| const sev = h.severity || ''; | |
| const ms = (h.inference_ms !== undefined && h.inference_ms !== null) | |
| ? `${Math.round(h.inference_ms)} ms` | |
| : ''; | |
| return `<div class="hitem"> | |
| <img class="hthumb" src="${h.thumbnail || ''}" alt="thumbnail"/> | |
| <div class="hmeta"> | |
| <div class="htitle" title="${escHtml(title)}">${escHtml(title)}</div> | |
| <div class="hrow"> | |
| <span class="hpill">π ${escHtml(when)}</span> | |
| ${conf ? `<span class="hpill">π― ${escHtml(conf)}</span>` : ``} | |
| ${sev ? `<span class="hpill">β ${escHtml(sev)}</span>` : ``} | |
| ${ms ? `<span class="hpill">β± ${escHtml(ms)}</span>` : ``} | |
| </div> | |
| </div> | |
| <div class="hactions"> | |
| <button class="hbtn" type="button" onclick="reviewHistory(${idx})">Review</button> | |
| <button class="hbtn" type="button" onclick="deleteHistory(${idx})">Delete</button> | |
| </div> | |
| </div>`; | |
| }).join(''); | |
| } | |
| function deleteHistory(idx){ | |
| const hist = loadHistory(); | |
| hist.splice(idx, 1); | |
| saveHistory(hist); | |
| renderHistory(); | |
| } | |
| function reviewHistory(idx){ | |
| const hist = loadHistory(); | |
| const h = hist[idx]; | |
| if(!h) return; | |
| // Show result section and render the saved result | |
| clearError(); | |
| document.getElementById('result-section').style.display='block'; | |
| document.getElementById('loading').style.display='none'; | |
| // ensure upload area isn't hiding important actions | |
| // (do not alter existing flow too much) | |
| renderResult(h, h.inference_ms); | |
| // scroll back up to results | |
| const res = document.getElementById('result-section'); | |
| if(res) res.scrollIntoView({behavior:'smooth', block:'start'}); | |
| } | |
| /* ββ Particles ββ */ | |
| (function(){ | |
| const c = document.getElementById('particles'); | |
| for(let i=0;i<18;i++){ | |
| const p = document.createElement('div'); | |
| p.className='particle'; | |
| const s = Math.random()*12+4; | |
| p.style.cssText=`width:${s}px;height:${s}px;left:${Math.random()*100}%;animation-duration:${Math.random()*18+12}s;animation-delay:${Math.random()*-20}s;opacity:${Math.random()*0.4+0.1}`; | |
| c.appendChild(p); | |
| } | |
| })(); | |
| /* ββ File input ββ */ | |
| document.getElementById('fileInput').onchange=e=>{ | |
| const f=e.target.files[0]; if(f)handleFile(f); | |
| }; | |
| /* ββ Drag & drop ββ */ | |
| const dz=document.getElementById('drop-zone'); | |
| // Click-to-upload support (fix Choose image) | |
| document.getElementById('pick-btn').onclick = (e) => { | |
| e.stopPropagation(); | |
| document.getElementById('fileInput').click(); | |
| }; | |
| // Also allow clicking anywhere in the drop zone to open picker | |
| dz.onclick = (e) => { | |
| // If user clicked the button, the button handler will run | |
| if (e.target && e.target.id === 'pick-btn') return; | |
| document.getElementById('fileInput').click(); | |
| }; | |
| dz.ondragover=e=>{e.preventDefault();dz.classList.add('drag-over')}; | |
| dz.ondragleave=()=>dz.classList.remove('drag-over'); | |
| dz.ondrop=e=>{ | |
| e.preventDefault();dz.classList.remove('drag-over'); | |
| const f=e.dataTransfer.files[0]; | |
| if(f&&f.type.startsWith('image/'))handleFile(f); | |
| else showError('Please drop an image file.'); | |
| }; | |
| /* ββ URL ββ */ | |
| document.getElementById('url-btn').onclick=()=>{ | |
| const u=document.getElementById('url-input').value.trim(); | |
| if(!u){showError('Enter a URL first.');return} | |
| if(!u.startsWith('http')){showError('URL must start with http://');return} | |
| handleUrl(u); | |
| }; | |
| document.getElementById('url-input').onkeydown=e=>{if(e.key==='Enter')document.getElementById('url-btn').click()}; | |
| function handleFile(f){ | |
| clearError();resetResult();blob=f;imgUrl=null; | |
| const r=new FileReader(); | |
| r.onload=e=>showPreview(e.target.result); | |
| r.readAsDataURL(f); | |
| } | |
| function handleUrl(u){ | |
| clearError();resetResult();imgUrl=u;blob=null; | |
| showPreview(u); | |
| } | |
| function showPreview(src){ | |
| document.getElementById('preview-img').src=src; | |
| document.getElementById('preview-section').style.display='block'; | |
| document.getElementById('analyze-btn').style.display='block'; | |
| dz.style.display='none'; | |
| } | |
| async function analyze(){ | |
| clearError(); | |
| const btn=document.getElementById('analyze-btn'); | |
| btn.disabled=true;btn.textContent='π¬ Analyzing...'; | |
| document.getElementById('loading').style.display='block'; | |
| document.getElementById('result-section').style.display='none'; | |
| try{ | |
| let res; | |
| if(blob){ | |
| const fd=new FormData();fd.append('image',blob); | |
| res=await fetch(`${API}/api/predict`,{method:'POST',body:fd}); | |
| }else{ | |
| res=await fetch(`${API}/api/predict-url`,{ | |
| method:'POST',headers:{'Content-Type':'application/json'}, | |
| body:JSON.stringify({url:imgUrl}) | |
| }); | |
| } | |
| const d=await res.json(); | |
| if(!res.ok||!d.success){showError(d.error||'Prediction failed.');return} | |
| // render result | |
| renderResult(d.result,d.inference_ms); | |
| // NEW: save into history for review later | |
| const entry = { | |
| ts: Date.now(), | |
| inference_ms: d.inference_ms, | |
| ...d.result | |
| }; | |
| addToHistory(entry); | |
| }catch(e){ | |
| showError(`Cannot reach server. Make sure app.py is running: python app.py`); | |
| }finally{ | |
| document.getElementById('loading').style.display='none'; | |
| btn.disabled=false;btn.textContent='π¬ Analyze this leaf'; | |
| } | |
| } | |
| function renderResult(r,ms){ | |
| document.getElementById('result-section').style.display='block'; | |
| const rm=document.getElementById('result-main'); | |
| const t5=document.getElementById('top5-card'); | |
| if(!r.is_leaf){ | |
| rm.innerHTML=`<div class="not-leaf-banner"> | |
| <div class="nl-icon">π«</div> | |
| <div> | |
| <div class="nl-title">Not a leaf</div> | |
| <div class="nl-msg">${r.description}<br><br>${r.warning?'<em>'+escHtml(r.warning)+'</em>':''}<br>Upload a clear photo of a plant leaf for disease analysis.</div> | |
| </div></div>`; | |
| t5.innerHTML=r.top5.length?renderTop5(r.top5):''; | |
| return; | |
| } | |
| const conf = r.confidence; | |
| // colour-code confidence bar | |
| const confClass = conf >= 0.80 ? 'conf-high' : conf >= 0.65 ? 'conf-mid' : 'conf-low'; | |
| // severity pill class + icon (keep your existing mapping) | |
| const sevCls={None:'sev-none',Moderate:'sev-mod',High:'sev-high',Severe:'sev-sev'}[r.severity]||'sev-na'; | |
| const icon={None:'β ',Moderate:'β οΈ',High:'π΄',Severe:'π¨'}[r.severity]||'π'; | |
| // warning banner prominently if present | |
| const warnHtml = r.warning | |
| ? `<div class="warn-banner"><span class="warn-icon">β οΈ</span><span>${escHtml(r.warning)}</span></div>` | |
| : ''; | |
| // Inference time moved UP and shown as full-width row | |
| const hasMs = (ms !== undefined && ms !== null); | |
| const inferenceRow = hasMs | |
| ? `<div class="info-cell span2"> | |
| <div class="info-lbl">Inference time</div> | |
| <div class="info-val">${Math.round(ms)} ms (TTA Γ6)</div> | |
| </div>` | |
| : ''; | |
| // Replace "Unknown" with "Not sure" for low confidence / warning | |
| const notSure = (!!r.warning) || (conf < 0.50); | |
| const pillText = notSure ? 'Not sure' : escHtml(r.severity || ''); | |
| rm.innerHTML=` | |
| ${warnHtml} | |
| <div class="info-grid"> | |
| ${inferenceRow} | |
| </div> | |
| <div class="result-hero"> | |
| <div class="result-titles"> | |
| <div class="plant-label">π± ${escHtml(r.plant)}</div> | |
| <div class="disease-title">${icon} ${escHtml(r.disease)}</div> | |
| <div class="conf-row"> | |
| <div class="conf-track"> | |
| <div class="conf-fill ${confClass}" id="conf-fill" style="width:0%" data-w="${Math.round(conf*100)}"></div> | |
| </div> | |
| <span class="conf-pct">${r.confidence_pct}</span> | |
| </div> | |
| </div> | |
| ${(r.severity && r.severity !== 'None' && pillText) | |
| ? `<div class="sev-pill ${sevCls}">${pillText}</div>` | |
| : ``} | |
| </div> | |
| <div class="info-grid"> | |
| <div class="info-cell"> | |
| <div class="info-lbl">Description</div> | |
| <div class="info-val">${escHtml(r.description)}</div> | |
| </div> | |
| <div class="info-cell"> | |
| <div class="info-lbl">Treatment</div> | |
| <div class="info-val">${escHtml(r.treatment)}</div> | |
| </div> | |
| </div>`; | |
| setTimeout(()=>{ | |
| const f=document.getElementById('conf-fill'); | |
| if(f)f.style.width=f.dataset.w+'%'; | |
| },100); | |
| t5.innerHTML=renderTop5(r.top5); | |
| } | |
| function renderTop5(top5){ | |
| const rows=top5.map((it,i)=>{ | |
| const pct=(it.probability*100).toFixed(1); | |
| const w=Math.round(it.probability*100); | |
| const nm=it.class.split('___').map(s=>s.replace(/_/g,' ')).join(' β '); | |
| return `<div class="t5-row"> | |
| <div class="t5-rank ${i===0?'gold':''}">${i+1}</div> | |
| <div class="t5-name" title="${nm}">${nm}</div> | |
| <div class="t5-track"><div class="t5-bar" style="width:0%" data-w="${w}"></div></div> | |
| <div class="t5-pct">${pct}%</div> | |
| </div>`; | |
| }).join(''); | |
| setTimeout(()=>{ | |
| document.querySelectorAll('.t5-bar').forEach((b,i)=>{ | |
| setTimeout(()=>b.style.width=b.dataset.w+'%',i*60); | |
| }); | |
| },150); | |
| return `<div class="top5-head">Top 5 predictions</div>${rows}`; | |
| } | |
| function escHtml(str){ | |
| if(!str)return ''; | |
| return str.replace(/&/g,'&').replace(/</g,'<').replace(/>/g,'>').replace(/"/g,'"'); | |
| } | |
| function resetResult(){ | |
| document.getElementById('result-section').style.display='none'; | |
| document.getElementById('result-main').innerHTML=''; | |
| document.getElementById('top5-card').innerHTML=''; | |
| } | |
| function resetAll(){ | |
| blob=null;imgUrl=null; | |
| document.getElementById('fileInput').value=''; | |
| document.getElementById('url-input').value=''; | |
| document.getElementById('preview-section').style.display='none'; | |
| document.getElementById('analyze-btn').style.display='none'; | |
| document.getElementById('loading').style.display='none'; | |
| document.getElementById('result-section').style.display='none'; | |
| document.getElementById('result-main').innerHTML=''; | |
| document.getElementById('top5-card').innerHTML=''; | |
| dz.style.display='block'; | |
| clearError(); | |
| } | |
| function showError(m){ | |
| const b=document.getElementById('error-box'); | |
| b.textContent='β '+m;b.style.display='block'; | |
| document.getElementById('loading').style.display='none'; | |
| } | |
| function clearError(){document.getElementById('error-box').style.display='none'} | |
| // Health check | |
| (async()=>{ | |
| try{ | |
| const r=await fetch(`${API}/api/health`); | |
| const d=await r.json(); | |
| if(d.status!=='ok')showError('Model not loaded. Run: python app.py'); | |
| }catch{ | |
| showError('Server not running. Start it with: python app.py'); | |
| } | |
| })(); | |
| // NEW: render history on load | |
| renderHistory(); | |
| </script> | |
| </body> | |
| </html> |