MailLite — シンプルWebメール

<!DOCTYPE html>
<html lang="ja">
<head>
<meta charset="utf-8" />
<meta name="viewport" content="width=device-width, initial-scale=1" />
<title>MailLite — シンプルWebメール</title>
<style>
  :root{
    --bg:#f6f7fb;
    --panel:#ffffff;
    --text:#1f2937;
    --muted:#6b7280;
    --primary:#4f46e5;
    --primary-weak:#eef2ff;
    --border:#e5e7eb;
    --danger:#ef4444;
    --success:#10b981;
    --warning:#f59e0b;
  }
  .dark{
    --bg:#0b0e15;
    --panel:#0f1623;
    --text:#e5e7eb;
    --muted:#9ca3af;
    --primary:#8b5cf6;
    --primary-weak:#221a36;
    --border:#1f2937;
    --danger:#f87171;
    --success:#34d399;
    --warning:#fbbf24;
  }
  *{box-sizing:border-box}
  html,body{height:100%}
  body{
    margin:0;background:var(--bg);color:var(--text);
    font:14px/1.6 -apple-system,BlinkMacSystemFont,"Segoe UI",Roboto,Helvetica,Arial,"Noto Sans JP",sans-serif;
  }
  .app{
    display:grid;grid-template-rows:56px 1fr;height:100%;
  }
  /* Topbar */
  .topbar{
    display:flex;align-items:center;gap:12px;
    padding:0 16px;border-bottom:1px solid var(--border);background:var(--panel);
    position:sticky;top:0;z-index:5;
  }
  .logo{
    display:flex;align-items:center;gap:10px;font-weight:700;
    letter-spacing:.2px;
  }
  .badge{font-size:10px;padding:2px 6px;border-radius:999px;background:var(--primary-weak);color:var(--primary)}
  .search{
    margin-left:auto;display:flex;align-items:center;gap:8px;background:var(--bg);
    padding:6px 10px;border-radius:10px;border:1px solid var(--border);min-width:220px;max-width:460px;flex:1;
  }
  .search input{border:none;background:transparent;outline:none;color:var(--text);width:100%}
  .icon{width:18px;height:18px;display:inline-block;flex:0 0 18px}
  .btn{
    display:inline-flex;align-items:center;gap:8px;padding:8px 12px;border-radius:10px;
    border:1px solid var(--border);background:var(--panel);cursor:pointer;color:var(--text);
  }
  .btn.primary{background:var(--primary);border-color:var(--primary);color:#fff}
  .btn.ghost{background:transparent}
  .btn:disabled{opacity:.6;cursor:not-allowed}

  /* Layout */
  .layout{
    display:grid;grid-template-columns:260px 360px 1fr;gap:12px;padding:12px;height:calc(100vh - 56px);
  }
  .panel{background:var(--panel);border:1px solid var(--border);border-radius:14px;overflow:hidden;display:flex;flex-direction:column;min-height:0}
  .sidebar{padding:12px}
  .compose-block{padding:12px}
  .compose-button{width:100%;justify-content:center}
  .nav-group{margin-top:8px}
  .nav-item{
    display:flex;align-items:center;gap:10px;padding:10px 12px;border-radius:10px;color:var(--text);text-decoration:none;cursor:pointer;
  }
  .nav-item:hover{background:var(--primary-weak)}
  .nav-item.active{background:var(--primary);color:#fff}
  .nav-item .count{margin-left:auto;opacity:.8}

  /* List */
  .list-toolbar{display:flex;align-items:center;gap:8px;padding:8px;border-bottom:1px solid var(--border)}
  .list{overflow:auto}
  .msg{
    display:grid;grid-template-columns:24px 1fr auto;gap:10px;padding:12px;border-bottom:1px solid var(--border);cursor:pointer;
  }
  .msg:hover{background:var(--primary-weak)}
  .msg.unread{background:linear-gradient(0deg,transparent,transparent), var(--panel);font-weight:600}
  .msg .from{color:var(--text);white-space:nowrap;overflow:hidden;text-overflow:ellipsis}
  .msg .subject{color:var(--text);white-space:nowrap;overflow:hidden;text-overflow:ellipsis}
  .msg .snippet{color:var(--muted);white-space:nowrap;overflow:hidden;text-overflow:ellipsis}
  .msg .meta{display:flex;flex-direction:column;align-items:end;gap:4px;color:var(--muted)}
  .chip{display:inline-flex;align-items:center;gap:6px;padding:2px 8px;border-radius:999px;border:1px solid var(--border);font-size:11px}
  .star{cursor:pointer;opacity:.7}
  .star.active{opacity:1}
  .avatar{
    width:24px;height:24px;border-radius:50%;background:linear-gradient(135deg,var(--primary),#22c1c3);
    display:grid;place-items:center;color:#fff;font-size:12px;font-weight:700;
  }

  /* Reader */
  .reader-head{
    padding:12px;border-bottom:1px solid var(--border);display:flex;align-items:center;gap:12px;flex-wrap:wrap;
  }
  .reader-title{font-size:18px;font-weight:700}
  .reader-meta{color:var(--muted);font-size:12px}
  .reader-actions{margin-left:auto;display:flex;gap:8px}
  .reader-body{padding:16px;overflow:auto}
  .empty{display:grid;place-items:center;height:100%;color:var(--muted)}

  /* Modal */
  .modal{
    position:fixed;inset:0;background:rgba(0,0,0,.4);display:none;align-items:center;justify-content:center;z-index:20;
  }
  .modal.open{display:flex}
  .modal-card{
    width:min(920px,94vw);max-height:88vh;overflow:auto;background:var(--panel);border:1px solid var(--border);
    border-radius:16px;
  }
  .modal-head{display:flex;align-items:center;justify-content:space-between;padding:12px 16px;border-bottom:1px solid var(--border)}
  .modal-body{padding:16px;display:grid;gap:10px}
  .field{display:grid;gap:6px}
  .input, textarea{
    width:100%;padding:10px 12px;border-radius:10px;border:1px solid var(--border);
    background:transparent;color:var(--text);outline:none;
  }
  textarea{min-height:220px;resize:vertical}
  .row{display:flex;gap:10px;flex-wrap:wrap}
  .grow{flex:1}

  /* Responsive */
  @media (max-width: 1100px){
    .layout{grid-template-columns:220px 1fr}
    .reader{display:none}
    .layout.show-reader .list{display:none}
    .layout.show-reader .reader{display:flex}
  }
  @media (max-width: 640px){
    .layout{grid-template-columns:1fr}
    .sidebar{display:none}
  }
</style>
</head>
<body>
<div class="app">
  <!-- Topbar -->
  <div class="topbar">
    <div class="logo">
      <svg class="icon" viewBox="0 0 24 24" fill="none" aria-hidden="true">
        <path d="M3 7.5 12 13l9-5.5v9A2.5 2.5 0 0 1 18.5 19h-13A2.5 2.5 0 0 1 3 16.5v-9Z" stroke="currentColor" stroke-width="1.5"/>
        <path d="m3 7.5 9-5 9 5" stroke="currentColor" stroke-width="1.5"/>
      </svg>
      MailLite <span class="badge">beta</span>
    </div>

    <div class="search">
      <svg class="icon" viewBox="0 0 24 24" fill="none"><path d="m21 21-4.2-4.2" stroke="currentColor" stroke-width="1.5" stroke-linecap="round"/><circle cx="11" cy="11" r="7" stroke="currentColor" stroke-width="1.5"/></svg>
      <input id="search" placeholder="メールを検索(差出人・件名・本文・ラベル)" />
      <button class="btn ghost" id="clearSearch" title="検索クリア">クリア</button>
    </div>

    <button class="btn" id="toggleDark" title="ダークモード">
      <svg class="icon" viewBox="0 0 24 24" fill="none"><path d="M12 3a9 9 0 1 0 9 9 7 7 0 0 1-9-9Z" stroke="currentColor" stroke-width="1.5"/></svg>
      主题
    </button>
    <button class="btn primary" id="composeBtn">
      <svg class="icon" viewBox="0 0 24 24" fill="none"><path d="M4 20h16M6 14l9.5-9.5a2.1 2.1 0 1 1 3 3L9 17l-5 1 2-4Z" stroke="#fff" stroke-width="1.5" stroke-linejoin="round"/></svg>
      新規作成
    </button>
  </div>

  <!-- Main layout -->
  <div class="layout" id="layout">
    <!-- Sidebar -->
    <aside class="panel sidebar">
      <div class="compose-block">
        <button class="btn primary compose-button" id="composeBtn2">
          <svg class="icon" viewBox="0 0 24 24" fill="none"><path d="M4 20h16M6 14l9.5-9.5a2.1 2.1 0 1 1 3 3L9 17l-5 1 2-4Z" stroke="#fff" stroke-width="1.5" stroke-linejoin="round"/></svg>
          新規メール
        </button>
      </div>
      <nav class="nav-group" id="folders"></nav>
    </aside>

    <!-- List -->
    <section class="panel">
      <div class="list-toolbar">
        <button class="btn" id="markReadBtn" title="既読にする">既読</button>
        <button class="btn" id="markUnreadBtn" title="未読にする">未読</button>
        <button class="btn" id="archiveBtn" title="アーカイブ">アーカイブ</button>
        <button class="btn" id="deleteBtn" title="削除">削除</button>
        <div style="margin-left:auto;display:flex;align-items:center;gap:6px;">
          <span class="chip"><span class="dot" style="width:8px;height:8px;border-radius:50%;background:var(--primary)"></span> ラベル</span>
        </div>
      </div>
      <div class="list" id="list"></div>
    </section>

    <!-- Reader -->
    <section class="panel reader" id="reader">
      <div class="empty" id="emptyState">メールを選択してください</div>
      <div style="display:none;flex-direction:column;height:100%;" id="readerWrap">
        <div class="reader-head">
          <div style="display:flex;align-items:center;gap:10px;min-width:0">
            <div class="avatar" id="readerAvatar">Y</div>
            <div style="min-width:0">
              <div class="reader-title" id="readerSubject">件名</div>
              <div class="reader-meta" id="readerMeta">From – To ・ 日付</div>
            </div>
          </div>
          <div class="reader-actions">
            <button class="btn" id="replyBtn" title="返信">返信</button>
            <button class="btn" id="starBtn" title="スター">
              <span id="starIcon">☆</span> スター
            </button>
            <button class="btn" id="archBtn" title="アーカイブ">アーカイブ</button>
            <button class="btn" id="trashBtn" title="削除" style="color:var(--danger)">削除</button>
          </div>
        </div>
        <div class="reader-body">
          <div id="readerBody"></div>
        </div>
      </div>
    </section>
  </div>
</div>

<!-- Compose Modal -->
<div class="modal" id="composeModal" aria-hidden="true">
  <div class="modal-card">
    <div class="modal-head">
      <strong>新規メッセージ</strong>
      <div class="row">
        <button class="btn" id="saveDraftBtn">下書き保存</button>
        <button class="btn primary" id="sendBtn">送信</button>
        <button class="btn" id="closeModalBtn">閉じる</button>
      </div>
    </div>
    <div class="modal-body">
      <div class="row">
        <div class="field grow">
          <label for="to">宛先(カンマ区切り)</label>
          <input class="input" id="to" placeholder="example@example.com, someone@domain.jp" />
        </div>
        <div class="field" style="min-width:160px;">
          <label for="label">ラベル</label>
          <input class="input" id="label" placeholder="work, personal など" />
        </div>
      </div>
      <div class="field">
        <label for="subject">件名</label>
        <input class="input" id="subject" placeholder="件名を入力" />
      </div>
      <div class="field">
        <label for="body">本文</label>
        <textarea id="body" placeholder="本文を入力"></textarea>
      </div>
    </div>
  </div>
</div>

<script>
/** ======= Simple Mail App (no backend) ======= */
const DB_KEY = "maillite-db-v1";
const QS = sel => document.querySelector(sel);
const QSA = sel => [...document.querySelectorAll(sel)];
const state = {
  currentFolder: "inbox",
  query: "",
  selectedIds: new Set(),
  currentId: null,
  db: { messages: [] }
};
const FOLDERS = [
  {id:"inbox",   name:"受信箱",    icon:"📥"},
  {id:"starred", name:"スター",    icon:"⭐"},
  {id:"sent",    name:"送信済み",  icon:"📤"},
  {id:"drafts",  name:"下書き",    icon:"📝"},
  {id:"archive", name:"アーカイブ",icon:"🗄️"},
  {id:"spam",    name:"迷惑",      icon:"🚫"},
  {id:"trash",   name:"ゴミ箱",    icon:"🗑️"},
];

function initDB(){
  const saved = localStorage.getItem(DB_KEY);
  if(saved){
    state.db = JSON.parse(saved);
    return;
  }
  // Seed sample messages
  const now = Date.now();
  const demo = [
    mkMsg("suzuki@example.com","ようこそ MailLite へ","MailLite をお試しいただきありがとうございます!\n\nこのメールはデモです。",["welcome"], now-3600_000),
    mkMsg("shop@ec.example.com","【お知らせ】サマーセール開催!","最大 50%OFF。今すぐチェック!",["promo"], now-7200_000),
    mkMsg("boss@company.jp","明日の打合せ議題","・リリース計画\n・障害対応\n・コスト見直し",["work"], now-86400_000, true),
    mkMsg("friend@chat.jp","週末の予定どう?","映画かカラオケ行かない?",["personal"], now-5400_000),
    mkMsg("security@service.jp","ログイン通知","新しい端末からログインがありました。",["security"], now-9600_000),
  ];
  demo[2].unread = false;
  state.db.messages = demo;
  persist();
}
function mkMsg(from, subject, body, labels=[], time=Date.now(), starred=false){
  const id = crypto.randomUUID();
  const initials = (from.split("@")[0][0]||"U").toUpperCase();
  return {
    id, box:"inbox", from, to:[], subject, body, labels, starred, unread:true,
    date: time, initials
  };
}
function persist(){ localStorage.setItem(DB_KEY, JSON.stringify(state.db)); }

function formatDate(ts){
  const d = new Date(ts);
  const pad = n => String(n).padStart(2,"0");
  return `${d.getFullYear()}/${pad(d.getMonth()+1)}/${pad(d.getDate())} ${pad(d.getHours())}:${pad(d.getMinutes())}`;
}

// Render folders
function renderFolders(){
  const el = QS("#folders");
  el.innerHTML = "";
  for(const f of FOLDERS){
    const count = countFolder(f.id);
    const a = document.createElement("a");
    a.className = "nav-item" + (state.currentFolder===f.id?" active":"");
    a.dataset.id = f.id;
    a.innerHTML = `<span>${f.icon}</span><span>${f.name}</span><span class="count">${count||""}</span>`;
    a.addEventListener("click", ()=>{
      state.currentFolder = f.id;
      state.selectedIds.clear();
      state.currentId = null;
      render();
    });
    el.appendChild(a);
  }
}
function countFolder(folder){
  return filterByFolder(state.db.messages, folder).length;
}
function filterByFolder(list, folder){
  switch(folder){
    case "inbox":   return list.filter(m=>m.box==="inbox");
    case "starred": return list.filter(m=>m.starred && m.box!=="trash");
    case "sent":    return list.filter(m=>m.box==="sent");
    case "drafts":  return list.filter(m=>m.box==="drafts");
    case "archive": return list.filter(m=>m.box==="archive");
    case "spam":    return list.filter(m=>m.box==="spam");
    case "trash":   return list.filter(m=>m.box==="trash");
    default:        return list;
  }
}

// Search
function matchesQuery(m,q){
  if(!q) return true;
  const s = q.toLowerCase();
  const hay = [
    m.from, (m.to||[]).join(","), m.subject, m.body, (m.labels||[]).join(",")
  ].join("\n").toLowerCase();
  return hay.includes(s);
}

// Render list
function renderList(){
  const wrap = QS("#list");
  const items = filterByFolder(state.db.messages, state.currentFolder)
    .filter(m=>matchesQuery(m, state.query))
    .sort((a,b)=>b.date-a.date);
  wrap.innerHTML = "";
  if(items.length===0){
    wrap.innerHTML = `<div class="empty" style="height:100%;">${state.query? "検索結果がありません":"このフォルダは空です"}</div>`;
    return;
  }
  for(const m of items){
    const row = document.createElement("div");
    row.className = "msg" + (m.unread?" unread":"");
    row.dataset.id = m.id;
    const starClass = m.starred? "active":"";
    row.innerHTML = `
      <div class="avatar" title="${m.from}">${m.initials}</div>
      <div style="min-width:0">
        <div class="from">${m.from}</div>
        <div class="subject">${m.subject}</div>
        <div class="snippet">${(m.labels?.length? m.labels.map(l=>"#"+l).join(" ")+" · ":"")}${m.body.replace(/\n/g," ").slice(0,120)}</div>
      </div>
      <div class="meta">
        <div>${formatDate(m.date)}</div>
        <div class="star ${starClass}" data-star-id="${m.id}" title="スター">${m.starred?"★":"☆"}</div>
      </div>
    `;
    row.addEventListener("click", (e)=>{
      // If star clicked, don't open
      if(e.target && e.target.dataset.starId){ return; }
      openMessage(m.id);
    });
    row.querySelector(".star").addEventListener("click",(e)=>{
      e.stopPropagation();
      toggleStar(m.id);
    });
    wrap.appendChild(row);
  }
}

// Reader
function openMessage(id){
  const m = state.db.messages.find(x=>x.id===id);
  if(!m) return;
  state.currentId = id;
  m.unread = false;
  persist();
  QS("#emptyState").style.display = "none";
  QS("#readerWrap").style.display = "flex";
  QS("#readerSubject").textContent = m.subject || "(件名なし)";
  QS("#readerMeta").textContent    = `From: ${m.from}  /  To: ${(m.to||[]).join(", ")||"(なし)"} ・ ${formatDate(m.date)}`; 
  QS("#readerBody").innerHTML      = safeHtml(m.body).replace(/\n/g,"<br>");
  QS("#readerAvatar").textContent  = m.initials || "U";
  QS("#starIcon").textContent      = m.starred ? "★" : "☆";
  // Mobile: show reader
  QS("#layout").classList.add("show-reader");
  render();
}

function safeHtml(s=""){
  return s.replace(/[&<>"']/g, ch => ({
    "&":"&amp;","<":"&lt;",">":"&gt;",'"':"&quot;","'":"&#39;"
  }[ch]));
}

// Actions
function getSelectedOrCurrentIds(){
  if(state.selectedIds.size>0) return [...state.selectedIds];
  if(state.currentId) return [state.currentId];
  return [];
}
function markRead(read=true){
  for(const id of getSelectedOrCurrentIds()){
    const m = state.db.messages.find(x=>x.id===id);
    if(m) m.unread = !read;
  }
  persist(); render();
}
function moveTo(box){
  for(const id of getSelectedOrCurrentIds()){
    const m = state.db.messages.find(x=>x.id===id);
    if(m) m.box = box;
  }
  // If current message was moved out of view, clear reader
  if(state.currentId){
    const cm = state.db.messages.find(x=>x.id===state.currentId);
    const visible = filterByFolder([cm], state.currentFolder).length>0;
    if(!visible){ state.currentId = null; QS("#readerWrap").style.display="none"; QS("#emptyState").style.display="grid"; }
  }
  persist(); render();
}
function toggleStar(id){
  const m = state.db.messages.find(x=>x.id===id);
  if(m){ m.starred = !m.starred; persist(); render(); if(state.currentId===id) QS("#starIcon").textContent=m.starred?"★":"☆"; }
}

// Compose
function openCompose(prefill={}){
  QS("#composeModal").classList.add("open");
  QS("#to").value      = prefill.to?.join(", ") || "";
  QS("#subject").value = prefill.subject || "";
  QS("#body").value    = prefill.body || "";
  QS("#label").value   = (prefill.labels||[]).join(", ");
}
function closeCompose(){ QS("#composeModal").classList.remove("open"); }

function saveDraft(){
  const draft = collectForm();
  draft.box = "drafts";
  draft.unread = false;
  draft.from = "me@local";
  draft.id = crypto.randomUUID();
  state.db.messages.push(draft);
  persist(); closeCompose(); render();
  alert("下書きを保存しました。");
}
function sendMail(){
  const msg = collectForm();
  if(!msg.to.length){ alert("宛先を入力してください"); return; }
  msg.box = "sent";
  msg.unread = false;
  msg.from = "me@local";
  msg.id = crypto.randomUUID();
  state.db.messages.push(msg);
  persist(); closeCompose(); render();
  alert("送信しました(デモ動作:実送信なし)");
}
function collectForm(){
  const to = QS("#to").value.split(",").map(s=>s.trim()).filter(Boolean);
  const subject = QS("#subject").value.trim() || "(件名なし)";
  const body = QS("#body").value;
  const labels = QS("#label").value.split(",").map(s=>s.trim()).filter(Boolean);
  return { to, subject, body, labels, date: Date.now(), starred:false, unread:true, initials:"M" };
}

// Selection (click+Ctrl/Shift optional simplified)
QS("#list").addEventListener("click",(e)=>{
  const row = e.target.closest(".msg");
  if(!row) return;
  if(e.ctrlKey || e.metaKey){
    const id = row.dataset.id;
    if(state.selectedIds.has(id)) state.selectedIds.delete(id); else state.selectedIds.add(id);
    row.classList.toggle("selected");
  }
});

// Topbar controls
QS("#composeBtn").onclick = ()=>openCompose();
QS("#composeBtn2").onclick = ()=>openCompose();
QS("#closeModalBtn").onclick = closeCompose;
QS("#saveDraftBtn").onclick = saveDraft;
QS("#sendBtn").onclick = sendMail;

QS("#search").addEventListener("input",(e)=>{ state.query = e.target.value.trim(); renderList(); });
QS("#clearSearch").onclick = ()=>{ QS("#search").value=""; state.query=""; renderList(); };

QS("#toggleDark").onclick = ()=>{
  document.body.classList.toggle("dark");
  localStorage.setItem("maillite-theme", document.body.classList.contains("dark")? "dark":"light");
};

// List toolbar actions
QS("#markReadBtn").onclick = ()=>markRead(true);
QS("#markUnreadBtn").onclick = ()=>markRead(false);
QS("#archiveBtn").onclick = ()=>moveTo("archive");
QS("#deleteBtn").onclick = ()=>moveTo("trash");

// Reader actions
QS("#replyBtn").onclick = ()=>{
  const m = state.db.messages.find(x=>x.id===state.currentId);
  if(!m) return;
  openCompose({ to:[m.from], subject:"Re: "+m.subject, body:`\n\n--- ${m.from} さんのメッセージ ---\n${m.body}`, labels:["reply"] });
};
QS("#starBtn").onclick = ()=>{ if(state.currentId) toggleStar(state.currentId); };
QS("#archBtn").onclick = ()=>moveTo("archive");
QS("#trashBtn").onclick = ()=>moveTo("trash");

// Clicking outside modal to close
QS("#composeModal").addEventListener("click",(e)=>{ if(e.target===QS("#composeModal")) closeCompose(); });

// Mobile back from reader by clicking empty area (or press Escape)
document.addEventListener("keydown",(e)=>{
  if(e.key==="Escape"){
    if(QS("#composeModal").classList.contains("open")) closeCompose();
    else QS("#layout").classList.remove("show-reader");
  }
});

// Initial render
function render(){
  renderFolders();
  renderList();
}
(function boot(){
  initDB();
  render();
  // Theme
  if(localStorage.getItem("maillite-theme")==="dark"){ document.body.classList.add("dark"); }
})();
</script>
</body>
</html>

投稿者: chosuke

趣味はゲームやアニメや漫画などです

コメントを残す

メールアドレスが公開されることはありません。 が付いている欄は必須項目です