Spaces:
Running
Running
| <html lang="en"> | |
| <head> | |
| <meta charset="utf-8"/> | |
| <meta name="viewport" content="width=device-width, initial-scale=1"/> | |
| <title>PillPal</title> | |
| <style> | |
| :root{ | |
| --bg:#f8fafc; --panel:#ffffff; --ink:#0f172a; --muted:#64748b; --line:#e5e7eb; | |
| --blue:#2563eb; --blue-soft:#eff4ff; --blue-ink:#1d4ed8; --green:#16a34a; --green-soft:#dcfce7; | |
| --amber-bg:#fffbeb; --amber-line:#fde68a; --amber-ink:#92660e; --red:#dc2626; --shadow:0 1px 3px rgba(15,23,42,.06),0 1px 2px rgba(15,23,42,.04); | |
| } | |
| body.dark{ --bg:#0b1220; --panel:#111a2e; --ink:#e7edf7; --muted:#90a0bb; --line:#22304d; | |
| --blue-soft:#13243f; --green-soft:#0e2a1c; --amber-bg:#241d09; --amber-line:#4a3a12; --shadow:0 1px 3px rgba(0,0,0,.4);} | |
| *{box-sizing:border-box;} html,body{margin:0;background:var(--bg);color:var(--ink);font-family:'Inter',system-ui,Segoe UI,Roboto,sans-serif;} | |
| a{color:var(--blue);text-decoration:none;} | |
| .topbar{display:flex;align-items:center;gap:18px;background:var(--panel);border-bottom:1px solid var(--line);padding:14px 26px;} | |
| .logo{display:flex;align-items:center;gap:10px;font-weight:800;font-size:1.4rem;} | |
| .logo .mark{width:34px;height:34px;border-radius:9px;background:linear-gradient(135deg,#3b82f6,#2563eb);display:grid;place-items:center;color:#fff;font-size:1.1rem;} | |
| .tagline{color:var(--muted);font-size:.92rem;flex:1;} | |
| .theme{border:1px solid var(--line);background:var(--panel);color:var(--ink);border-radius:9px;padding:8px 12px;cursor:pointer;font-weight:600;font-size:.85rem;} | |
| .shell{display:flex;align-items:flex-start;} | |
| .sidebar{width:230px;flex:none;padding:18px 14px;display:flex;flex-direction:column;min-height:calc(100vh - 64px);gap:4px;} | |
| .nav{display:flex;align-items:center;gap:11px;padding:10px 12px;border-radius:10px;color:var(--muted);font-weight:600;cursor:pointer;font-size:.95rem;} | |
| .nav:hover{background:var(--blue-soft);} .nav.active{background:var(--blue-soft);color:var(--blue-ink);} | |
| .nav .i{width:18px;text-align:center;} | |
| .track{margin-top:auto;background:var(--panel);border:1px solid var(--line);border-radius:14px;padding:14px;box-shadow:var(--shadow);} | |
| .track h4{margin:0 0 4px;display:flex;align-items:center;gap:6px;} .track p{margin:0 0 10px;color:var(--muted);font-size:.82rem;line-height:1.4;} | |
| .track button{width:100%;border:1px solid var(--line);background:var(--panel);color:var(--blue);border-radius:9px;padding:8px;font-weight:700;cursor:pointer;} | |
| .content{flex:1;padding:20px 26px 40px;max-width:1180px;margin:0 auto;} | |
| .banner{display:flex;gap:10px;align-items:center;background:var(--amber-bg);border:1px solid var(--amber-line);color:var(--amber-ink);border-radius:12px;padding:12px 16px;font-size:.9rem;margin-bottom:18px;} | |
| .grid{display:grid;grid-template-columns:1fr 320px;gap:18px;align-items:start;} | |
| @media(max-width:1100px){.grid{grid-template-columns:1fr;}} | |
| .card{background:var(--panel);border:1px solid var(--line);border-radius:16px;padding:18px 20px;box-shadow:var(--shadow);margin-bottom:18px;} | |
| .stitle{display:flex;align-items:center;gap:10px;font-weight:800;font-size:1.05rem;margin:0 0 16px;} | |
| .stitle::before{content:"";width:4px;height:18px;border-radius:3px;background:var(--blue);} | |
| .two{display:grid;grid-template-columns:1fr;gap:16px;} | |
| .drop{border:2px dashed var(--line);border-radius:12px;background:var(--bg);min-height:210px;display:grid;place-items:center;text-align:center;color:var(--muted);cursor:pointer;padding:16px;} | |
| .drop.hover{border-color:var(--blue);background:var(--blue-soft);} .drop b{color:var(--ink);display:block;margin:8px 0 4px;} | |
| .drop img{max-height:180px;border-radius:8px;} | |
| .dicons{display:flex;justify-content:center;gap:26px;color:var(--muted);margin-top:10px;font-size:1.1rem;} | |
| .btn{border:none;border-radius:10px;padding:11px 14px;font-weight:700;cursor:pointer;font-size:.92rem;width:100%;} | |
| .btn.primary{background:var(--blue);color:#fff;} .btn.primary:hover{filter:brightness(1.05);} | |
| .btn.ghost{background:var(--panel);border:1px solid var(--line);color:var(--ink);} .btn.danger{background:var(--panel);border:1px solid #fecaca;color:var(--red);} | |
| .row2{display:grid;grid-template-columns:1fr 1fr;gap:10px;} | |
| .med{border:1px solid var(--line);border-radius:12px;padding:14px;margin-bottom:12px;} | |
| .med .top{display:flex;align-items:center;gap:8px;} | |
| .med .name{font-weight:800;font-size:1.05rem;} .badge{background:var(--blue-soft);color:var(--blue-ink);border-radius:7px;padding:2px 8px;font-size:.78rem;font-weight:700;} | |
| .ok{margin-left:auto;background:var(--green-soft);color:var(--green);border-radius:999px;padding:3px 10px;font-size:.78rem;font-weight:700;} | |
| .warn{margin-left:auto;background:var(--amber-bg);color:var(--amber-ink);border:1px solid var(--amber-line);border-radius:999px;padding:3px 10px;font-size:.78rem;font-weight:700;} | |
| .chips{display:flex;gap:8px;flex-wrap:wrap;margin:10px 0;} .chip{background:var(--blue-soft);color:var(--blue-ink);border-radius:7px;padding:4px 10px;font-size:.8rem;font-weight:700;} | |
| .meta{color:var(--muted);font-size:.85rem;display:flex;gap:18px;flex-wrap:wrap;} | |
| .selbox{border:1px solid var(--line);border-radius:12px;padding:14px;margin-top:4px;} | |
| .selbox label{font-size:.85rem;color:var(--muted);} select,input{width:100%;border:1px solid var(--line);border-radius:9px;padding:10px 12px;background:var(--panel);color:var(--ink);font-size:.92rem;margin-top:6px;outline:none;} | |
| input:focus,select:focus{border-color:var(--blue);} | |
| .field{margin-bottom:12px;} .field>label{font-size:.85rem;color:var(--muted);font-weight:600;} | |
| .subtle{color:var(--muted);font-size:.85rem;margin:-8px 0 12px;} | |
| .dose{background:var(--blue-soft);border-radius:10px;padding:9px 12px;margin-bottom:8px;} | |
| .dose .t{color:var(--blue-ink);font-weight:800;font-size:.85rem;} .dose .d{font-weight:700;font-size:.85rem;margin-top:1px;} .dose .q{color:var(--muted);font-size:.75rem;margin-top:1px;} | |
| .tip{display:flex;gap:10px;align-items:flex-start;color:var(--muted);font-size:.9rem;margin:10px 0;} | |
| .head-ico{display:flex;align-items:center;gap:8px;font-weight:800;font-size:.98rem;margin:0 0 4px;} | |
| footer{border-top:1px solid var(--line);margin-top:10px;padding:18px;text-align:center;color:var(--muted);font-size:.85rem;} | |
| footer span{margin:0 10px;} | |
| .toast{position:fixed;bottom:18px;left:50%;transform:translateX(-50%);background:#0f172a;color:#fff;padding:10px 16px;border-radius:10px;opacity:0;transition:.3s;z-index:9;} | |
| .toast.show{opacity:1;} | |
| </style> | |
| </head> | |
| <body> | |
| <header class="topbar"> | |
| <div class="logo"><span class="mark">💊</span> PillPal</div> | |
| <div class="tagline">Photograph a bottle. Get a clear daily schedule — and a heads-up before you run out.</div> | |
| <button class="theme" id="theme">Theme ▾</button> | |
| </header> | |
| <div class="shell"> | |
| <main class="content"> | |
| <div class="grid"> | |
| <div> | |
| <section class="card"> | |
| <h3 class="stitle">1. Read a label</h3> | |
| <div class="two"> | |
| <div> | |
| <div class="drop" id="drop"> | |
| <div> | |
| <b>Upload pill bottle photo</b> | |
| <span>Drag and drop an image here<br/>or click to browse</span> | |
| </div> | |
| </div> | |
| <input type="file" id="file" accept="image/*" style="display:none"/> | |
| <button class="btn primary" id="btnRead" style="margin-top:14px">Read label</button> | |
| </div> | |
| <div> | |
| <div id="medCards"></div> | |
| <div class="selbox"> | |
| <label>Select a medication to edit or delete</label> | |
| <select id="medSelect"><option value="">Choose a medication…</option></select> | |
| <div class="row2" style="margin-top:12px"> | |
| <button class="btn ghost" id="btnLoad">Load for editing</button> | |
| <button class="btn danger" id="btnDelete">Delete</button> | |
| </div> | |
| <button class="btn ghost" id="btnClear" style="margin-top:10px">Clear all</button> | |
| </div> | |
| </div> | |
| </div> | |
| </section> | |
| <section class="card"> | |
| <h3 class="stitle">2. Confirm or correct</h3> | |
| <div class="two"> | |
| <div> | |
| <div class="field"><label>Medication</label><input id="f_name" placeholder="e.g., Metformin"/></div> | |
| <div class="field"><label>Dose</label><input id="f_dose" placeholder="e.g., 500 mg"/></div> | |
| <div class="field"><label>Instruction</label><input id="f_instruction" placeholder="e.g., take 1 tablet twice daily"/></div> | |
| </div> | |
| <div> | |
| <div class="row2"> | |
| <div class="field"><label>Quantity in bottle</label><input id="f_qty" placeholder="e.g., 60"/></div> | |
| <div class="field"><label>Refill / expiry (optional)</label><input id="f_expiry" placeholder="YYYY-MM-DD"/></div> | |
| </div> | |
| <div class="row2" style="margin-top:30px"> | |
| <button class="btn primary" id="btnAdd">Add new</button> | |
| <button class="btn ghost" id="btnUpdate">Update selected</button> | |
| </div> | |
| </div> | |
| </div> | |
| </section> | |
| </div> | |
| <div> | |
| <section class="card"> | |
| <h3 class="head-ico">Today's schedule</h3> | |
| <div class="subtle" id="today">—</div> | |
| <div id="scheduleList"></div> | |
| </section> | |
| <section class="card"> | |
| <h3 class="head-ico">Quick tips</h3> | |
| <div class="tip">Take photos in good lighting</div> | |
| <div class="tip">Check the label for dose & instructions</div> | |
| <div class="tip">Keep your medication list up to date</div> | |
| </section> | |
| </div> | |
| </div> | |
| </main> | |
| </div> | |
| <div class="toast" id="toast"></div> | |
| <script type="module"> | |
| import { Client } from "https://cdn.jsdelivr.net/npm/@gradio/client/dist/index.min.js"; | |
| const byId=(i)=>document.getElementById(i), esc=(s)=>(s==null?"":String(s)).replace(/[&<>]/g,c=>({"&":"&","<":"<",">":">"}[c])); | |
| let _client=null; const C=async()=>(_client??=await Client.connect(window.location.origin)); | |
| let selected=-1, imageData=""; | |
| const toast=(m)=>{const t=byId("toast");t.textContent=m;t.classList.add("show");clearTimeout(t._h);t._h=setTimeout(()=>t.classList.remove("show"),2400);}; | |
| function renderMedCard(m){ | |
| const status=m.on_track?`<span class="ok">On track</span>`:`<span class="warn">${esc(m.warning||"Refill soon")}</span>`; | |
| const chips=(m.times||[]).map(t=>`<span class="chip">${esc(t)}</span>`).join(""); | |
| return `<div class="med"><div class="top"><span class="name">${esc(m.name||"Medication")}</span> | |
| ${m.dose?`<span class="badge">${esc(m.dose)}</span>`:""}${status}</div> | |
| ${chips?`<div class="chips">${chips}</div>`:""} | |
| <div class="meta">${m.quantity!=null?`<span>Qty <b>${esc(m.quantity)}</b></span>`:""} | |
| ${m.run_out?`<span>Run-out <b>${esc(m.run_out)}</b></span>`:""}</div></div>`; | |
| } | |
| function render(s){ | |
| byId("today").textContent=s.today||""; | |
| byId("medCards").innerHTML=(s.meds||[]).map(renderMedCard).join("")||`<div class="meta" style="padding:8px 0">No medications yet — add one below.</div>`; | |
| const sel=byId("medSelect"); sel.innerHTML=`<option value="">Choose a medication…</option>`+ | |
| (s.meds||[]).map(m=>`<option value="${m.i}">${esc(m.name||"Medication")}${m.dose?" ("+esc(m.dose)+")":""}</option>`).join(""); | |
| byId("scheduleList").innerHTML=(s.schedule||[]).map(b=>`<div class="dose"><div class="t">${esc(b.time)}</div>`+ | |
| b.items.map(it=>`<div class="d">${esc(it)}</div><div class="q">1 dose</div>`).join("")+`</div>`).join("") | |
| || `<div class="subtle">Nothing scheduled today.</div>`; | |
| window._meds=s.meds||[]; | |
| } | |
| async function refresh(){ const c=await C(); render((await c.predict("/state",{})).data[0]); } | |
| const drop=byId("drop"), file=byId("file"); | |
| drop.onclick=()=>file.click(); | |
| file.onchange=e=>{const f=e.target.files[0]; if(f)readFile(f);}; | |
| ["dragover","dragenter"].forEach(ev=>drop.addEventListener(ev,e=>{e.preventDefault();drop.classList.add("hover");})); | |
| ["dragleave","drop"].forEach(ev=>drop.addEventListener(ev,e=>{e.preventDefault();drop.classList.remove("hover");})); | |
| drop.addEventListener("drop",e=>{const f=e.dataTransfer.files[0]; if(f)readFile(f);}); | |
| function readFile(f){const r=new FileReader();r.onload=()=>{imageData=r.result; drop.innerHTML=`<img src="${imageData}"/>`;};r.readAsDataURL(f);} | |
| byId("btnRead").onclick=async()=>{ if(!imageData){toast("Upload a photo first.");return;} | |
| toast("Reading label…"); const c=await C(); const r=(await c.predict("/read_label",{image_data:imageData})).data[0]; | |
| if(!r.ok){toast(r.message);return;} const f=r.fields; | |
| byId("f_name").value=f.drug_name; byId("f_dose").value=f.dose; byId("f_instruction").value=f.frequency_text; | |
| byId("f_qty").value=f.quantity; byId("f_expiry").value=f.refill_or_expiry_date; toast("Label read — confirm the details."); }; | |
| function formArgs(index){ return {drug_name:byId("f_name").value, dose:byId("f_dose").value, | |
| frequency_text:byId("f_instruction").value, quantity:byId("f_qty").value, refill_or_expiry_date:byId("f_expiry").value, index}; } | |
| function clearForm(){ ["f_name","f_dose","f_instruction","f_qty","f_expiry"].forEach(i=>byId(i).value=""); selected=-1; } | |
| async function save(index){ const c=await C(); const r=(await c.predict("/save_med",formArgs(index))).data[0]; | |
| if(!r.ok){toast(r.message);return;} render(r); toast(index>=0?"Updated.":"Added."); clearForm(); } | |
| byId("btnAdd").onclick=()=>save(-1); | |
| byId("btnUpdate").onclick=()=>{ if(selected<0){toast("Pick a medication to update (Load for editing).");return;} save(selected); }; | |
| byId("btnLoad").onclick=()=>{ const i=byId("medSelect").value; if(i===""){toast("Choose a medication first.");return;} | |
| const m=window._meds[+i]; selected=+i; byId("f_name").value=m.name||""; byId("f_dose").value=m.dose||""; | |
| byId("f_instruction").value=m.instruction||""; byId("f_qty").value=m.quantity??""; byId("f_expiry").value=m.refill_or_expiry||""; toast("Loaded — edit then Update selected."); }; | |
| byId("btnDelete").onclick=async()=>{ const i=byId("medSelect").value; if(i===""){toast("Choose a medication first.");return;} | |
| const c=await C(); render((await c.predict("/delete_med",{index:+i})).data[0]); toast("Deleted."); clearForm(); }; | |
| byId("btnClear").onclick=async()=>{ const c=await C(); render((await c.predict("/clear_all",{})).data[0]); toast("Cleared all."); clearForm(); }; | |
| byId("theme").onclick=()=>{ document.body.classList.toggle("dark"); | |
| byId("theme").textContent=(document.body.classList.contains("dark")?"Light ▾":"Dark ▾")+""; }; | |
| refresh(); | |
| </script> | |
| </body> | |
| </html> | |