← back to George Gmail

public/index.html

195 lines

<!DOCTYPE html>
<html lang="en">
<head>
<meta charset="UTF-8">
<meta name="viewport" content="width=device-width, initial-scale=1.0">
<title>George — Gmail Agent</title>
<style>
:root{--bg:#0f1117;--card:#1a1d27;--border:#2a2d3a;--text:#e4e4e7;--muted:#888;--accent:#EA4335;--green:#4ade80;--blue:#60a5fa;--red:#f87171}
*{margin:0;padding:0;box-sizing:border-box}
body{font-family:-apple-system,BlinkMacSystemFont,'Segoe UI',sans-serif;background:var(--bg);color:var(--text);min-height:100vh}
.header{padding:20px 24px;border-bottom:1px solid var(--border);display:flex;align-items:center;gap:14px;background:var(--card)}
.avatar{width:48px;height:48px;border-radius:14px;display:flex;align-items:center;justify-content:center;font-size:24px;font-weight:700;color:#fff;background:linear-gradient(135deg,#EA4335,#FBBC04,#34A853,#4285F4)}
.header-info h1{font-size:20px;font-weight:700}.header-info span{font-size:12px;color:var(--muted)}
.status-dot{display:inline-block;width:8px;height:8px;border-radius:50%;margin-right:6px}
.status-dot.online{background:var(--green)}.status-dot.error{background:var(--red)}
.container{max-width:1200px;margin:0 auto;padding:20px}
.stats{display:grid;grid-template-columns:repeat(auto-fit,minmax(160px,1fr));gap:12px;margin-bottom:20px}
.stat{background:var(--card);border:1px solid var(--border);border-radius:12px;padding:16px;text-align:center}
.stat .val{font-size:24px;font-weight:700;color:var(--accent)}.stat .label{font-size:11px;color:var(--muted);margin-top:4px}
.tabs{display:flex;gap:4px;margin-bottom:16px;border-bottom:1px solid var(--border);padding-bottom:8px}
.tab{background:none;border:none;color:var(--muted);padding:8px 16px;border-radius:8px;cursor:pointer;font-size:13px;font-weight:500}
.tab.active{background:rgba(234,67,53,.12);color:var(--accent)}
.tab:hover{color:var(--text)}
.search-bar{display:flex;gap:8px;margin-bottom:16px}
.search-bar input{flex:1;background:var(--card);border:1px solid var(--border);border-radius:10px;padding:10px 16px;color:var(--text);font-size:14px;outline:none}
.search-bar input:focus{border-color:var(--accent)}
.search-bar button{background:var(--accent);color:#fff;border:none;border-radius:10px;padding:10px 20px;font-size:13px;font-weight:600;cursor:pointer}
.search-bar button:hover{opacity:.9}
.email-list{display:flex;flex-direction:column;gap:6px}
.email-item{background:var(--card);border:1px solid var(--border);border-radius:10px;padding:12px 16px;cursor:pointer;transition:all .15s}
.email-item:hover{border-color:var(--accent);transform:translateY(-1px)}
.email-item .subject{font-size:14px;font-weight:600;margin-bottom:4px;white-space:nowrap;overflow:hidden;text-overflow:ellipsis}
.email-item .from{font-size:12px;color:var(--blue);margin-bottom:2px}
.email-item .snippet{font-size:12px;color:var(--muted);white-space:nowrap;overflow:hidden;text-overflow:ellipsis}
.email-item .date{font-size:10px;color:var(--muted);float:right;margin-top:-20px}
.email-detail{background:var(--card);border:1px solid var(--border);border-radius:12px;padding:20px}
.email-detail h2{font-size:16px;margin-bottom:8px}
.email-detail .meta{font-size:12px;color:var(--muted);margin-bottom:16px;line-height:1.6}
.email-detail .body{font-size:13px;line-height:1.7;white-space:pre-wrap;max-height:500px;overflow-y:auto;padding:12px;background:rgba(0,0,0,.2);border-radius:8px}
.compose{display:flex;flex-direction:column;gap:10px}
.compose input,.compose textarea{background:var(--card);border:1px solid var(--border);border-radius:8px;padding:10px 14px;color:var(--text);font-size:13px;outline:none;font-family:inherit}
.compose textarea{min-height:200px;resize:vertical}
.compose button{align-self:flex-end;background:var(--accent);color:#fff;border:none;border-radius:8px;padding:10px 24px;font-weight:600;cursor:pointer}
.loading{text-align:center;padding:40px;color:var(--muted)}
.empty{text-align:center;padding:30px;color:var(--muted);font-size:13px}
.badge{display:inline-block;padding:2px 8px;border-radius:6px;font-size:10px;font-weight:600}
.badge-green{background:rgba(74,222,128,.15);color:var(--green)}
.badge-red{background:rgba(248,113,113,.15);color:var(--red)}
.label-list{display:flex;flex-wrap:wrap;gap:6px;margin-top:10px}
.label-chip{padding:4px 10px;border-radius:8px;font-size:11px;background:rgba(96,165,250,.1);color:var(--blue);border:1px solid rgba(96,165,250,.2)}
.back-btn{background:none;border:1px solid var(--border);color:var(--muted);padding:6px 14px;border-radius:8px;cursor:pointer;font-size:12px;margin-bottom:12px}
.back-btn:hover{color:var(--text);border-color:var(--text)}
</style>
    <link rel="icon" type="image/svg+xml" href="/favicon.svg">
  </head>
<body>
<div id="root"></div>
<script src="https://unpkg.com/react@18/umd/react.production.min.js"></script>
function xfetch(url,opts){return new Promise(function(resolve,reject){var x=new XMLHttpRequest();x.open((opts&&opts.method)||"GET",url,true);x.withCredentials=true;if(opts&&opts.body){x.setRequestHeader("Content-Type","application/json");}x.onload=function(){try{resolve({ok:x.status>=200&&x.status<300,status:x.status,json:function(){return Promise.resolve(JSON.parse(x.responseText))},text:function(){return Promise.resolve(x.responseText)}})}catch(e){reject(e)}};x.onerror=function(){reject(new Error("XHR failed"))};x.send(opts&&opts.body?JSON.stringify(opts.body):null)})};
<script src="https://unpkg.com/react-dom@18/umd/react-dom.production.min.js"></script>
<script src="https://unpkg.com/@babel/standalone/babel.min.js"></script>
<script type="text/babel">
const { useState, useEffect, useCallback } = React;

const AUTH = '';
async function api(path) {
  const r = await xfetch(path, { headers: { Authorization: AUTH } });
  return r.json();
}
async function apiPost(path, body) {
  const r = await xfetch(path, { method: 'POST', headers: { Authorization: AUTH, 'Content-Type': 'application/json' }, body: JSON.stringify(body) });
  return r.json();
}

function App() {
  const [status, setStatus] = useState(null);
  const [profile, setProfile] = useState(null);
  const [tab, setTab] = useState('inbox');
  const [messages, setMessages] = useState([]);
  const [labels, setLabels] = useState([]);
  const [loading, setLoading] = useState(true);
  const [search, setSearch] = useState('');
  const [selectedMsg, setSelectedMsg] = useState(null);
  const [msgDetail, setMsgDetail] = useState(null);

  useEffect(() => {
    Promise.all([api('/api/status'), api('/api/profile'), api('/api/messages?maxResults=25'), api('/api/labels')])
      .then(([s, p, m, l]) => { setStatus(s); setProfile(p); setMessages(m.messages || []); setLabels(l); setLoading(false); })
      .catch(() => setLoading(false));
  }, []);

  async function doSearch() {
    if (!search.trim()) return;
    setLoading(true);
    const r = await api('/api/search?maxResults=20&q=' + encodeURIComponent(search));
    setMessages(r.messages || []);
    setTab('search');
    setLoading(false);
  }

  async function loadInbox() {
    setLoading(true);
    setSelectedMsg(null); setMsgDetail(null);
    const r = await api('/api/messages?maxResults=25');
    setMessages(r.messages || []);
    setTab('inbox');
    setLoading(false);
  }

  async function openMessage(id) {
    setSelectedMsg(id);
    const r = await api('/api/messages/' + id);
    setMsgDetail(r);
  }

  if (loading && !status) return <div className="loading">Loading George...</div>;

  return (
    <div>
      <div className="header">
        <div className="avatar">G</div>
        <div className="header-info">
          <h1>George <span style={{color:'var(--muted)',fontWeight:400,fontSize:14}}>Gmail Agent</span></h1>
          <span>
            <span className={'status-dot ' + (status?.status === 'online' ? 'online' : 'error')} />
            {profile?.emailAddress || 'loading...'} — {profile?.messagesTotal?.toLocaleString()} messages
          </span>
        </div>
        <div style={{marginLeft:'auto'}}>
          <span className={'badge ' + (status?.status === 'online' ? 'badge-green' : 'badge-red')}>{status?.status || 'unknown'}</span>
        </div>
      </div>

      <div className="container">
        <div className="stats">
          <div className="stat"><div className="val">{profile?.messagesTotal?.toLocaleString() || '—'}</div><div className="label">Total Messages</div></div>
          <div className="stat"><div className="val">{profile?.threadsTotal?.toLocaleString() || '—'}</div><div className="label">Threads</div></div>
          <div className="stat"><div className="val">{labels.length}</div><div className="label">Labels</div></div>
          <div className="stat"><div className="val" style={{color:'var(--green)'}}>{status?.capabilities?.length || 0}</div><div className="label">Capabilities</div></div>
        </div>

        <div className="search-bar">
          <input value={search} onChange={e => setSearch(e.target.value)} onKeyDown={e => e.key === 'Enter' && doSearch()} placeholder="Search emails (Gmail syntax: from:, subject:, has:attachment, newer_than:7d ...)" />
          <button onClick={doSearch}>Search</button>
        </div>

        <div className="tabs">
          <button className={'tab' + (tab === 'inbox' ? ' active' : '')} onClick={loadInbox}>Inbox</button>
          <button className={'tab' + (tab === 'search' ? ' active' : '')} disabled={tab !== 'search'}>Search Results</button>
          <button className={'tab' + (tab === 'labels' ? ' active' : '')} onClick={() => setTab('labels')}>Labels</button>
        </div>

        {selectedMsg && msgDetail ? (
          <div>
            <button className="back-btn" onClick={() => { setSelectedMsg(null); setMsgDetail(null); }}>← Back</button>
            <div className="email-detail">
              <h2>{msgDetail.subject || '(no subject)'}</h2>
              <div className="meta">
                <strong>From:</strong> {msgDetail.from}<br/>
                <strong>To:</strong> {msgDetail.to}<br/>
                {msgDetail.cc && <><strong>Cc:</strong> {msgDetail.cc}<br/></>}
                <strong>Date:</strong> {msgDetail.date}
                {msgDetail.attachments?.length > 0 && <><br/><strong>Attachments:</strong> {msgDetail.attachments.map(a => a.filename).join(', ')}</>}
              </div>
              <div className="body">{msgDetail.body || msgDetail.snippet || '(empty)'}</div>
            </div>
          </div>
        ) : tab === 'labels' ? (
          <div className="label-list">
            {labels.map(l => <span key={l.id} className="label-chip">{l.name || l.id}</span>)}
          </div>
        ) : (
          <div className="email-list">
            {loading && <div className="loading">Loading...</div>}
            {!loading && messages.length === 0 && <div className="empty">No messages found</div>}
            {!loading && messages.map(m => (
              <div key={m.id} className="email-item" onClick={() => openMessage(m.id)}>
                <div className="date">{m.date ? new Date(m.date).toLocaleString('en-US', { timeZone: 'America/Los_Angeles', month: 'short', day: 'numeric', hour: 'numeric', minute: '2-digit' }) : ''}</div>
                <div className="from">{m.from}</div>
                <div className="subject">{m.subject || '(no subject)'}</div>
                <div className="snippet">{m.snippet}</div>
              </div>
            ))}
          </div>
        )}
      </div>
    </div>
  );
}

ReactDOM.createRoot(document.getElementById('root')).render(<App />);
</script>
</body>
</html>