4211 lines
260 KiB
React
4211 lines
260 KiB
React
const { useState, useRef, useEffect } = React;
|
||
|
||
const ACCENTS=["#0078d4","#0063b1","#744da9","#e3008c","#c50f1f","#ca5010","#e87722","#b5911b","#498205","#107c10","#00b7c3","#4e6b8c"];
|
||
const FONT="'Segoe UI Variable','Segoe UI',system-ui,sans-serif";
|
||
const DEFAULT_LOGIN_BG="./login-bg-default.jpg";
|
||
|
||
function mkTheme(dark,accent){
|
||
return{accent,accentHov:blend(accent,dark?"#ffffff":"#000000",0.15),accentBg:accent+"18",accentBorder:accent+"40",
|
||
bg:dark?"#1f1f1f":"#f3f3f3",sidebar:dark?"#181818":"#f0f0f0",surface:dark?"#2a2a2a":"#ffffff",
|
||
surface2:dark?"#222222":"#fafafa",surface3:dark?"#323232":"#f5f5f5",
|
||
border:dark?"rgba(255,255,255,0.08)":"rgba(0,0,0,0.08)",text:dark?"#f0f0f0":"#1c1c1c",
|
||
muted:dark?"#a8a8a8":"#5f5f5f",subtle:dark?"#606060":"#9e9e9e",hover:dark?"rgba(255,255,255,0.06)":"rgba(0,0,0,0.05)",
|
||
inputBg:dark?"#333":"#fafafa",inBorder:dark?"rgba(255,255,255,0.14)":"rgba(0,0,0,0.12)",
|
||
titleBar:"#1a1a1a",danger:dark?"#ff6b6b":"#c50f1f",selectedBg:accent+"18",selectedLeft:accent+"80"};
|
||
}
|
||
function blend(hex,w,t){
|
||
const p=x=>parseInt(x,16);
|
||
const r=[p(hex.slice(1,3)),p(hex.slice(3,5)),p(hex.slice(5,7))];
|
||
const ww=[p(w.slice(1,3)),p(w.slice(3,5)),p(w.slice(5,7))];
|
||
return "#"+r.map((v,i)=>Math.round(v+(ww[i]-v)*t).toString(16).padStart(2,"0")).join("");
|
||
}
|
||
|
||
// ── Background catalogue ──────────────────────────────────────────────────────
|
||
const BG_SOLID=[
|
||
{id:"white", label:"White", preview:"#ffffff",dark:false},
|
||
{id:"smoke", label:"Smoke Gray", preview:"#e4e4e4",dark:false},
|
||
{id:"solarized",label:"Solarized", preview:"#fdf6e3",dark:false},
|
||
{id:"dark", label:"Dark", preview:"#1a1a1a",dark:true},
|
||
{id:"navy", label:"Deep Navy", preview:"#0d2040",dark:true},
|
||
{id:"charcoal", label:"Charcoal", preview:"#2b2b2b",dark:true},
|
||
];
|
||
const BG_IMAGES=[
|
||
{id:"mountains",label:"Mountains",swatch:"linear-gradient(180deg,#1b3a5e 0%,#4a7fb5 35%,#7aad6a 65%,#8b7355 100%)",app:"linear-gradient(150deg,#1b3a5e 0%,#4a7fb5 45%,#7aad6a 80%,#8b7355 100%)"},
|
||
{id:"beach", label:"Beach", swatch:"linear-gradient(180deg,#87ceeb 0%,#b8e0f0 40%,#f5dfa8 70%,#c8a04a 100%)",app:"linear-gradient(150deg,#87ceeb 0%,#b8e0f0 40%,#f5dfa8 75%,#c8a04a 100%)"},
|
||
{id:"ocean", label:"Ocean", swatch:"linear-gradient(180deg,#0d47a1 0%,#1976d2 40%,#42a5f5 75%,#80deea 100%)",app:"linear-gradient(150deg,#0d47a1 0%,#1976d2 40%,#42a5f5 80%,#80deea 100%)"},
|
||
{id:"landscape",label:"Landscape",swatch:"linear-gradient(180deg,#1a4a1a 0%,#2e7d32 35%,#7cb342 65%,#c8e6c9 100%)",app:"linear-gradient(150deg,#1a4a1a 0%,#2e7d32 40%,#7cb342 75%,#c8e6c9 100%)"},
|
||
{id:"flowers", label:"Flowers", swatch:"linear-gradient(180deg,#880e4f 0%,#e91e63 35%,#f48fb1 65%,#fce4ec 100%)",app:"linear-gradient(150deg,#880e4f 0%,#c2185b 40%,#f48fb1 75%,#fce4ec 100%)"},
|
||
{id:"city", label:"City", swatch:"linear-gradient(180deg,#0a0a1a 0%,#1a1a3e 35%,#2d2d6e 60%,#e94560 100%)",app:"linear-gradient(150deg,#0a0a1a 0%,#1a1a3e 40%,#2d2d6e 75%,#e94560 100%)"},
|
||
{id:"sunset", label:"Sunset", swatch:"linear-gradient(180deg,#1a0533 0%,#7b1fa2 30%,#e65100 65%,#ff8f00 100%)",app:"linear-gradient(150deg,#1a0533 0%,#7b1fa2 35%,#e65100 70%,#ff8f00 100%)"},
|
||
{id:"aurora", label:"Aurora", swatch:"linear-gradient(180deg,#0d1b2a 0%,#1a5276 35%,#1abc9c 65%,#a9f0d1 100%)",app:"linear-gradient(150deg,#0d1b2a 0%,#1a5276 40%,#1abc9c 70%,#a9f0d1 100%)"},
|
||
{id:"desert", label:"Desert", swatch:"linear-gradient(180deg,#1a0a00 0%,#b35900 35%,#e8a020 65%,#f5d080 100%)",app:"linear-gradient(150deg,#1a0a00 0%,#b35900 40%,#e8a020 70%,#f5d080 100%)"},
|
||
{id:"arctic", label:"Arctic", swatch:"linear-gradient(180deg,#e8f4ff 0%,#b8d8f0 35%,#d0eaff 65%,#f0f8ff 100%)",app:"linear-gradient(150deg,#dceefb 0%,#b8d8f0 40%,#d0eaff 75%,#f0f8ff 100%)"},
|
||
];
|
||
|
||
function getBgStyle(bgId){
|
||
if(!bgId||bgId==="white")return{};
|
||
if(typeof bgId==="string"&&bgId.startsWith("#"))return{background:bgId};
|
||
const solid=BG_SOLID.find(b=>b.id===bgId);
|
||
if(solid)return{background:solid.preview};
|
||
const img=BG_IMAGES.find(b=>b.id===bgId);
|
||
if(img)return{background:img.app};
|
||
return{};
|
||
}
|
||
function bgIsImage(bgId){return BG_IMAGES.some(b=>b.id===bgId);}
|
||
function bgIsDark(bgId){
|
||
if(typeof bgId==="string"&&/^#[0-9a-f]{6}$/i.test(bgId)){
|
||
const r=parseInt(bgId.slice(1,3),16),g=parseInt(bgId.slice(3,5),16),b=parseInt(bgId.slice(5,7),16);
|
||
return (r*0.299+g*0.587+b*0.114)<128;
|
||
}
|
||
const solid=BG_SOLID.find(b=>b.id===bgId);
|
||
if(solid)return solid.dark;
|
||
const imgDark=["city","sunset","aurora","ocean","mountains"];
|
||
return imgDark.includes(bgId);
|
||
}
|
||
|
||
// ── Profile system ────────────────────────────────────────────────────────────
|
||
const INIT_PROFILES=[
|
||
{id:"p1",name:"Alex Johnson",initials:"AJ",color:"#744da9",password:"demo",
|
||
accountIds:["alex@example-corp.com","default"],
|
||
settings:{accent:"#0078d4",isDark:false,bg:"white",notifications:true,sound:false,readReceipts:false,autoMark:true}},
|
||
{id:"p2",name:"Sales Team",initials:"ST",color:"#107c10",password:"sales",
|
||
accountIds:["sales@example-corp.com"],
|
||
settings:{accent:"#107c10",isDark:false,bg:"white",notifications:true,sound:true,readReceipts:true,autoMark:true}},
|
||
{id:"p3",name:"J. Doe Personal",initials:"JD",color:"#ca5010",password:"demo3",
|
||
accountIds:["j.doe@mailbox.org"],
|
||
settings:{accent:"#ca5010",isDark:true,bg:"dark",notifications:false,sound:false,readReceipts:false,autoMark:false}},
|
||
];
|
||
|
||
// ── Accounts + folders ────────────────────────────────────────────────────────
|
||
const ALL_ACCOUNTS=[
|
||
{id:"default", display:"Default Account", email:null, av:"DA",avColor:"#0078d4"},
|
||
{id:"alex@example-corp.com", display:"Alex", email:"alex@example-corp.com", av:"AJ",avColor:"#744da9"},
|
||
{id:"sales@example-corp.com", display:"Sales", email:"sales@example-corp.com",av:"SA",avColor:"#107c10"},
|
||
{id:"j.doe@mailbox.org", display:"J. Doe", email:"j.doe@mailbox.org", av:"JD",avColor:"#ca5010"},
|
||
];
|
||
const STD_FOLDERS=[
|
||
{id:"inbox", label:"Inbox", icon:"ti-inbox", badge:true},
|
||
{id:"sent", label:"Sent", icon:"ti-send", badge:false},
|
||
{id:"drafts", label:"Drafts", icon:"ti-file-text", badge:true},
|
||
{id:"out", label:"Outbox", icon:"ti-upload", badge:false},
|
||
{id:"archive", label:"Archive", icon:"ti-archive", badge:false},
|
||
{id:"templates",label:"Templates",icon:"ti-layout-grid", badge:false},
|
||
{id:"trash", label:"Trash", icon:"ti-trash", badge:false},
|
||
];
|
||
|
||
// ── Seed emails ───────────────────────────────────────────────────────────────
|
||
const SEED=[
|
||
{id:1,account:"alex@example-corp.com",folder:"inbox",from:"Sarah Chen",from_email:"sarah@acme.com",to:"alex@example-corp.com",subject:"Q3 Budget Review — Action Required",preview:"Hi team, please review the attached budget spreadsheet before Friday's meeting...",body:"Hi Alex,\n\nPlease review the attached budget spreadsheet before Friday's meeting. We need everyone's input on proposed changes.\n\nKey items:\n • Marketing budget +15%\n • IT infrastructure refresh ($240K)\n • Travel policy revisions\n\nReturn by EOD Thursday.\n\nBest,\nSarah",date:"2:34 PM",read:false,starred:true,atts:["Q3_Budget_2024.xlsx","Policy_Updates.pdf"],av:"SC",avColor:"#0078d4"},
|
||
{id:2,account:"alex@example-corp.com",folder:"inbox",from:"James Holloway",from_email:"james@dev.io",to:"alex@example-corp.com",subject:"Re: Deployment pipeline — staging env down",preview:"The issue was traced to a misconfigured Nginx proxy rule...",body:"Hey Alex,\n\nIssue traced to a misconfigured Nginx proxy rule added during last night's security patch. Fix pushed — health check should go green in ~10 min.\n\nJames",date:"11:20 AM",read:false,starred:false,atts:[],av:"JH",avColor:"#744da9"},
|
||
{id:3,account:"alex@example-corp.com",folder:"inbox",from:"Priya Nair",from_email:"priya@design.studio",to:"alex@example-corp.com",subject:"Brand refresh assets — final delivery",preview:"Attached are the final approved assets for the brand refresh...",body:"Hi Alex,\n\nAttached are the final approved assets. SVG, PNG @1x/2x, and Figma source.\n\nPriya",date:"Yesterday",read:true,starred:true,atts:["Brand_Assets_Final.zip"],av:"PN",avColor:"#e87722"},
|
||
{id:4,account:"alex@example-corp.com",folder:"sent",from:"Alex",from_email:"alex@example-corp.com",to:"sarah@acme.com",subject:"Re: Q3 Budget Review — Action Required",preview:"Hi Sarah, I've reviewed the spreadsheet...",body:"Hi Sarah,\n\nReviewed the spreadsheet. Aligned on the marketing increase — would like to discuss travel policy changes.\n\nAlex",date:"3:10 PM",read:true,starred:false,atts:[],av:"AJ",avColor:"#744da9"},
|
||
{id:5,account:"sales@example-corp.com",folder:"inbox",from:"Mark Okafor",from_email:"mark@partners.net",to:"sales@example-corp.com",subject:"Partnership proposal — follow-up",preview:"Thanks for the meeting yesterday. Revised proposal attached...",body:"Hi,\n\nRevised proposal attached. 90-day pilot, 30% lower upfront, dedicated account manager.\n\nMark",date:"Yesterday",read:false,starred:false,atts:["Partnership_Proposal_v3.pptx"],av:"MO",avColor:"#00b7c3"},
|
||
{id:6,account:"sales@example-corp.com",folder:"inbox",from:"Lena Fischer",from_email:"lena@gmbh.de",to:"sales@example-corp.com",subject:"Order #4821 — shipping inquiry",preview:"Good morning, I wanted to follow up on my order placed two weeks ago...",body:"Good morning,\n\nFollowing up on order #4821 placed two weeks ago. No shipping confirmation received.\n\nLena Fischer",date:"10:05 AM",read:false,starred:false,atts:[],av:"LF",avColor:"#0063b1"},
|
||
{id:7,account:"j.doe@mailbox.org",folder:"inbox",from:"Tech Digest",from_email:"news@techdigest.io",to:"j.doe@mailbox.org",subject:"This week in open source",preview:"Mutt 2.3.2 released, Neovim 0.11 ships treesitter overhaul...",body:"This Week in Open Source\n─────────────────────────\n• Mutt 2.3.2 released\n• Neovim 0.11 treesitter overhaul\n• Linux 6.9 released",date:"9:00 AM",read:true,starred:false,atts:[],av:"TD",avColor:"#498205"},
|
||
{id:8,account:"default",folder:"inbox",from:"Mutt Daemon",from_email:"mutt@localhost",to:"default",subject:"System notification — 4 new messages",preview:"Your mailbox has received 4 new messages...",body:"Mutt Mail Notification\n──────────────────────\nMailbox: .Mails/default/inbox\nNew msgs: 4\nSMB: \\\\mailserver\\mail (connected)",date:"10:55 AM",read:true,starred:false,atts:[],av:"MD",avColor:"#107c10"},
|
||
];
|
||
|
||
const INIT_RULES=[
|
||
{id:"r1",name:"Newsletter auto-archive",enabled:true,condLogic:"any",conditions:[{id:"c1",field:"subject",op:"contains",value:"unsubscribe"},{id:"c2",field:"from",op:"contains",value:"newsletter"}],actions:[{id:"a1",type:"move",folder:"archive"},{id:"a2",type:"markRead"}]},
|
||
{id:"r2",name:"Flag partnership emails",enabled:true,condLogic:"any",conditions:[{id:"c3",field:"subject",op:"contains",value:"partnership"}],actions:[{id:"a3",type:"star"}]},
|
||
];
|
||
|
||
const INIT_CONTACTS=[
|
||
{id:"ct1",name:"Sarah Chen", email:"sarah@acme.com", av:"SC",avColor:"#0078d4"},
|
||
{id:"ct2",name:"James Holloway", email:"james@dev.io", av:"JH",avColor:"#744da9"},
|
||
{id:"ct3",name:"Priya Nair", email:"priya@design.studio", av:"PN",avColor:"#e87722"},
|
||
{id:"ct4",name:"Mark Okafor", email:"mark@partners.net", av:"MO",avColor:"#00b7c3"},
|
||
{id:"ct5",name:"Lena Fischer", email:"lena@gmbh.de", av:"LF",avColor:"#0063b1"},
|
||
{id:"ct6",name:"Tech Digest", email:"news@techdigest.io", av:"TD",avColor:"#498205"},
|
||
];
|
||
|
||
// ── Utilities ─────────────────────────────────────────────────────────────────
|
||
const quoteBody=(text,from)=>`\n\n— On ${new Date().toDateString()}, ${from} wrote:\n${(text??"").split("\n").map(l=>`> ${l}`).join("\n")}`;
|
||
const fwdBody=e=>`\n\n---------- Forwarded message ----------\nFrom: ${e.from}\nDate: ${e.date}\nSubject: ${e.subject}\nTo: ${e.to}\n\n${e.body??""}`;
|
||
const formatSize=b=>b<1024?b+" B":b<1048576?(b/1024).toFixed(1)+" KB":(b/1048576).toFixed(1)+" MB";
|
||
function getAttIcon(name){
|
||
const ext=(name.split(".").pop()||"").toLowerCase();
|
||
if(["jpg","jpeg","png","gif","svg","webp"].includes(ext))return "ti-photo";
|
||
if(ext==="pdf")return "ti-file-type-pdf";
|
||
if(["doc","docx"].includes(ext))return "ti-file-type-doc";
|
||
if(["xls","xlsx"].includes(ext))return "ti-file-type-xls";
|
||
if(["ppt","pptx"].includes(ext))return "ti-file-type-ppt";
|
||
if(["zip","gz","tar","7z"].includes(ext))return "ti-file-zip";
|
||
if(["eml","msg"].includes(ext))return "ti-mail";
|
||
if(["txt","md"].includes(ext))return "ti-file-text";
|
||
return "ti-file";
|
||
}
|
||
function printAttachment(name,srcEmail){
|
||
const ext=(name.split(".").pop()||"").toLowerCase();
|
||
let html;
|
||
if(ext==="eml"&&srcEmail){
|
||
html=`<!DOCTYPE html><html><head><meta charset="UTF-8"><title>${name}</title>
|
||
<style>body{font-family:Arial,sans-serif;padding:24px;max-width:700px;margin:0 auto}
|
||
.header{border-bottom:1px solid #ccc;padding-bottom:12px;margin-bottom:20px}
|
||
.label{color:#666;font-size:12px;text-transform:uppercase;letter-spacing:.05em}
|
||
.value{font-size:14px;margin-bottom:8px}pre{white-space:pre-wrap;font-family:inherit;font-size:14px;line-height:1.6}</style>
|
||
</head><body>
|
||
<div class="header">
|
||
<div class="label">From</div><div class="value">${srcEmail.from||""} <${srcEmail.from_email||""}></div>
|
||
<div class="label">To</div><div class="value">${srcEmail.to||""}</div>
|
||
<div class="label">Subject</div><div class="value"><strong>${srcEmail.subject||""}</strong></div>
|
||
<div class="label">Date</div><div class="value">${srcEmail.date||""}</div>
|
||
</div>
|
||
<pre>${(srcEmail.body||"").replace(/&/g,"&").replace(/</g,"<").replace(/>/g,">")}</pre>
|
||
</body></html>`;
|
||
} else {
|
||
html=`<!DOCTYPE html><html><head><meta charset="UTF-8"><title>${name}</title>
|
||
<style>body{font-family:Arial,sans-serif;padding:40px;text-align:center;color:#444}
|
||
h2{margin-bottom:12px}.note{font-size:13px;color:#888;margin-top:20px}</style>
|
||
</head><body>
|
||
<h2>📎 ${name}</h2>
|
||
<p class="note">Attachment: ${name}</p>
|
||
<p class="note">From email: ${srcEmail?.subject||"(no subject)"}</p>
|
||
<p class="note">Date: ${new Date().toLocaleString()}</p>
|
||
<p class="note">This is a demo attachment — print preview via DashMail.</p>
|
||
</body></html>`;
|
||
}
|
||
const w=window.open("","_blank","width=800,height=600");
|
||
if(!w)return;
|
||
w.document.write(html);
|
||
w.document.close();
|
||
w.focus();
|
||
setTimeout(()=>{w.print();},400);
|
||
}
|
||
function downloadAttachment(name,srcEmail){
|
||
const ext=(name.split(".").pop()||"").toLowerCase();
|
||
let content,mime;
|
||
if(ext==="eml"&&srcEmail){
|
||
content=[
|
||
`From: ${srcEmail.from||""} <${srcEmail.from_email||""}>`,
|
||
`To: ${srcEmail.to||""}`,
|
||
`Subject: ${srcEmail.subject||""}`,
|
||
`Date: ${srcEmail.date||new Date().toUTCString()}`,
|
||
`MIME-Version: 1.0`,
|
||
`Content-Type: text/plain; charset=UTF-8`,
|
||
``,
|
||
srcEmail.body||""
|
||
].join("\r\n");
|
||
mime="message/rfc822";
|
||
} else {
|
||
content=`Demo attachment: ${name}\nGenerated by DashMail\nDate: ${new Date().toLocaleString()}\n\nThis is a placeholder file. In a live deployment this file would be fetched from the SMB share.`;
|
||
mime="text/plain";
|
||
}
|
||
const blob=new Blob([content],{type:mime});
|
||
const url=URL.createObjectURL(blob);
|
||
const a=document.createElement("a");
|
||
a.href=url; a.download=name; a.click();
|
||
setTimeout(()=>URL.revokeObjectURL(url),5000);
|
||
}
|
||
const uid=()=>Math.random().toString(36).slice(2,9);
|
||
function printEmail(email){
|
||
const w=window.open("","_blank","width=720,height=960");
|
||
w.document.write(`<html><head><title>${email.subject}</title><style>body{font-family:'Segoe UI',sans-serif;max-width:700px;margin:40px auto}h2{margin:0 0 12px}.hdr{border-bottom:1px solid #ddd;padding-bottom:14px;margin-bottom:18px}.row{margin:3px 0;font-size:13.5px}.lbl{color:#666;font-weight:600;display:inline-block;width:60px}.body{font-size:14px;line-height:1.75;white-space:pre-wrap}@media print{body{margin:20px}}</style></head><body><div class="hdr"><h2>${email.subject}</h2><div class="row"><span class="lbl">From:</span>${email.from} <${email.from_email}></div><div class="row"><span class="lbl">To:</span>${email.to}</div><div class="row"><span class="lbl">Date:</span>${email.date}</div>${email.atts?.length?`<div style="margin-top:12px;font-size:13px;color:#666">Attachments: ${email.atts.join(", ")}</div>`:""}</div><div class="body">${(email.body??"").replace(/</g,"<")}</div></body></html>`);
|
||
w.document.close();w.focus();setTimeout(()=>w.print(),400);
|
||
}
|
||
|
||
// ── Base components ───────────────────────────────────────────────────────────
|
||
function Avatar({initials,color,size=36}){
|
||
return <div style={{width:size,height:size,borderRadius:"50%",background:color+"22",border:`1.5px solid ${color}44`,display:"flex",alignItems:"center",justifyContent:"center",fontSize:size*0.33,fontWeight:700,color,fontFamily:FONT,flexShrink:0,userSelect:"none"}}>{initials}</div>;
|
||
}
|
||
function IBtn({icon,title,onClick,C,danger=false,active=false,size=16,label=""}){
|
||
const[h,setH]=useState(false);
|
||
return <button title={title} onClick={onClick} onMouseEnter={()=>setH(true)} onMouseLeave={()=>setH(false)}
|
||
style={{background:active?C.accentBg:h?C.hover:"transparent",border:"none",borderRadius:5,padding:"6px 8px",cursor:"pointer",
|
||
color:danger&&h?C.danger:active?C.accent:C.muted,display:"flex",alignItems:"center",gap:4,transition:"all 0.1s",fontFamily:FONT,fontSize:13}}>
|
||
<i className={`ti ${icon}`} style={{fontSize:size}} aria-hidden="true"/>
|
||
{label&&<span style={{fontSize:12.5}}>{label}</span>}
|
||
</button>;
|
||
}
|
||
function Sep({C}){return <div style={{width:1,height:18,background:C.border,margin:"0 2px",flexShrink:0}}/>;}
|
||
function Toggle({on,onChange,C}){
|
||
const[v,setV]=useState(on??false);
|
||
return <div onClick={()=>{setV(x=>!x);onChange&&onChange(!v);}}
|
||
style={{width:36,height:20,borderRadius:10,background:v?C.accent:C.muted,cursor:"pointer",position:"relative",transition:"background 0.2s",flexShrink:0}}>
|
||
<div style={{position:"absolute",top:2,left:v?18:2,width:16,height:16,borderRadius:"50%",background:"#fff",transition:"left 0.2s",boxShadow:"0 1px 3px rgba(0,0,0,0.3)"}}/>
|
||
</div>;
|
||
}
|
||
function PanelHeader({title,onClose,C}){
|
||
return <div style={{display:"flex",alignItems:"center",justifyContent:"space-between",padding:"14px 18px",borderBottom:`1px solid ${C.border}`,flexShrink:0}}>
|
||
<span style={{fontSize:16,fontWeight:600,color:C.text}}>{title}</span>
|
||
<IBtn icon="ti-x" title="Close" C={C} onClick={onClose} size={18}/>
|
||
</div>;
|
||
}
|
||
|
||
// ── Full Settings Modal ───────────────────────────────────────────────────────
|
||
const SETTINGS_NAV=[
|
||
{id:"general", icon:"ti-settings", label:"General"},
|
||
{id:"theme", icon:"ti-palette", label:"Appearance"},
|
||
{id:"notifications",icon:"ti-bell", label:"Notifications"},
|
||
{id:"accounts", icon:"ti-user-circle", label:"Accounts"},
|
||
{id:"rules", icon:"ti-filter", label:"Filters & Rules"},
|
||
{id:"privacy", icon:"ti-eye", label:"Privacy"},
|
||
{id:"emailaddr", icon:"ti-at", label:"Email addresses"},
|
||
{id:"encryption", icon:"ti-key", label:"Encryption"},
|
||
{id:"spam", icon:"ti-trash", label:"Spam & Trash"},
|
||
{id:"backup", icon:"ti-database", label:"Backup"},
|
||
{id:"advanced", icon:"ti-adjustments", label:"Advanced",keywords:"security smb share server"},
|
||
];
|
||
|
||
// ── Email Addresses Settings ──────────────────────────────────────────────────
|
||
const ACCT_STATS={
|
||
"alex@example-corp.com":{count:1842,size:"312 MB"},
|
||
"sales@example-corp.com":{count:234,size:"41 MB"},
|
||
"j.doe@mailbox.org":{count:89,size:"14 MB"},
|
||
"default":{count:12,size:"2 MB"},
|
||
};
|
||
// Mock: extra folder on the share not yet imported
|
||
const MOCK_NEW_FOLDERS=["invoices@example-corp.com"];
|
||
|
||
function SettingsEmailAddr({accounts,C,smbConfig,onDiscoverMore}){
|
||
const[scanning,setScanning]=useState(false);
|
||
const[scanResult,setScanResult]=useState(null);
|
||
|
||
function runScan(){
|
||
setScanning(true);setScanResult(null);
|
||
setTimeout(()=>{
|
||
setScanning(false);
|
||
const known=new Set(accounts.map(a=>a.email||a.id));
|
||
const newOnes=MOCK_NEW_FOLDERS.filter(e=>!known.has(e));
|
||
setScanResult({found:newOnes.length,folders:newOnes});
|
||
},2000);
|
||
}
|
||
|
||
return <div style={{padding:"22px 28px"}}>
|
||
<h2 style={{fontSize:20,fontWeight:700,color:C.text,margin:"0 0 6px"}}>Email addresses</h2>
|
||
<p style={{fontSize:13,color:C.muted,margin:"0 0 20px",lineHeight:1.5}}>
|
||
Folders imported from your mail server. The SMB share is never modified.
|
||
</p>
|
||
|
||
{/* Imported accounts */}
|
||
<div style={{display:"flex",flexDirection:"column",gap:8,marginBottom:24}}>
|
||
{accounts.map(acct=>{
|
||
const stats=ACCT_STATS[acct.email||acct.id]||{count:0,size:"—"};
|
||
return <div key={acct.id} style={{display:"flex",alignItems:"center",gap:12,
|
||
padding:"12px 14px",background:C.surface2,borderRadius:10,border:`1px solid ${C.border}`}}>
|
||
<div style={{width:38,height:38,borderRadius:9,background:acct.avColor||C.accent,
|
||
display:"flex",alignItems:"center",justifyContent:"center",
|
||
fontSize:13,fontWeight:700,color:"#fff",flexShrink:0,letterSpacing:"0.3px"}}>
|
||
{acct.av}
|
||
</div>
|
||
<div style={{flex:1,minWidth:0}}>
|
||
<div style={{fontSize:13.5,fontWeight:600,color:C.text,marginBottom:2,
|
||
overflow:"hidden",textOverflow:"ellipsis",whiteSpace:"nowrap"}}>
|
||
{acct.email||acct.id}
|
||
</div>
|
||
<div style={{fontSize:12,color:C.muted}}>
|
||
{acct.display&&acct.display!==(acct.email||acct.id)&&
|
||
<span style={{marginRight:8,opacity:0.8}}>{acct.display}</span>}
|
||
{stats.count.toLocaleString()} emails • {stats.size}
|
||
</div>
|
||
</div>
|
||
<div style={{display:"flex",alignItems:"center",gap:5,padding:"3px 10px",
|
||
borderRadius:20,background:"rgba(34,197,94,0.1)",border:"1px solid rgba(34,197,94,0.22)",
|
||
fontSize:11.5,fontWeight:600,color:"#16a34a",flexShrink:0}}>
|
||
<div style={{width:6,height:6,borderRadius:"50%",background:"#22c55e"}}/>
|
||
Active
|
||
</div>
|
||
</div>;
|
||
})}
|
||
</div>
|
||
|
||
{/* Discover more */}
|
||
<div style={{borderTop:`1px solid ${C.border}`,paddingTop:18}}>
|
||
<div style={{fontSize:13,fontWeight:600,color:C.text,marginBottom:3}}>Discover more accounts</div>
|
||
<div style={{fontSize:12.5,color:C.muted,marginBottom:12,lineHeight:1.5}}>
|
||
Scan your mail server for new folders added since your last import.
|
||
</div>
|
||
|
||
{!scanResult&&<button onClick={runScan} disabled={scanning}
|
||
style={{display:"flex",alignItems:"center",gap:8,padding:"9px 16px",
|
||
border:`1px solid ${C.inBorder}`,borderRadius:8,background:"transparent",
|
||
color:scanning?C.muted:C.text,fontSize:13,fontWeight:500,
|
||
cursor:scanning?"default":"pointer",fontFamily:FONT,opacity:scanning?0.7:1}}>
|
||
{scanning
|
||
?<><i className="ti ti-loader-2 ti-spin" style={{fontSize:14}} aria-hidden="true"/>Scanning…</>
|
||
:<><i className="ti ti-radar" style={{fontSize:14}} aria-hidden="true"/>Scan for new accounts</>}
|
||
</button>}
|
||
|
||
{scanResult&&scanResult.found>0&&<div style={{padding:"12px 14px",borderRadius:9,
|
||
background:"rgba(34,197,94,0.07)",border:"1px solid rgba(34,197,94,0.2)"}}>
|
||
<div style={{display:"flex",alignItems:"flex-start",gap:10}}>
|
||
<i className="ti ti-folder-plus" style={{fontSize:18,color:"#22c55e",marginTop:1,flexShrink:0}} aria-hidden="true"/>
|
||
<div style={{flex:1}}>
|
||
<div style={{fontSize:13.5,fontWeight:600,color:C.text,marginBottom:3}}>
|
||
{scanResult.found} new folder{scanResult.found!==1?"s":""} found
|
||
</div>
|
||
<div style={{fontSize:12,color:C.muted,marginBottom:10}}>
|
||
{scanResult.folders.join(", ")}
|
||
</div>
|
||
<button onClick={onDiscoverMore}
|
||
style={{display:"inline-flex",alignItems:"center",gap:7,padding:"8px 16px",
|
||
border:"none",borderRadius:7,background:"#22c55e",
|
||
color:"#fff",fontSize:13,fontWeight:600,cursor:"pointer",fontFamily:FONT}}>
|
||
Set up now <i className="ti ti-arrow-right" style={{fontSize:13}} aria-hidden="true"/>
|
||
</button>
|
||
</div>
|
||
</div>
|
||
</div>}
|
||
|
||
{scanResult&&scanResult.found===0&&<div style={{display:"flex",alignItems:"center",gap:8,
|
||
padding:"11px 14px",borderRadius:9,background:C.surface2,border:`1px solid ${C.border}`,
|
||
fontSize:13,color:C.muted}}>
|
||
<i className="ti ti-circle-check" style={{fontSize:15,color:"#22c55e"}} aria-hidden="true"/>
|
||
No new folders found. Your account list is up to date.
|
||
<button onClick={()=>setScanResult(null)}
|
||
style={{marginLeft:"auto",background:"transparent",border:"none",
|
||
color:C.muted,cursor:"pointer",fontSize:12,fontFamily:FONT,padding:"2px 6px"}}>
|
||
Dismiss
|
||
</button>
|
||
</div>}
|
||
</div>
|
||
</div>;
|
||
}
|
||
|
||
function FullSettings({profile,onSave,onClose,C,accounts,updateAccount,rules,setRules,allFolders,smbConfig,setSmbConfig,loginBg,setLoginBg,loginDark,setLoginDark,onSetupNew}){
|
||
const[section,setSection]=useState("general");
|
||
const[search,setSearch]=useState("");
|
||
const[ls,setLS]=useState({...profile.settings});
|
||
const[profName,setProfName]=useState(profile.name);
|
||
const[profInitials,setProfInitials]=useState(profile.initials);
|
||
const[profColor,setProfColor]=useState(profile.color);
|
||
|
||
const[advancedTab,setAdvancedTab]=useState("security");
|
||
const filtered=search
|
||
?SETTINGS_NAV.filter(n=>
|
||
n.label.toLowerCase().includes(search.toLowerCase())||
|
||
(n.keywords&&n.keywords.toLowerCase().includes(search.toLowerCase())))
|
||
:SETTINGS_NAV;
|
||
|
||
function save(){onSave({...profile,name:profName,initials:profInitials,color:profColor,settings:{...ls}});}
|
||
|
||
// Derive a local theme from ls (for live preview inside settings)
|
||
const lC=mkTheme(ls.isDark,ls.accent);
|
||
|
||
return <div style={{position:"fixed",inset:0,zIndex:300,background:"rgba(0,0,0,0.6)",display:"flex",alignItems:"center",justifyContent:"center",fontFamily:FONT}}>
|
||
<div style={{width:"min(900px,95vw)",height:"min(640px,92vh)",background:C.surface,borderRadius:12,
|
||
border:`1px solid ${C.border}`,boxShadow:`0 20px 60px rgba(0,0,0,${C.surface==="#ffffff"?0.25:0.7})`,
|
||
display:"flex",flexDirection:"column",overflow:"hidden"}}>
|
||
|
||
{/* Header */}
|
||
<div style={{display:"flex",alignItems:"center",justifyContent:"space-between",padding:"16px 20px",borderBottom:`1px solid ${C.border}`,flexShrink:0}}>
|
||
<span style={{fontSize:20,fontWeight:700,color:C.text}}>Settings</span>
|
||
<div style={{display:"flex",gap:4}}>
|
||
<IBtn icon="ti-help" title="Help" C={C} size={18}/>
|
||
<IBtn icon="ti-x" title="Close" C={C} onClick={onClose} size={18}/>
|
||
</div>
|
||
</div>
|
||
|
||
<div style={{display:"flex",flex:1,minHeight:0}}>
|
||
|
||
{/* Left nav */}
|
||
<div style={{width:220,background:C.sidebar,borderRight:`1px solid ${C.border}`,display:"flex",flexDirection:"column",flexShrink:0}}>
|
||
<div style={{padding:"10px 10px 6px"}}>
|
||
<div style={{position:"relative"}}>
|
||
<i className="ti ti-search" style={{position:"absolute",left:9,top:"50%",transform:"translateY(-50%)",fontSize:13,color:C.subtle}} aria-hidden="true"/>
|
||
<input value={search} onChange={e=>setSearch(e.target.value)} placeholder="Search"
|
||
style={{width:"100%",boxSizing:"border-box",padding:"7px 9px 7px 28px",border:`1px solid ${C.inBorder}`,
|
||
borderRadius:6,fontSize:13,fontFamily:FONT,background:C.inputBg,color:C.text,outline:"none"}}/>
|
||
</div>
|
||
</div>
|
||
<div style={{flex:1,overflowY:"auto",padding:"4px 8px"}}>
|
||
{filtered.map(n=>{
|
||
const sel=section===n.id;
|
||
return <div key={n.id} onClick={()=>{setSection(n.id);setSearch("");}}
|
||
style={{display:"flex",alignItems:"center",gap:9,padding:"8px 10px",borderRadius:7,cursor:"pointer",marginBottom:1,
|
||
background:sel?C.selectedBg:"transparent",borderLeft:`3px solid ${sel?C.accent:"transparent"}`}}
|
||
onMouseEnter={e=>{if(!sel)e.currentTarget.style.background=C.hover;}}
|
||
onMouseLeave={e=>{if(!sel)e.currentTarget.style.background="transparent";}}>
|
||
<i className={`ti ${n.icon}`} style={{fontSize:16,color:sel?C.accent:C.muted,width:18,textAlign:"center"}} aria-hidden="true"/>
|
||
<span style={{fontSize:13.5,color:sel?C.accent:C.text,fontWeight:sel?600:400}}>{n.label}</span>
|
||
</div>;
|
||
})}
|
||
</div>
|
||
</div>
|
||
|
||
{/* Content area */}
|
||
<div style={{flex:1,overflowY:"auto",background:C.surface}}>
|
||
<div style={{padding:"22px 28px"}}>
|
||
<h2 style={{fontSize:20,fontWeight:700,color:C.text,margin:"0 0 20px"}}>
|
||
{section==="advanced"
|
||
?SETTINGS_NAV.find(n=>n.id==="advanced")?.label
|
||
:SETTINGS_NAV.find(n=>n.id===section)?.label}
|
||
</h2>
|
||
|
||
{section==="general"&&<SettingsGeneral ls={ls} setLS={setLS} profile={profile} profName={profName} setProfName={setProfName} profInitials={profInitials} setProfInitials={setProfInitials} profColor={profColor} setProfColor={setProfColor} C={C}/>}
|
||
{section==="theme"&&<SettingsTheme ls={ls} setLS={setLS} lC={lC} C={C} loginBg={loginBg} setLoginBg={setLoginBg} loginDark={loginDark} setLoginDark={setLoginDark}/>}
|
||
{section==="notifications"&&<SettingsNotifications ls={ls} setLS={setLS} C={C}/>}
|
||
{section==="accounts"&&<SettingsAccounts accounts={accounts} profile={profile} updateAccount={updateAccount} C={C}/>}
|
||
{section==="rules"&&<RulesInline C={C} rules={rules} setRules={setRules} allFolders={allFolders}/>}
|
||
{section==="privacy"&&<SettingsPrivacy ls={ls} setLS={setLS} C={C}/>}
|
||
{section==="backup"&&<SettingsBackup C={C}/>}
|
||
{section==="spam"&&<SettingsSpamTrash C={C}/>}
|
||
{section==="emailaddr"&&<SettingsEmailAddr accounts={accounts} C={C} smbConfig={smbConfig}
|
||
onDiscoverMore={()=>{onClose();onSetupNew?.();}}/>}
|
||
{section==="encryption"&&<div style={{padding:"20px 0",color:C.muted,fontSize:13.5}}>
|
||
<i className="ti ti-tools" style={{fontSize:32,display:"block",marginBottom:12,color:C.subtle}} aria-hidden="true"/>
|
||
This section will be available in a future update.
|
||
</div>}
|
||
{section==="advanced"&&<>
|
||
{/* Sub-tab bar */}
|
||
<div style={{display:"flex",gap:2,marginBottom:24,background:C.surface2,borderRadius:8,padding:3,width:"fit-content"}}>
|
||
{[{id:"security",icon:"ti-lock",label:"Security"},{id:"smb",icon:"ti-server",label:"SMB Share"}].map(t=>(
|
||
<button key={t.id} onClick={()=>setAdvancedTab(t.id)}
|
||
style={{display:"flex",alignItems:"center",gap:6,padding:"6px 16px",borderRadius:6,border:"none",
|
||
background:advancedTab===t.id?C.surface:"transparent",
|
||
color:advancedTab===t.id?C.text:C.muted,
|
||
cursor:"pointer",fontFamily:FONT,fontSize:13,fontWeight:advancedTab===t.id?600:400,
|
||
boxShadow:advancedTab===t.id?`0 1px 3px rgba(0,0,0,0.12)`:"none",transition:"all 0.15s"}}>
|
||
<i className={`ti ${t.icon}`} style={{fontSize:14}} aria-hidden="true"/>
|
||
{t.label}
|
||
</button>
|
||
))}
|
||
</div>
|
||
{advancedTab==="security"&&<SettingsSecurity ls={ls} setLS={setLS} C={C} profile={profile}/>}
|
||
{advancedTab==="smb"&&<SettingsSMB smbConfig={smbConfig} setSmbConfig={setSmbConfig} C={C}/>}
|
||
</>}
|
||
</div>
|
||
</div>
|
||
</div>
|
||
|
||
{/* Footer */}
|
||
<div style={{display:"flex",justifyContent:"flex-end",gap:8,padding:"12px 20px",borderTop:`1px solid ${C.border}`,flexShrink:0,background:C.surface2}}>
|
||
<button onClick={onClose} style={{background:"transparent",border:`1px solid ${C.border}`,borderRadius:6,padding:"7px 18px",fontSize:13.5,cursor:"pointer",fontFamily:FONT,color:C.muted}}>Cancel</button>
|
||
<button onClick={()=>{save();onClose();}} style={{background:C.accent,color:"#fff",border:"none",borderRadius:6,padding:"7px 20px",fontSize:13.5,cursor:"pointer",fontFamily:FONT,fontWeight:600}}>Save changes</button>
|
||
</div>
|
||
</div>
|
||
</div>;
|
||
}
|
||
|
||
// ── Settings sections ─────────────────────────────────────────────────────────
|
||
function SRow({label,sub,children,C,onClick}){
|
||
return <div onClick={onClick} style={{display:"flex",justifyContent:"space-between",alignItems:"center",padding:"11px 0",borderBottom:`1px solid ${C.border}`,cursor:onClick?"pointer":"default"}}
|
||
onMouseEnter={onClick?e=>{e.currentTarget.style.background=C.hover;}:undefined}
|
||
onMouseLeave={onClick?e=>{e.currentTarget.style.background="transparent";}:undefined}>
|
||
<div>
|
||
<div style={{fontSize:13.5,color:C.text}}>{label}</div>
|
||
{sub&&<div style={{fontSize:12,color:C.subtle,marginTop:2}}>{sub}</div>}
|
||
</div>
|
||
{children}
|
||
</div>;
|
||
}
|
||
function SCard({children,C}){
|
||
return <div style={{background:C.surface2,border:`1px solid ${C.border}`,borderRadius:10,padding:"4px 14px",marginBottom:18}}>{children}</div>;
|
||
}
|
||
function SectionHead({label,C}){
|
||
return <div style={{fontSize:11,fontWeight:700,color:C.accent,letterSpacing:"0.08em",textTransform:"uppercase",marginBottom:10,marginTop:20}}>{label}</div>;
|
||
}
|
||
|
||
function SettingsGeneral({ls,setLS,profile,profName,setProfName,profInitials,setProfInitials,profColor,setProfColor,C}){
|
||
const[pdOpen,setPdOpen]=useState(false);
|
||
const[firstName,setFirstName]=useState(()=>profName.split(" ")[0]||"");
|
||
const[lastName,setLastName]=useState(()=>profName.split(" ").slice(1).join(" ")||"");
|
||
const[company,setCompany]=useState("");
|
||
const[street,setStreet]=useState("");
|
||
const[zip,setZip]=useState("");
|
||
const[city,setCity]=useState("");
|
||
const[country,setCountry]=useState("United States of America");
|
||
const[phone,setPhone]=useState("");
|
||
const[pdSaved,setPdSaved]=useState(false);
|
||
|
||
const COUNTRIES=["United States of America","Canada","United Kingdom","Australia","Germany","France","Netherlands","New Zealand","Other"];
|
||
|
||
const fieldSt={width:"100%",boxSizing:"border-box",padding:"7px 10px",border:`1px solid ${C.inBorder}`,
|
||
borderRadius:5,fontSize:13,fontFamily:FONT,outline:"none",color:C.text,background:C.inputBg};
|
||
const lbSt={display:"block",fontSize:12,fontWeight:600,color:C.muted,marginBottom:4};
|
||
|
||
function savePD(){
|
||
const full=(firstName+" "+lastName).trim();
|
||
if(full)setProfName(full);
|
||
setPdSaved(true);
|
||
setTimeout(()=>setPdSaved(false),2500);
|
||
}
|
||
|
||
return <>
|
||
<SCard C={C}>
|
||
<SRow label="Personal data" sub="Name and display preferences" C={C} onClick={()=>setPdOpen(p=>!p)}>
|
||
<i className={`ti ti-chevron-${pdOpen?"up":"down"}`} style={{fontSize:16,color:C.subtle}} aria-hidden="true"/>
|
||
</SRow>
|
||
{pdOpen&&<div style={{padding:"16px 0 10px",display:"flex",flexDirection:"column",gap:12}}>
|
||
|
||
{/* Name row */}
|
||
<div style={{display:"flex",gap:12}}>
|
||
<div style={{flex:1}}>
|
||
<label style={lbSt}>First name</label>
|
||
<input value={firstName} onChange={e=>setFirstName(e.target.value)} style={fieldSt}/>
|
||
</div>
|
||
<div style={{flex:1}}>
|
||
<label style={lbSt}>Last name</label>
|
||
<input value={lastName} onChange={e=>setLastName(e.target.value)} style={fieldSt}/>
|
||
</div>
|
||
</div>
|
||
|
||
{/* Initials + Avatar color */}
|
||
<div style={{display:"flex",gap:12,alignItems:"flex-start"}}>
|
||
<div style={{width:80}}>
|
||
<label style={lbSt}>Initials</label>
|
||
<input value={profInitials} onChange={e=>setProfInitials(e.target.value.toUpperCase().slice(0,2))} maxLength={2}
|
||
style={{...fieldSt,width:"100%",textAlign:"center",fontWeight:700,letterSpacing:"0.05em"}}/>
|
||
</div>
|
||
<div style={{flex:1}}>
|
||
<label style={lbSt}>Avatar color</label>
|
||
<div style={{display:"flex",gap:7,flexWrap:"wrap",paddingTop:4}}>
|
||
{ACCENTS.map(a=>(
|
||
<div key={a} onClick={()=>setProfColor(a)} style={{width:24,height:24,borderRadius:5,background:a,
|
||
border:`2.5px solid ${a===profColor?"#fff":"transparent"}`,outline:a===profColor?`2px solid ${a}`:"none",
|
||
cursor:"pointer",transition:"transform 0.1s"}}
|
||
onMouseEnter={e=>e.currentTarget.style.transform="scale(1.15)"}
|
||
onMouseLeave={e=>e.currentTarget.style.transform="scale(1)"}/>
|
||
))}
|
||
</div>
|
||
</div>
|
||
</div>
|
||
|
||
{/* Avatar preview */}
|
||
<div style={{display:"flex",alignItems:"center",gap:10,padding:"9px 12px",background:C.accentBg,borderRadius:7}}>
|
||
<Avatar initials={profInitials||"?"} color={profColor} size={34}/>
|
||
<div>
|
||
<div style={{fontSize:13,fontWeight:600,color:C.text}}>{(firstName+" "+lastName).trim()||"Your name"}</div>
|
||
<div style={{fontSize:12,color:C.subtle}}>{profile.accountIds?.[0]||""}</div>
|
||
</div>
|
||
</div>
|
||
|
||
{/* Company */}
|
||
<div>
|
||
<label style={lbSt}>Company</label>
|
||
<input value={company} onChange={e=>setCompany(e.target.value)} style={fieldSt}/>
|
||
</div>
|
||
|
||
{/* Street */}
|
||
<div>
|
||
<label style={lbSt}>Street and number</label>
|
||
<input value={street} onChange={e=>setStreet(e.target.value)} style={fieldSt}/>
|
||
</div>
|
||
|
||
{/* ZIP + City */}
|
||
<div style={{display:"flex",gap:12}}>
|
||
<div style={{width:110}}>
|
||
<label style={lbSt}>ZIP code</label>
|
||
<input value={zip} onChange={e=>setZip(e.target.value)} style={fieldSt}/>
|
||
</div>
|
||
<div style={{flex:1}}>
|
||
<label style={lbSt}>City</label>
|
||
<input value={city} onChange={e=>setCity(e.target.value)} style={fieldSt}/>
|
||
</div>
|
||
</div>
|
||
|
||
{/* Country */}
|
||
<div>
|
||
<label style={lbSt}>Country</label>
|
||
<select value={country} onChange={e=>setCountry(e.target.value)}
|
||
style={{...fieldSt,appearance:"auto"}}>
|
||
{COUNTRIES.map(c=><option key={c}>{c}</option>)}
|
||
</select>
|
||
</div>
|
||
|
||
{/* Phone */}
|
||
<div>
|
||
<label style={lbSt}>Phone</label>
|
||
<input type="tel" value={phone} onChange={e=>setPhone(e.target.value)} style={fieldSt}/>
|
||
</div>
|
||
|
||
{/* Save */}
|
||
<div style={{display:"flex",alignItems:"center",gap:10,paddingTop:2}}>
|
||
<button onClick={savePD}
|
||
style={{background:C.accent,color:"#fff",border:"none",borderRadius:6,padding:"7px 20px",
|
||
fontSize:13,cursor:"pointer",fontFamily:FONT,fontWeight:600}}>Save</button>
|
||
{pdSaved&&<span style={{fontSize:12.5,color:"#22c55e",display:"flex",alignItems:"center",gap:4,fontWeight:600}}>
|
||
<i className="ti ti-circle-check" style={{fontSize:14}} aria-hidden="true"/>Saved
|
||
</span>}
|
||
</div>
|
||
|
||
</div>}
|
||
</SCard>
|
||
<SectionHead label="Display" C={C}/>
|
||
<SCard C={C}>
|
||
<SRow label="Language" sub="Interface language" C={C}>
|
||
<select style={{background:C.inputBg,border:`1px solid ${C.inBorder}`,borderRadius:5,padding:"4px 8px",color:C.text,fontFamily:FONT,fontSize:13,outline:"none"}}>
|
||
<option>English</option><option>Deutsch</option><option>Español</option><option>Français</option>
|
||
</select>
|
||
</SRow>
|
||
<SRow label="Date format" C={C}>
|
||
<select style={{background:C.inputBg,border:`1px solid ${C.inBorder}`,borderRadius:5,padding:"4px 8px",color:C.text,fontFamily:FONT,fontSize:13,outline:"none"}}>
|
||
<option>MM/DD/YYYY</option><option>DD/MM/YYYY</option><option>YYYY-MM-DD</option>
|
||
</select>
|
||
</SRow>
|
||
<SRow label="Time format" C={C}>
|
||
<select style={{background:C.inputBg,border:`1px solid ${C.inBorder}`,borderRadius:5,padding:"4px 8px",color:C.text,fontFamily:FONT,fontSize:13,outline:"none"}}>
|
||
<option>12-hour (AM/PM)</option><option>24-hour</option>
|
||
</select>
|
||
</SRow>
|
||
</SCard>
|
||
<SectionHead label="Reading" C={C}/>
|
||
<SCard C={C}>
|
||
<SRow label="Auto-mark as read" sub="Mark emails as read when opened" C={C}><Toggle on={ls.autoMark} onChange={v=>setLS(p=>({...p,autoMark:v}))} C={C}/></SRow>
|
||
<SRow label="Preview lines" sub="Lines of preview text in the list" C={C}>
|
||
<select style={{background:C.inputBg,border:`1px solid ${C.inBorder}`,borderRadius:5,padding:"4px 8px",color:C.text,fontFamily:FONT,fontSize:13,outline:"none"}}>
|
||
<option>1 line</option><option>2 lines</option><option>3 lines</option>
|
||
</select>
|
||
</SRow>
|
||
</SCard>
|
||
</>;
|
||
}
|
||
|
||
|
||
function SettingsTheme({ls,setLS,lC,C,loginBg,setLoginBg,loginDark,setLoginDark}){
|
||
const loginBgRef=useRef();
|
||
const customAccent=!ACCENTS.includes(ls.accent);
|
||
const customBg=ls.bg&&ls.bg.startsWith("#")&&!BG_SOLID.some(s=>s.preview.toLowerCase()===ls.bg.toLowerCase());
|
||
function bgIsDarkHex(hex){
|
||
if(!/^#[0-9a-f]{6}$/i.test(hex))return false;
|
||
const r=parseInt(hex.slice(1,3),16),g=parseInt(hex.slice(3,5),16),b=parseInt(hex.slice(5,7),16);
|
||
return (r*0.299+g*0.587+b*0.114)<128;
|
||
}
|
||
return <>
|
||
{/* Accent colors */}
|
||
<SectionHead label="Accent colors" C={C}/>
|
||
<div style={{display:"flex",gap:11,marginBottom:14,flexWrap:"wrap",alignItems:"center"}}>
|
||
{ACCENTS.map(a=>(
|
||
<div key={a} onClick={()=>setLS(p=>({...p,accent:a}))}
|
||
style={{width:36,height:36,borderRadius:"50%",background:a,cursor:"pointer",
|
||
border:`3px solid ${a===ls.accent?"#fff":"transparent"}`,
|
||
outline:a===ls.accent?`2.5px solid ${a}`:"none",transition:"transform 0.1s",boxSizing:"border-box"}}
|
||
onMouseEnter={e=>e.currentTarget.style.transform="scale(1.15)"}
|
||
onMouseLeave={e=>e.currentTarget.style.transform="scale(1)"}/>
|
||
))}
|
||
{/* Custom accent picker */}
|
||
<label style={{position:"relative",width:36,height:36,borderRadius:"50%",cursor:"pointer",
|
||
border:`3px solid ${customAccent?ls.accent:"transparent"}`,
|
||
outline:customAccent?`2.5px solid ${ls.accent}`:`2px dashed ${C.muted}`,
|
||
boxSizing:"border-box",overflow:"hidden",display:"flex",alignItems:"center",justifyContent:"center",
|
||
background:customAccent?ls.accent:"transparent"}}>
|
||
<input type="color" value={customAccent?ls.accent:"#ff8800"}
|
||
onChange={e=>setLS(p=>({...p,accent:e.target.value}))}
|
||
style={{position:"absolute",inset:0,opacity:0,cursor:"pointer",width:"100%",height:"100%",border:"none",padding:0}}/>
|
||
{!customAccent&&<i className="ti ti-plus" style={{fontSize:14,color:C.muted,pointerEvents:"none"}} aria-hidden="true"/>}
|
||
</label>
|
||
</div>
|
||
<div style={{fontSize:12,color:C.subtle,marginBottom:22}}>
|
||
Current accent: <code style={{fontSize:12,color:ls.accent,fontWeight:600}}>{ls.accent.toUpperCase()}</code>
|
||
{customAccent&&<span style={{marginLeft:8,fontSize:11,color:C.muted}}>· custom</span>}
|
||
</div>
|
||
|
||
{/* Backgrounds */}
|
||
<SectionHead label="Backgrounds" C={C}/>
|
||
<div style={{display:"flex",flexWrap:"wrap",gap:14,marginBottom:14,alignItems:"flex-start"}}>
|
||
{BG_SOLID.map(bg=>(
|
||
<div key={bg.id} onClick={()=>setLS(p=>({...p,bg:bg.id,isDark:bg.dark}))}
|
||
style={{display:"flex",flexDirection:"column",alignItems:"center",gap:7,cursor:"pointer"}}>
|
||
<div style={{width:90,height:60,borderRadius:8,background:bg.preview,
|
||
border:`2.5px solid ${ls.bg===bg.id?C.accent:C.border}`,
|
||
boxShadow:ls.bg===bg.id?`0 0 0 2px ${C.accent}40`:"none",transition:"all 0.15s"}}>
|
||
{bg.dark&&<div style={{width:"100%",height:"100%",display:"flex",alignItems:"center",justifyContent:"center",borderRadius:6}}>
|
||
<i className="ti ti-moon" style={{fontSize:18,color:"rgba(255,255,255,0.2)"}} aria-hidden="true"/>
|
||
</div>}
|
||
</div>
|
||
<span style={{fontSize:12,color:ls.bg===bg.id?C.accent:C.muted,fontWeight:ls.bg===bg.id?600:400,textDecoration:ls.bg===bg.id?"underline":"none",textUnderlineOffset:2}}>{bg.label}</span>
|
||
</div>
|
||
))}
|
||
{/* Custom background picker */}
|
||
<label style={{display:"flex",flexDirection:"column",alignItems:"center",gap:7,cursor:"pointer",position:"relative"}}>
|
||
<div style={{width:90,height:60,borderRadius:8,position:"relative",overflow:"hidden",
|
||
border:customBg?`2.5px solid ${C.accent}`:`2px dashed ${C.muted}`,
|
||
boxShadow:customBg?`0 0 0 2px ${C.accent}40`:"none",
|
||
background:customBg?ls.bg:"transparent"}}>
|
||
<input type="color" value={customBg?ls.bg:"#888888"}
|
||
onChange={e=>setLS(p=>({...p,bg:e.target.value,isDark:bgIsDarkHex(e.target.value)}))}
|
||
style={{position:"absolute",inset:0,opacity:0,cursor:"pointer",width:"100%",height:"100%",border:"none",padding:0}}/>
|
||
{!customBg&&<div style={{position:"absolute",inset:0,display:"flex",alignItems:"center",justifyContent:"center",pointerEvents:"none"}}>
|
||
<i className="ti ti-plus" style={{fontSize:18,color:C.muted}} aria-hidden="true"/>
|
||
</div>}
|
||
</div>
|
||
<span style={{fontSize:12,color:customBg?C.accent:C.muted,fontWeight:customBg?600:400,textDecoration:customBg?"underline":"none",textUnderlineOffset:2}}>Custom</span>
|
||
</label>
|
||
</div>
|
||
|
||
{/* Live preview strip */}
|
||
<SectionHead label="Preview" C={C}/>
|
||
<div style={{height:60,borderRadius:10,border:`1px solid ${C.border}`,overflow:"hidden",marginBottom:22,...getBgStyle(ls.bg),background:customBg?ls.bg:undefined,display:"flex",alignItems:"center",justifyContent:"center"}}>
|
||
<span style={{fontSize:13,fontWeight:600,color:(customBg?bgIsDarkHex(ls.bg):bgIsDark(ls.bg)||bgIsImage(ls.bg))?"rgba(255,255,255,0.8)":"rgba(0,0,0,0.4)",background:"rgba(0,0,0,0.15)",padding:"4px 14px",borderRadius:20,backdropFilter:"blur(4px)"}}>Reading pane preview</span>
|
||
</div>
|
||
|
||
{/* Login background */}
|
||
<SectionHead label="Login background" C={C}/>
|
||
<SCard C={C}>
|
||
<SRow label="Login page theme" sub="Controls the appearance of the sign-in screen" C={C}>
|
||
<div style={{display:"flex",gap:4,background:C.surface3||C.surface2,borderRadius:7,padding:3}}>
|
||
{[{v:true,icon:"ti-moon",label:"Dark"},{v:false,icon:"ti-sun",label:"Light"}].map(opt=>(
|
||
<button key={String(opt.v)} onClick={()=>setLoginDark(opt.v)}
|
||
style={{display:"flex",alignItems:"center",gap:5,padding:"5px 13px",borderRadius:5,border:"none",
|
||
background:loginDark===opt.v?C.surface:"transparent",
|
||
color:loginDark===opt.v?C.text:C.muted,
|
||
cursor:"pointer",fontFamily:FONT,fontSize:12.5,fontWeight:loginDark===opt.v?600:400,
|
||
boxShadow:loginDark===opt.v?`0 1px 3px rgba(0,0,0,0.1)`:"none",transition:"all 0.15s"}}>
|
||
<i className={`ti ${opt.icon}`} style={{fontSize:13}} aria-hidden="true"/>
|
||
{opt.label}
|
||
</button>
|
||
))}
|
||
</div>
|
||
</SRow>
|
||
</SCard>
|
||
<div style={{display:"flex",alignItems:"center",gap:16,marginBottom:8}}>
|
||
{/* Thumbnail */}
|
||
<div style={{width:120,height:80,borderRadius:8,overflow:"hidden",border:`1px solid ${C.border}`,flexShrink:0,position:"relative",background:"#0c0c0c"}}>
|
||
<img src={loginBg||DEFAULT_LOGIN_BG} style={{width:"100%",height:"100%",objectFit:"cover"}} alt="Login background"/>
|
||
</div>
|
||
<div style={{display:"flex",flexDirection:"column",gap:8}}>
|
||
<div style={{fontSize:13,color:C.text,fontWeight:500}}>{loginBg?"Custom image":"Default mountain photo"}</div>
|
||
<div style={{display:"flex",gap:8}}>
|
||
<input ref={loginBgRef} type="file" accept="image/*" style={{display:"none"}}
|
||
onChange={e=>{
|
||
const file=e.target.files?.[0];
|
||
if(!file)return;
|
||
const reader=new FileReader();
|
||
reader.onload=ev=>setLoginBg(ev.target.result);
|
||
reader.readAsDataURL(file);
|
||
e.target.value="";
|
||
}}/>
|
||
<button onClick={()=>loginBgRef.current?.click()}
|
||
style={{padding:"6px 14px",borderRadius:6,border:`1px solid ${C.border}`,background:C.surface2,
|
||
color:C.text,cursor:"pointer",fontFamily:FONT,fontSize:13,display:"flex",alignItems:"center",gap:6}}>
|
||
<i className="ti ti-upload" style={{fontSize:14}} aria-hidden="true"/>
|
||
Upload image
|
||
</button>
|
||
{loginBg&&<button onClick={()=>setLoginBg(null)}
|
||
style={{padding:"6px 14px",borderRadius:6,border:`1px solid ${C.border}`,background:"transparent",
|
||
color:C.muted,cursor:"pointer",fontFamily:FONT,fontSize:13,display:"flex",alignItems:"center",gap:6}}>
|
||
<i className="ti ti-refresh" style={{fontSize:14}} aria-hidden="true"/>
|
||
Reset to default
|
||
</button>}
|
||
</div>
|
||
<span style={{fontSize:12,color:C.subtle}}>JPG, PNG, WEBP · shown on the login screen right panel</span>
|
||
</div>
|
||
</div>
|
||
</>;
|
||
}
|
||
|
||
function SettingsNotifications({ls,setLS,C}){
|
||
return <SCard C={C}>
|
||
<SRow label="Desktop notifications" sub="Show popup for new messages" C={C}><Toggle on={ls.notifications} onChange={v=>setLS(p=>({...p,notifications:v}))} C={C}/></SRow>
|
||
<SRow label="Notification sound" sub="Play a sound on new mail" C={C}><Toggle on={ls.sound} onChange={v=>setLS(p=>({...p,sound:v}))} C={C}/></SRow>
|
||
<SRow label="Badge count" sub="Show unread count on taskbar" C={C}><Toggle on={true} C={C}/></SRow>
|
||
<SRow label="Notification preview" sub="Show sender and subject in popup" C={C}><Toggle on={true} C={C}/></SRow>
|
||
<SRow label="Do not disturb" sub="Silence all notifications" C={C}><Toggle on={false} C={C}/></SRow>
|
||
</SCard>;
|
||
}
|
||
|
||
function SettingsAccounts({accounts,profile,updateAccount,C}){
|
||
const[editingId,setEditingId]=useState(null);
|
||
const[draft,setDraft]=useState({});
|
||
|
||
const[emailErr,setEmailErr]=useState("");
|
||
|
||
function startEdit(acc){
|
||
setEditingId(acc.id);
|
||
setEmailErr("");
|
||
setDraft({display:acc.display,av:acc.av,avColor:acc.avColor,email:acc.email??acc.id});
|
||
}
|
||
function cancelEdit(){setEditingId(null);setDraft({});setEmailErr("");}
|
||
function saveEdit(id){
|
||
const trimmed=draft.email.trim();
|
||
if(trimmed&&!/^[^\s@]+@[^\s@]+\.[^\s@]+$/.test(trimmed)){
|
||
setEmailErr("Enter a valid email address.");return;
|
||
}
|
||
updateAccount(id,{display:draft.display,av:draft.av,avColor:draft.avColor,email:trimmed||null});
|
||
setEditingId(null);setDraft({});setEmailErr("");
|
||
}
|
||
|
||
return <>
|
||
<p style={{fontSize:13,color:C.muted,marginBottom:14,lineHeight:1.7}}>
|
||
Accounts in this profile: {profile.accountIds.length}. All accounts read from <code style={{fontSize:12,background:C.surface2,padding:"1px 6px",borderRadius:4,color:C.accent}}>.Mails/</code> on the SMB share.
|
||
</p>
|
||
{accounts.map(acc=>{
|
||
const isEditing=editingId===acc.id;
|
||
return <div key={acc.id} style={{background:C.surface2,border:`1px solid ${isEditing?C.accent:C.border}`,borderRadius:9,marginBottom:8,overflow:"hidden",transition:"border-color 0.15s"}}>
|
||
{/* Row header */}
|
||
<div style={{display:"flex",alignItems:"center",gap:12,padding:"10px 14px"}}>
|
||
<Avatar initials={isEditing?(draft.av||"?"):acc.av} color={isEditing?draft.avColor:acc.avColor} size={36}/>
|
||
<div style={{flex:1}}>
|
||
<div style={{fontSize:13.5,fontWeight:600,color:C.text}}>{isEditing?draft.display:acc.display}</div>
|
||
<div style={{fontSize:12,color:C.subtle}}>{isEditing?(draft.email||"No email set"):(acc.email??("Default account · .Mails/default/"))}</div>
|
||
</div>
|
||
{!isEditing&&<IBtn icon="ti-pencil" title="Edit account" C={C} onClick={()=>startEdit(acc)}/>}
|
||
{isEditing&&<IBtn icon="ti-x" title="Cancel" C={C} onClick={cancelEdit}/>}
|
||
</div>
|
||
|
||
{/* Inline edit form */}
|
||
{isEditing&&<div style={{padding:"0 14px 14px",borderTop:`1px solid ${C.border}`}}>
|
||
<div style={{marginTop:12,marginBottom:12}}>
|
||
<label style={{display:"block",fontSize:12.5,fontWeight:600,color:C.muted,marginBottom:5}}>Email address</label>
|
||
<input value={draft.email} onChange={e=>{setDraft(p=>({...p,email:e.target.value}));setEmailErr("");}}
|
||
placeholder="you@example.com" type="email"
|
||
style={{width:"100%",boxSizing:"border-box",padding:"7px 10px",
|
||
border:`1px solid ${emailErr?C.danger??'#c50f1f':C.inBorder}`,borderRadius:5,
|
||
fontSize:13.5,fontFamily:FONT,outline:"none",color:C.text,background:C.inputBg}}/>
|
||
{emailErr&&<div style={{fontSize:12,color:C.danger??"#c50f1f",marginTop:4}}>{emailErr}</div>}
|
||
</div>
|
||
<div style={{display:"flex",gap:12,marginBottom:12}}>
|
||
<div style={{flex:1}}>
|
||
<label style={{display:"block",fontSize:12.5,fontWeight:600,color:C.muted,marginBottom:5}}>Display name</label>
|
||
<input value={draft.display} onChange={e=>setDraft(p=>({...p,display:e.target.value}))}
|
||
style={{width:"100%",boxSizing:"border-box",padding:"7px 10px",border:`1px solid ${C.inBorder}`,borderRadius:5,fontSize:13.5,fontFamily:FONT,outline:"none",color:C.text,background:C.inputBg}}/>
|
||
</div>
|
||
<div>
|
||
<label style={{display:"block",fontSize:12.5,fontWeight:600,color:C.muted,marginBottom:5}}>Initials</label>
|
||
<input value={draft.av} onChange={e=>setDraft(p=>({...p,av:e.target.value.toUpperCase().slice(0,2)}))} maxLength={2}
|
||
style={{width:56,padding:"7px 10px",border:`1px solid ${C.inBorder}`,borderRadius:5,fontSize:13.5,fontFamily:FONT,outline:"none",color:C.text,background:C.inputBg,textAlign:"center",fontWeight:700,boxSizing:"border-box"}}/>
|
||
</div>
|
||
</div>
|
||
<label style={{display:"block",fontSize:12.5,fontWeight:600,color:C.muted,marginBottom:8}}>Avatar color</label>
|
||
<div style={{display:"flex",gap:7,flexWrap:"wrap",marginBottom:14}}>
|
||
{ACCENTS.map(a=>(
|
||
<div key={a} onClick={()=>setDraft(p=>({...p,avColor:a}))}
|
||
style={{width:24,height:24,borderRadius:5,background:a,cursor:"pointer",
|
||
border:`2.5px solid ${a===draft.avColor?"#fff":"transparent"}`,
|
||
outline:a===draft.avColor?`2px solid ${a}`:"none",transition:"transform 0.1s"}}
|
||
onMouseEnter={e=>e.currentTarget.style.transform="scale(1.15)"}
|
||
onMouseLeave={e=>e.currentTarget.style.transform="scale(1)"}/>
|
||
))}
|
||
</div>
|
||
<div style={{display:"flex",gap:8}}>
|
||
<button onClick={()=>saveEdit(acc.id)}
|
||
style={{background:C.accent,color:"#fff",border:"none",borderRadius:5,padding:"6px 16px",fontSize:13,cursor:"pointer",fontFamily:FONT,fontWeight:600}}>
|
||
Save
|
||
</button>
|
||
<button onClick={cancelEdit}
|
||
style={{background:"transparent",border:`1px solid ${C.border}`,borderRadius:5,padding:"6px 12px",fontSize:13,cursor:"pointer",fontFamily:FONT,color:C.muted}}>
|
||
Cancel
|
||
</button>
|
||
</div>
|
||
</div>}
|
||
</div>;
|
||
})}
|
||
</>;
|
||
}
|
||
|
||
|
||
function SettingsSecurity({ls,setLS,C,profile}){
|
||
const pinEnabled=!!(ls.pin);
|
||
const[flow,setFlow]=useState(null); // null | "setup" | "disable"
|
||
const[newPin,setNewPin]=useState("");
|
||
const[confirmPin,setConfirmPin]=useState("");
|
||
const[currentPin,setCurrentPin]=useState("");
|
||
const[pinErr,setPinErr]=useState("");
|
||
const[showDeleteDialog,setShowDeleteDialog]=useState(false);
|
||
const[deleteStep,setDeleteStep]=useState(1); // 1=warn, 2=confirm type name, 3=done
|
||
const[deleteInput,setDeleteInput]=useState("");
|
||
const CONFIRM_WORD="DELETE";
|
||
|
||
function handleToggle(v){
|
||
if(v){setFlow("setup");setNewPin("");setConfirmPin("");setPinErr("");}
|
||
else{setFlow("disable");setCurrentPin("");setPinErr("");}
|
||
}
|
||
function submitSetup(){
|
||
if(!/^\d{4,6}$/.test(newPin)){setPinErr("PIN must be 4–6 digits.");return;}
|
||
if(newPin!==confirmPin){setPinErr("PINs don't match.");return;}
|
||
setLS(p=>({...p,pin:newPin}));
|
||
setFlow(null);setNewPin("");setConfirmPin("");setPinErr("");
|
||
}
|
||
function submitDisable(){
|
||
if(currentPin!==ls.pin){setPinErr("Incorrect PIN.");return;}
|
||
setLS(p=>({...p,pin:null}));
|
||
setFlow(null);setCurrentPin("");setPinErr("");
|
||
}
|
||
function cancelFlow(){setFlow(null);setNewPin("");setConfirmPin("");setCurrentPin("");setPinErr("");}
|
||
|
||
const pinInputSt={padding:"8px 12px",border:`1px solid ${C.inBorder}`,borderRadius:5,
|
||
fontSize:18,fontFamily:"monospace",outline:"none",color:C.text,background:C.inputBg,
|
||
width:150,boxSizing:"border-box",letterSpacing:6,textAlign:"center"};
|
||
|
||
return <><SCard C={C}>
|
||
<SRow label="Profile PIN" sub={pinEnabled?"PIN active — required at login":"Require a PIN to access this profile"} C={C}>
|
||
<Toggle on={pinEnabled} onChange={handleToggle} C={C}/>
|
||
</SRow>
|
||
|
||
{flow==="setup"&&<div style={{padding:"14px 0 10px"}}>
|
||
<div style={{fontSize:13,fontWeight:700,color:C.accent,marginBottom:14}}>Set up PIN</div>
|
||
<div style={{display:"flex",flexDirection:"column",gap:12}}>
|
||
<div>
|
||
<label style={{display:"block",fontSize:12.5,fontWeight:600,color:C.muted,marginBottom:6}}>New PIN (4–6 digits)</label>
|
||
<input type="password" inputMode="numeric" maxLength={6} value={newPin}
|
||
onChange={e=>{setNewPin(e.target.value.replace(/\D/g,""));setPinErr("");}}
|
||
style={pinInputSt} placeholder="••••"/>
|
||
</div>
|
||
<div>
|
||
<label style={{display:"block",fontSize:12.5,fontWeight:600,color:C.muted,marginBottom:6}}>Confirm PIN</label>
|
||
<input type="password" inputMode="numeric" maxLength={6} value={confirmPin}
|
||
onChange={e=>{setConfirmPin(e.target.value.replace(/\D/g,""));setPinErr("");}}
|
||
style={pinInputSt} placeholder="••••"/>
|
||
</div>
|
||
{pinErr&&<div style={{fontSize:12.5,color:"#c50f1f"}}>{pinErr}</div>}
|
||
<div style={{display:"flex",gap:8}}>
|
||
<button onClick={submitSetup}
|
||
style={{background:C.accent,color:"#fff",border:"none",borderRadius:5,padding:"7px 18px",fontSize:13,cursor:"pointer",fontFamily:FONT,fontWeight:600}}>
|
||
Set PIN
|
||
</button>
|
||
<button onClick={cancelFlow}
|
||
style={{background:"transparent",border:`1px solid ${C.border}`,borderRadius:5,padding:"7px 12px",fontSize:13,cursor:"pointer",fontFamily:FONT,color:C.muted}}>
|
||
Cancel
|
||
</button>
|
||
</div>
|
||
</div>
|
||
</div>}
|
||
|
||
{flow==="disable"&&<div style={{padding:"14px 0 10px"}}>
|
||
<div style={{fontSize:13,fontWeight:700,color:C.accent,marginBottom:14}}>Enter current PIN to disable</div>
|
||
<input type="password" inputMode="numeric" maxLength={6} value={currentPin}
|
||
onChange={e=>{setCurrentPin(e.target.value.replace(/\D/g,""));setPinErr("");}}
|
||
style={pinInputSt} placeholder="••••"/>
|
||
{pinErr&&<div style={{fontSize:12.5,color:"#c50f1f",marginTop:6}}>{pinErr}</div>}
|
||
<div style={{display:"flex",gap:8,marginTop:12}}>
|
||
<button onClick={submitDisable}
|
||
style={{background:"#c50f1f",color:"#fff",border:"none",borderRadius:5,padding:"7px 18px",fontSize:13,cursor:"pointer",fontFamily:FONT,fontWeight:600}}>
|
||
Disable PIN
|
||
</button>
|
||
<button onClick={cancelFlow}
|
||
style={{background:"transparent",border:`1px solid ${C.border}`,borderRadius:5,padding:"7px 12px",fontSize:13,cursor:"pointer",fontFamily:FONT,color:C.muted}}>
|
||
Cancel
|
||
</button>
|
||
</div>
|
||
</div>}
|
||
|
||
<SRow label="Auto-lock" sub="Lock after inactivity" C={C}>
|
||
<select style={{background:C.inputBg,border:`1px solid ${C.inBorder}`,borderRadius:5,padding:"4px 8px",color:C.text,fontFamily:FONT,fontSize:13,outline:"none"}}>
|
||
<option>Never</option><option>5 minutes</option><option>15 minutes</option><option>1 hour</option>
|
||
</select>
|
||
</SRow>
|
||
<SRow label="Clear cache on lock" sub="Remove cached emails when auto-locked" C={C}><Toggle on={false} C={C}/></SRow>
|
||
</SCard>
|
||
|
||
{/* ── Danger Zone ── */}
|
||
<div style={{marginTop:32,border:"1.5px solid #fca5a5",borderRadius:10,overflow:"hidden"}}>
|
||
{/* Header */}
|
||
<div style={{display:"flex",alignItems:"center",gap:8,padding:"10px 16px",background:"#fef2f2",borderBottom:"1.5px solid #fca5a5"}}>
|
||
<i className="ti ti-alert-triangle" style={{fontSize:15,color:"#c50f1f",flexShrink:0}} aria-hidden="true"/>
|
||
<span style={{fontSize:12,fontWeight:700,color:"#c50f1f",letterSpacing:"0.06em",textTransform:"uppercase"}}>Danger Zone</span>
|
||
</div>
|
||
{/* Delete profile row */}
|
||
<div style={{display:"flex",justifyContent:"space-between",alignItems:"center",padding:"14px 16px",background:C.surface}}>
|
||
<div>
|
||
<div style={{fontSize:13.5,fontWeight:600,color:C.text}}>Delete this profile</div>
|
||
<div style={{fontSize:12,color:C.muted,marginTop:3,lineHeight:1.5}}>
|
||
Permanently removes this profile and all locally cached email data.<br/>
|
||
<strong style={{color:"#c50f1f"}}>The SMB share and its files are never modified.</strong>
|
||
</div>
|
||
</div>
|
||
<button onClick={()=>{setShowDeleteDialog(true);setDeleteStep(1);setDeleteInput("");}}
|
||
style={{background:"transparent",border:"1.5px solid #c50f1f",borderRadius:6,padding:"7px 16px",
|
||
fontSize:13,cursor:"pointer",fontFamily:FONT,color:"#c50f1f",fontWeight:600,
|
||
display:"flex",alignItems:"center",gap:6,flexShrink:0,marginLeft:16,whiteSpace:"nowrap"}}
|
||
onMouseEnter={e=>{e.currentTarget.style.background="#fef2f2";}}
|
||
onMouseLeave={e=>{e.currentTarget.style.background="transparent";}}>
|
||
<i className="ti ti-user-x" style={{fontSize:14}} aria-hidden="true"/>
|
||
Delete Profile
|
||
</button>
|
||
</div>
|
||
</div>
|
||
|
||
{/* ── Delete confirmation dialog ── */}
|
||
{showDeleteDialog&&<div style={{position:"fixed",inset:0,zIndex:500,background:"rgba(0,0,0,0.55)",
|
||
display:"flex",alignItems:"center",justifyContent:"center"}}
|
||
onClick={e=>{if(e.target===e.currentTarget){setShowDeleteDialog(false);setDeleteStep(1);setDeleteInput("");}}}>
|
||
<div style={{background:C.surface,border:"1.5px solid #fca5a5",borderRadius:12,padding:"28px 32px",
|
||
width:420,boxShadow:"0 20px 50px rgba(0,0,0,0.3)",fontFamily:FONT}}>
|
||
|
||
{deleteStep===1&&<>
|
||
<div style={{display:"flex",alignItems:"center",gap:10,marginBottom:16}}>
|
||
<div style={{width:40,height:40,borderRadius:"50%",background:"#fef2f2",display:"flex",alignItems:"center",justifyContent:"center",flexShrink:0}}>
|
||
<i className="ti ti-user-x" style={{fontSize:20,color:"#c50f1f"}} aria-hidden="true"/>
|
||
</div>
|
||
<div style={{fontSize:17,fontWeight:700,color:C.text}}>Delete profile?</div>
|
||
</div>
|
||
<p style={{fontSize:13.5,color:C.text,lineHeight:1.7,margin:"0 0 14px"}}>
|
||
You are about to permanently delete the profile <strong>"{profile?.name||"this profile"}"</strong> and all its locally cached email data, settings, and search index.
|
||
</p>
|
||
<div style={{background:"#fef2f2",border:"1px solid #fca5a5",borderRadius:8,padding:"12px 14px",marginBottom:20}}>
|
||
<div style={{fontSize:12.5,color:"#7f1d1d",lineHeight:1.7}}>
|
||
<div style={{display:"flex",alignItems:"flex-start",gap:7,marginBottom:6}}>
|
||
<i className="ti ti-circle-check" style={{fontSize:13,color:"#22c55e",marginTop:2,flexShrink:0}} aria-hidden="true"/>
|
||
<span>Your emails on the SMB share are <strong>completely untouched</strong> — no files will be deleted, moved, or modified.</span>
|
||
</div>
|
||
<div style={{display:"flex",alignItems:"flex-start",gap:7,marginBottom:6}}>
|
||
<i className="ti ti-circle-x" style={{fontSize:13,color:"#c50f1f",marginTop:2,flexShrink:0}} aria-hidden="true"/>
|
||
<span>Local DashMail database, cached attachments, and profile settings will be <strong>permanently deleted</strong>.</span>
|
||
</div>
|
||
<div style={{display:"flex",alignItems:"flex-start",gap:7}}>
|
||
<i className="ti ti-circle-x" style={{fontSize:13,color:"#c50f1f",marginTop:2,flexShrink:0}} aria-hidden="true"/>
|
||
<span>This action <strong>cannot be undone</strong>. Create a backup first if you want to restore later.</span>
|
||
</div>
|
||
</div>
|
||
</div>
|
||
<div style={{display:"flex",justifyContent:"flex-end",gap:8}}>
|
||
<button onClick={()=>{setShowDeleteDialog(false);}}
|
||
style={{background:"transparent",border:`1px solid ${C.border}`,borderRadius:6,padding:"7px 18px",fontSize:13.5,cursor:"pointer",fontFamily:FONT,color:C.muted}}>Cancel</button>
|
||
<button onClick={()=>setDeleteStep(2)}
|
||
style={{background:"#c50f1f",color:"#fff",border:"none",borderRadius:6,padding:"7px 20px",fontSize:13.5,cursor:"pointer",fontFamily:FONT,fontWeight:600,display:"flex",alignItems:"center",gap:7}}>
|
||
I understand, continue <i className="ti ti-arrow-right" style={{fontSize:14}} aria-hidden="true"/>
|
||
</button>
|
||
</div>
|
||
</>}
|
||
|
||
{deleteStep===2&&<>
|
||
<div style={{fontSize:17,fontWeight:700,color:C.text,marginBottom:8}}>Confirm deletion</div>
|
||
<p style={{fontSize:13,color:C.muted,lineHeight:1.6,marginBottom:18}}>
|
||
Type <strong style={{color:"#c50f1f",fontFamily:"monospace",fontSize:14,letterSpacing:1}}>{CONFIRM_WORD}</strong> to permanently delete this profile.
|
||
</p>
|
||
<input value={deleteInput} onChange={e=>setDeleteInput(e.target.value.toUpperCase())}
|
||
placeholder={CONFIRM_WORD}
|
||
style={{width:"100%",boxSizing:"border-box",padding:"9px 12px",border:`1.5px solid ${deleteInput===CONFIRM_WORD?"#c50f1f":C.inBorder}`,
|
||
borderRadius:6,fontSize:14,fontFamily:"monospace",outline:"none",color:"#c50f1f",
|
||
background:deleteInput===CONFIRM_WORD?"#fef2f2":C.inputBg,letterSpacing:2,marginBottom:18,
|
||
textAlign:"center",fontWeight:700}}/>
|
||
<div style={{display:"flex",justifyContent:"flex-end",gap:8}}>
|
||
<button onClick={()=>setDeleteStep(1)}
|
||
style={{background:"transparent",border:`1px solid ${C.border}`,borderRadius:6,padding:"7px 18px",fontSize:13.5,cursor:"pointer",fontFamily:FONT,color:C.muted}}>Back</button>
|
||
<button onClick={()=>setDeleteStep(3)} disabled={deleteInput!==CONFIRM_WORD}
|
||
style={{background:deleteInput===CONFIRM_WORD?"#c50f1f":"rgba(197,15,31,0.3)",color:"#fff",border:"none",borderRadius:6,
|
||
padding:"7px 20px",fontSize:13.5,cursor:deleteInput===CONFIRM_WORD?"pointer":"default",
|
||
fontFamily:FONT,fontWeight:600,display:"flex",alignItems:"center",gap:7,transition:"background 0.15s"}}>
|
||
<i className="ti ti-trash" style={{fontSize:14}} aria-hidden="true"/>Delete Profile
|
||
</button>
|
||
</div>
|
||
</>}
|
||
|
||
{deleteStep===3&&<>
|
||
<div style={{display:"flex",flexDirection:"column",alignItems:"center",gap:12,textAlign:"center",padding:"10px 0"}}>
|
||
<div style={{width:52,height:52,borderRadius:"50%",background:"#fef2f2",display:"flex",alignItems:"center",justifyContent:"center"}}>
|
||
<i className="ti ti-circle-check" style={{fontSize:28,color:"#22c55e"}} aria-hidden="true"/>
|
||
</div>
|
||
<div style={{fontSize:17,fontWeight:700,color:C.text}}>Profile deleted</div>
|
||
<div style={{fontSize:13,color:C.muted,lineHeight:1.6}}>
|
||
The profile and all local data have been removed.<br/>
|
||
Your emails on the SMB share remain untouched.<br/>
|
||
You will be signed out now.
|
||
</div>
|
||
</div>
|
||
<div style={{display:"flex",justifyContent:"center",marginTop:22}}>
|
||
<button onClick={()=>{setShowDeleteDialog(false);setDeleteStep(1);}}
|
||
style={{background:C.accent,color:"#fff",border:"none",borderRadius:6,padding:"8px 28px",fontSize:13.5,cursor:"pointer",fontFamily:FONT,fontWeight:600}}>
|
||
Sign out
|
||
</button>
|
||
</div>
|
||
</>}
|
||
</div>
|
||
</div>}
|
||
</>;
|
||
}
|
||
|
||
function SettingsPrivacy({ls,setLS,C}){
|
||
return <SCard C={C}>
|
||
<SRow label="Read receipts" sub="Send read confirmation to senders" C={C}><Toggle on={ls.readReceipts} onChange={v=>setLS(p=>({...p,readReceipts:v}))} C={C}/></SRow>
|
||
<SRow label="Block tracking pixels" sub="Prevent email tracking images from loading" C={C}><Toggle on={true} C={C}/></SRow>
|
||
<SRow label="Load external images" sub="Automatically load images in emails" C={C}><Toggle on={false} C={C}/></SRow>
|
||
<SRow label="Link click confirmation" sub="Warn before opening external links" C={C}><Toggle on={true} C={C}/></SRow>
|
||
</SCard>;
|
||
}
|
||
|
||
function SettingsSMB({smbConfig,setSmbConfig,C}){
|
||
const[status,setStatus]=useState(null); // null | "testing" | "ok" | "err"
|
||
const[errMsg,setErrMsg]=useState("");
|
||
function set(k,v){setSmbConfig(p=>({...p,[k]:v}));}
|
||
function testConn(){
|
||
setStatus("testing");setErrMsg("");
|
||
setTimeout(()=>{
|
||
if(smbConfig.host.trim()&&smbConfig.share.trim()){setStatus("ok");}
|
||
else{setStatus("err");setErrMsg("Host and share name are required.");}
|
||
},1400);
|
||
}
|
||
const inputSt={width:"100%",boxSizing:"border-box",padding:"7px 10px",border:`1px solid ${C.inBorder}`,
|
||
borderRadius:5,fontSize:13.5,fontFamily:FONT,outline:"none",color:C.text,background:C.inputBg};
|
||
return <>
|
||
<p style={{fontSize:13,color:C.muted,marginBottom:16,lineHeight:1.7}}>
|
||
Configure the SMB network share where your mailbox folders are stored.
|
||
DashMail reads mail from <code style={{fontSize:12,background:C.surface2,padding:"1px 6px",borderRadius:4,color:C.accent}}>\\HOST\SHARE\SUBFOLDER</code>.
|
||
</p>
|
||
<SectionHead label="Connection" C={C}/>
|
||
<SCard C={C}>
|
||
<div style={{marginBottom:12}}>
|
||
<label style={{display:"block",fontSize:12.5,fontWeight:600,color:C.muted,marginBottom:5}}>Server host</label>
|
||
<input value={smbConfig.host||""} onChange={e=>set("host",e.target.value)} placeholder="192.168.1.100 or mailserver.local" style={inputSt}/>
|
||
</div>
|
||
<div style={{display:"flex",gap:12,marginBottom:12}}>
|
||
<div style={{flex:2}}>
|
||
<label style={{display:"block",fontSize:12.5,fontWeight:600,color:C.muted,marginBottom:5}}>Share name</label>
|
||
<input value={smbConfig.share||""} onChange={e=>set("share",e.target.value)} placeholder="mail" style={inputSt}/>
|
||
</div>
|
||
<div style={{flex:1}}>
|
||
<label style={{display:"block",fontSize:12.5,fontWeight:600,color:C.muted,marginBottom:5}}>Drive letter</label>
|
||
<select value={smbConfig.driveLetter||"Z"} onChange={e=>set("driveLetter",e.target.value)}
|
||
style={{width:"100%",padding:"7px 8px",border:`1px solid ${C.inBorder}`,borderRadius:5,
|
||
fontSize:13.5,fontFamily:FONT,outline:"none",color:C.text,background:C.inputBg,boxSizing:"border-box"}}>
|
||
{"DEFGHIJKLMNOPQRSTUVWXYZ".split("").map(l=><option key={l} value={l}>{l}:</option>)}
|
||
</select>
|
||
</div>
|
||
</div>
|
||
<div>
|
||
<label style={{display:"block",fontSize:12.5,fontWeight:600,color:C.muted,marginBottom:5}}>Subfolder <span style={{fontWeight:400,color:C.subtle,fontSize:12}}>(within the share)</span></label>
|
||
<input value={smbConfig.subfolder||""} onChange={e=>set("subfolder",e.target.value)} placeholder=".Mails" style={inputSt}/>
|
||
</div>
|
||
</SCard>
|
||
<SectionHead label="Authentication" C={C}/>
|
||
<SCard C={C}>
|
||
<div style={{marginBottom:12}}>
|
||
<label style={{display:"block",fontSize:12.5,fontWeight:600,color:C.muted,marginBottom:5}}>Username</label>
|
||
<input value={smbConfig.username||""} onChange={e=>set("username",e.target.value)} placeholder="domain\\user or user@domain" style={inputSt} autoComplete="off"/>
|
||
</div>
|
||
<div>
|
||
<label style={{display:"block",fontSize:12.5,fontWeight:600,color:C.muted,marginBottom:5}}>Password</label>
|
||
<input type="password" value={smbConfig.password||""} onChange={e=>set("password",e.target.value)} style={inputSt} autoComplete="new-password"/>
|
||
</div>
|
||
</SCard>
|
||
<div style={{display:"flex",alignItems:"center",gap:12,marginTop:6}}>
|
||
<button onClick={testConn} disabled={status==="testing"}
|
||
style={{background:C.accent,color:"#fff",border:"none",borderRadius:6,padding:"8px 20px",
|
||
fontSize:13.5,cursor:status==="testing"?"default":"pointer",fontFamily:FONT,fontWeight:600,
|
||
display:"flex",alignItems:"center",gap:7,opacity:status==="testing"?0.7:1}}>
|
||
{status==="testing"
|
||
?<><i className="ti ti-loader-2 ti-spin" style={{fontSize:14}} aria-hidden="true"/> Testing…</>
|
||
:<><i className="ti ti-plug" style={{fontSize:14}} aria-hidden="true"/> Test Connection</>}
|
||
</button>
|
||
{status==="ok"&&<span style={{fontSize:13,color:"#22c55e",display:"flex",alignItems:"center",gap:5,fontWeight:600}}>
|
||
<i className="ti ti-circle-check" style={{fontSize:15}} aria-hidden="true"/>Connected
|
||
</span>}
|
||
{status==="err"&&<span style={{fontSize:13,color:C.danger??"#c50f1f",display:"flex",alignItems:"center",gap:5}}>
|
||
<i className="ti ti-alert-circle" style={{fontSize:15}} aria-hidden="true"/>{errMsg||"Connection failed"}
|
||
</span>}
|
||
</div>
|
||
</>;
|
||
}
|
||
|
||
// ── Spam & Trash settings ─────────────────────────────────────────────────────
|
||
function SettingsSpamTrash({C}){
|
||
const[trashAfter,setTrashAfter]=useState("30");
|
||
const[spamAfter,setSpamAfter]=useState("30");
|
||
const[confirmDelete,setConfirmDelete]=useState(true);
|
||
const[emptyingTrash,setEmptyingTrash]=useState(false);
|
||
const[emptyDone,setEmptyDone]=useState(false);
|
||
|
||
function emptyNow(){
|
||
setEmptyingTrash(true);setEmptyDone(false);
|
||
setTimeout(()=>{setEmptyingTrash(false);setEmptyDone(true);setTimeout(()=>setEmptyDone(false),3000);},1600);
|
||
}
|
||
|
||
const TRASH_OPTS=[
|
||
{v:"never", label:"Never (manual only)"},
|
||
{v:"1", label:"After 1 day"},
|
||
{v:"7", label:"After 7 days"},
|
||
{v:"14", label:"After 14 days"},
|
||
{v:"30", label:"After 30 days"},
|
||
{v:"60", label:"After 60 days"},
|
||
{v:"90", label:"After 90 days"},
|
||
];
|
||
const SPAM_OPTS=[
|
||
{v:"never", label:"Never (manual only)"},
|
||
{v:"7", label:"After 7 days"},
|
||
{v:"14", label:"After 14 days"},
|
||
{v:"30", label:"After 30 days"},
|
||
{v:"60", label:"After 60 days"},
|
||
];
|
||
const selSt={background:C.inputBg,border:`1px solid ${C.inBorder}`,borderRadius:5,
|
||
padding:"5px 9px",color:C.text,fontFamily:FONT,fontSize:13,outline:"none"};
|
||
|
||
return <>
|
||
<SectionHead label="Trash" C={C}/>
|
||
<SCard C={C}>
|
||
<SRow label="Automatically empty trash" sub="Permanently delete items in Trash older than" C={C}>
|
||
<select value={trashAfter} onChange={e=>setTrashAfter(e.target.value)} style={selSt}>
|
||
{TRASH_OPTS.map(o=><option key={o.v} value={o.v}>{o.label}</option>)}
|
||
</select>
|
||
</SRow>
|
||
<SRow label="Confirm before deleting" sub="Show a confirmation prompt before permanently deleting" C={C}>
|
||
<Toggle on={confirmDelete} onChange={setConfirmDelete} C={C}/>
|
||
</SRow>
|
||
</SCard>
|
||
|
||
{/* Empty trash now */}
|
||
<div style={{display:"flex",alignItems:"center",gap:12,marginBottom:24}}>
|
||
<button onClick={emptyNow} disabled={emptyingTrash}
|
||
style={{background:"transparent",border:`1px solid ${C.danger||"#c50f1f"}`,borderRadius:6,
|
||
padding:"7px 18px",fontSize:13,cursor:emptyingTrash?"default":"pointer",fontFamily:FONT,
|
||
color:C.danger||"#c50f1f",display:"flex",alignItems:"center",gap:7,
|
||
opacity:emptyingTrash?0.65:1,fontWeight:500}}>
|
||
{emptyingTrash
|
||
?<><i className="ti ti-loader-2 ti-spin" style={{fontSize:14}} aria-hidden="true"/>Emptying…</>
|
||
:<><i className="ti ti-trash" style={{fontSize:14}} aria-hidden="true"/>Empty Trash Now</>}
|
||
</button>
|
||
{emptyDone&&<span style={{fontSize:13,color:"#22c55e",display:"flex",alignItems:"center",gap:5,fontWeight:600}}>
|
||
<i className="ti ti-circle-check" style={{fontSize:14}} aria-hidden="true"/>Trash emptied
|
||
</span>}
|
||
</div>
|
||
|
||
<SectionHead label="Spam" C={C}/>
|
||
<SCard C={C}>
|
||
<SRow label="Automatically delete spam" sub="Permanently remove spam messages older than" C={C}>
|
||
<select value={spamAfter} onChange={e=>setSpamAfter(e.target.value)} style={selSt}>
|
||
{SPAM_OPTS.map(o=><option key={o.v} value={o.v}>{o.label}</option>)}
|
||
</select>
|
||
</SRow>
|
||
<SRow label="Move to spam folder" sub="Flagged messages go to Spam instead of Trash" C={C}>
|
||
<Toggle on={true} C={C}/>
|
||
</SRow>
|
||
<SRow label="Trust sender after mark" sub="Whitelist sender when you mark as Not Spam" C={C}>
|
||
<Toggle on={true} C={C}/>
|
||
</SRow>
|
||
</SCard>
|
||
|
||
{trashAfter!=="never"&&<div style={{display:"flex",alignItems:"flex-start",gap:8,
|
||
background:C.accentBg||C.surface2,border:`1px solid ${C.accentBorder||C.border}`,
|
||
borderRadius:7,padding:"10px 14px",marginTop:-8}}>
|
||
<i className="ti ti-info-circle" style={{fontSize:14,color:C.accent,flexShrink:0,marginTop:1}} aria-hidden="true"/>
|
||
<span style={{fontSize:12.5,color:C.muted,lineHeight:1.6}}>
|
||
Trash items older than <strong style={{color:C.text}}>{TRASH_OPTS.find(o=>o.v===trashAfter)?.label.replace("After ","")}</strong> will be permanently deleted from your local database. The SMB share is never modified.
|
||
</span>
|
||
</div>}
|
||
</>;
|
||
}
|
||
|
||
// ── Backup settings ───────────────────────────────────────────────────────────
|
||
const MOCK_BACKUP_HISTORY=[
|
||
{id:"bk-001",type:"full", date:"2026-05-24 22:00",emails:1842,size:"48.3 MB",status:"ok",incrementals:[
|
||
{id:"bk-002",type:"inc",date:"2026-05-25 22:00",emails:12,size:"1.1 MB",status:"ok"},
|
||
{id:"bk-003",type:"inc",date:"2026-05-26 22:00",emails:8, size:"0.7 MB",status:"ok"},
|
||
{id:"bk-004",type:"inc",date:"2026-05-27 22:00",emails:19,size:"2.4 MB",status:"ok"},
|
||
]},
|
||
{id:"bk-005",type:"full", date:"2026-04-24 22:00",emails:1803,size:"46.1 MB",status:"ok",incrementals:[]},
|
||
];
|
||
|
||
function SettingsBackup({C}){
|
||
const[paths,setPaths]=useState(["C:\\Users\\User\\DashMailBackups"]);
|
||
const[newPath,setNewPath]=useState("");
|
||
const[schedule,setSchedule]=useState("daily");
|
||
const[scheduleDay,setScheduleDay]=useState("1"); // 0=Sun..6=Sat
|
||
const[scheduleTime,setScheduleTime]=useState("22:00");
|
||
const[bkType,setBkType]=useState("smart");
|
||
const[retention,setRetention]=useState("5");
|
||
const[history,setHistory]=useState(MOCK_BACKUP_HISTORY);
|
||
const[expanded,setExpanded]=useState({"bk-001":true});
|
||
const[running,setRunning]=useState(false);
|
||
const[runType,setRunType]=useState(null); // "full"|"inc"
|
||
const[runMsg,setRunMsg]=useState("");
|
||
const[showDropdown,setShowDropdown]=useState(false);
|
||
const[showRestore,setShowRestore]=useState(false);
|
||
const[restoreFile,setRestoreFile]=useState("");
|
||
const[restoreConfirm,setRestoreConfirm]=useState(false);
|
||
const[restoreRunning,setRestoreRunning]=useState(false);
|
||
const[restoreDone,setRestoreDone]=useState(false);
|
||
const dropRef=useRef();
|
||
const folderInputRef=useRef();
|
||
|
||
useEffect(()=>{
|
||
function handleClick(e){if(dropRef.current&&!dropRef.current.contains(e.target))setShowDropdown(false);}
|
||
document.addEventListener("mousedown",handleClick);
|
||
return()=>document.removeEventListener("mousedown",handleClick);
|
||
},[]);
|
||
|
||
async function browseForFolder(){
|
||
// Use File System Access API if available (Edge / Chrome on Windows)
|
||
if(window.showDirectoryPicker){
|
||
try{
|
||
const dir=await window.showDirectoryPicker({mode:"read"});
|
||
// API gives folder name only (full path blocked by browser security)
|
||
// Prefix with a common root so user can correct it in the text field
|
||
setNewPath(dir.name);
|
||
}catch(e){/* user cancelled */}
|
||
} else {
|
||
// Fallback: hidden file input with webkitdirectory
|
||
folderInputRef.current?.click();
|
||
}
|
||
}
|
||
|
||
function addPath(){
|
||
const p=newPath.trim();
|
||
if(p&&!paths.includes(p)){setPaths(prev=>[...prev,p]);}
|
||
setNewPath("");
|
||
}
|
||
function removePath(i){setPaths(prev=>prev.filter((_,idx)=>idx!==i));}
|
||
|
||
function startBackup(type){
|
||
setRunning(true);setRunType(type);setRunMsg("");setShowDropdown(false);
|
||
const label=type==="full"?"Full backup":"Incremental backup";
|
||
setTimeout(()=>{
|
||
const now=new Date().toISOString().replace("T"," ").slice(0,16);
|
||
const newEntry=type==="full"
|
||
?{id:"bk-new-f",type:"full",date:now,emails:1860,size:"49.1 MB",status:"ok",incrementals:[]}
|
||
:{id:"bk-new-i",type:"inc",date:now,emails:7,size:"0.6 MB",status:"ok"};
|
||
if(type==="full"){
|
||
setHistory(prev=>[newEntry,...prev]);
|
||
} else {
|
||
setHistory(prev=>prev.map((b,i)=>i===0?{...b,incrementals:[newEntry,...(b.incrementals||[])]}:b));
|
||
}
|
||
setRunning(false);setRunMsg(`${label} completed successfully.`);
|
||
setTimeout(()=>setRunMsg(""),6000);
|
||
},2400);
|
||
}
|
||
|
||
function doRestore(){
|
||
setRestoreRunning(true);
|
||
setTimeout(()=>{setRestoreRunning(false);setRestoreDone(true);},2800);
|
||
}
|
||
|
||
const inputSt={width:"100%",boxSizing:"border-box",padding:"7px 10px",border:`1px solid ${C.inBorder}`,
|
||
borderRadius:5,fontSize:13,fontFamily:FONT,outline:"none",color:C.text,background:C.inputBg};
|
||
const selSt={...inputSt,width:"auto"};
|
||
|
||
return <>
|
||
{/* ── Backup locations ─────────────────────────────────────── */}
|
||
<SectionHead label="Backup locations" C={C}/>
|
||
<SCard C={C}>
|
||
{paths.map((p,i)=>(
|
||
<div key={i} style={{display:"flex",alignItems:"center",gap:8,padding:"9px 0",
|
||
borderBottom:i<paths.length-1?`1px solid ${C.border}`:"none"}}>
|
||
<i className="ti ti-folder" style={{fontSize:15,color:C.accent,flexShrink:0}} aria-hidden="true"/>
|
||
<span style={{flex:1,fontSize:13,color:C.text,wordBreak:"break-all"}}>{p}</span>
|
||
<button onClick={()=>removePath(i)} title="Remove location"
|
||
style={{background:"none",border:"none",cursor:"pointer",padding:"2px 4px",color:C.subtle,lineHeight:1,fontSize:13,fontFamily:FONT,borderRadius:4}}
|
||
onMouseEnter={e=>{e.currentTarget.style.color=C.danger;e.currentTarget.style.background=C.hover;}}
|
||
onMouseLeave={e=>{e.currentTarget.style.color=C.subtle;e.currentTarget.style.background="none";}}>
|
||
<i className="ti ti-x" style={{fontSize:13}} aria-hidden="true"/>
|
||
</button>
|
||
</div>
|
||
))}
|
||
</SCard>
|
||
|
||
<div style={{display:"flex",gap:8,marginTop:-10,marginBottom:18}}>
|
||
{/* Hidden folder picker fallback for browsers without showDirectoryPicker */}
|
||
<input ref={folderInputRef} type="file" webkitdirectory="" style={{display:"none"}}
|
||
onChange={e=>{
|
||
const files=e.target.files;
|
||
if(files&&files.length>0){
|
||
const folderName=files[0].webkitRelativePath.split("/")[0];
|
||
setNewPath(folderName);
|
||
}
|
||
e.target.value="";
|
||
}}/>
|
||
<input value={newPath} onChange={e=>setNewPath(e.target.value)}
|
||
onKeyDown={e=>{if(e.key==="Enter")addPath();}}
|
||
placeholder="Add backup path, e.g. D:\Backups\DashMail"
|
||
style={{...inputSt,flex:1}}/>
|
||
<button onClick={browseForFolder} title="Browse"
|
||
style={{background:C.surface2,border:`1px solid ${C.inBorder}`,borderRadius:5,padding:"7px 12px",
|
||
fontSize:12.5,cursor:"pointer",fontFamily:FONT,color:C.text,display:"flex",alignItems:"center",gap:5,whiteSpace:"nowrap"}}>
|
||
<i className="ti ti-folder-open" style={{fontSize:13}} aria-hidden="true"/> Browse
|
||
</button>
|
||
<button onClick={addPath}
|
||
style={{background:C.accent,color:"#fff",border:"none",borderRadius:5,padding:"7px 14px",
|
||
fontSize:12.5,cursor:"pointer",fontFamily:FONT,fontWeight:600,whiteSpace:"nowrap"}}>
|
||
+ Add
|
||
</button>
|
||
</div>
|
||
|
||
{/* ── Schedule ─────────────────────────────────────────────── */}
|
||
<SectionHead label="Schedule" C={C}/>
|
||
<SCard C={C}>
|
||
<SRow label="Frequency" C={C}>
|
||
<select value={schedule} onChange={e=>setSchedule(e.target.value)} style={{...selSt}}>
|
||
<option value="manual">Manual only</option>
|
||
<option value="daily">Daily</option>
|
||
<option value="weekly">Weekly</option>
|
||
</select>
|
||
</SRow>
|
||
{schedule==="weekly"&&<SRow label="Day of week" C={C}>
|
||
<select value={scheduleDay} onChange={e=>setScheduleDay(e.target.value)} style={selSt}>
|
||
{["Sunday","Monday","Tuesday","Wednesday","Thursday","Friday","Saturday"].map((d,i)=>(
|
||
<option key={i} value={String(i)}>{d}</option>
|
||
))}
|
||
</select>
|
||
</SRow>}
|
||
{schedule!=="manual"&&<SRow label="Time" sub="24-hour format, local time" C={C}>
|
||
<input type="time" value={scheduleTime} onChange={e=>setScheduleTime(e.target.value)}
|
||
style={{...selSt,width:110}}/>
|
||
</SRow>}
|
||
{schedule!=="manual"&&<SRow label="Windows Task Scheduler" sub="DashMail registers a scheduled task automatically" C={C}>
|
||
<span style={{fontSize:12,color:"#22c55e",display:"flex",alignItems:"center",gap:4,fontWeight:600}}>
|
||
<i className="ti ti-circle-check" style={{fontSize:14}} aria-hidden="true"/>Active
|
||
</span>
|
||
</SRow>}
|
||
</SCard>
|
||
|
||
{/* ── Backup type ──────────────────────────────────────────── */}
|
||
<SectionHead label="Backup type" C={C}/>
|
||
<SCard C={C}>
|
||
{[
|
||
{id:"smart", label:"Smart (recommended)", sub:"Incremental daily, full every 30 days or 10 incrementals"},
|
||
{id:"full", label:"Always full", sub:"Every backup is a complete copy — safer, uses more storage"},
|
||
{id:"inc", label:"Always incremental", sub:"Only new/changed emails backed up each time"},
|
||
].map(opt=>(
|
||
<div key={opt.id} onClick={()=>setBkType(opt.id)}
|
||
style={{display:"flex",alignItems:"flex-start",gap:10,padding:"10px 0",
|
||
borderBottom:opt.id!=="inc"?`1px solid ${C.border}`:"none",cursor:"pointer"}}>
|
||
<div style={{width:16,height:16,borderRadius:"50%",border:`2px solid ${bkType===opt.id?C.accent:C.inBorder}`,
|
||
background:bkType===opt.id?C.accent:"transparent",flexShrink:0,marginTop:2,
|
||
display:"flex",alignItems:"center",justifyContent:"center"}}>
|
||
{bkType===opt.id&&<div style={{width:6,height:6,borderRadius:"50%",background:"#fff"}}/>}
|
||
</div>
|
||
<div>
|
||
<div style={{fontSize:13.5,fontWeight:bkType===opt.id?600:400,color:C.text}}>{opt.label}</div>
|
||
<div style={{fontSize:12,color:C.muted,marginTop:2}}>{opt.sub}</div>
|
||
</div>
|
||
</div>
|
||
))}
|
||
</SCard>
|
||
|
||
{/* ── Retention ────────────────────────────────────────────── */}
|
||
<SectionHead label="Retention" C={C}/>
|
||
<SCard C={C}>
|
||
<SRow label="Keep backup sets" sub="Older backup chains are automatically removed" C={C}>
|
||
<select value={retention} onChange={e=>setRetention(e.target.value)} style={selSt}>
|
||
{["3","5","10","20","50"].map(n=><option key={n} value={n}>{n} sets</option>)}
|
||
</select>
|
||
</SRow>
|
||
</SCard>
|
||
|
||
{/* ── Back Up Now ──────────────────────────────────────────── */}
|
||
<SectionHead label="Manual backup" C={C}/>
|
||
<div style={{display:"flex",alignItems:"center",gap:12,marginBottom:18}}>
|
||
<div style={{position:"relative"}} ref={dropRef}>
|
||
<div style={{display:"flex"}}>
|
||
<button onClick={()=>startBackup(bkType==="full"?"full":"inc")} disabled={running}
|
||
style={{background:C.accent,color:"#fff",border:"none",borderRadius:"6px 0 0 6px",
|
||
padding:"8px 18px",fontSize:13.5,cursor:running?"default":"pointer",fontFamily:FONT,
|
||
fontWeight:600,display:"flex",alignItems:"center",gap:7,opacity:running?0.75:1}}>
|
||
{running
|
||
?<><i className="ti ti-loader-2 ti-spin" style={{fontSize:14}} aria-hidden="true"/>Backing up…</>
|
||
:<><i className="ti ti-database-export" style={{fontSize:14}} aria-hidden="true"/>Back Up Now</>}
|
||
</button>
|
||
<button disabled={running} onClick={()=>setShowDropdown(v=>!v)}
|
||
style={{background:C.accentHov||C.accent,color:"#fff",border:"none",borderLeft:"1px solid rgba(255,255,255,0.25)",
|
||
borderRadius:"0 6px 6px 0",padding:"8px 10px",cursor:running?"default":"pointer",fontSize:13,
|
||
opacity:running?0.75:1}}>
|
||
<i className="ti ti-chevron-down" style={{fontSize:12}} aria-hidden="true"/>
|
||
</button>
|
||
</div>
|
||
{showDropdown&&<div style={{position:"absolute",top:"calc(100% + 4px)",left:0,minWidth:170,
|
||
background:C.surface,border:`1px solid ${C.border}`,borderRadius:7,
|
||
boxShadow:"0 6px 20px rgba(0,0,0,0.18)",zIndex:50,overflow:"hidden"}}>
|
||
{[{t:"full",label:"Full backup",icon:"ti-database"},
|
||
{t:"inc", label:"Incremental backup",icon:"ti-database-plus"}].map(o=>(
|
||
<div key={o.t} onClick={()=>startBackup(o.t)}
|
||
style={{display:"flex",alignItems:"center",gap:8,padding:"9px 14px",
|
||
cursor:"pointer",fontSize:13,color:C.text}}
|
||
onMouseEnter={e=>e.currentTarget.style.background=C.hover}
|
||
onMouseLeave={e=>e.currentTarget.style.background="transparent"}>
|
||
<i className={`ti ${o.icon}`} style={{fontSize:14,color:C.accent}} aria-hidden="true"/>
|
||
{o.label}
|
||
</div>
|
||
))}
|
||
</div>}
|
||
</div>
|
||
{runMsg&&<span style={{fontSize:13,color:"#22c55e",display:"flex",alignItems:"center",gap:5,fontWeight:600}}>
|
||
<i className="ti ti-circle-check" style={{fontSize:14}} aria-hidden="true"/>{runMsg}
|
||
</span>}
|
||
</div>
|
||
|
||
{/* ── Backup history ───────────────────────────────────────── */}
|
||
<SectionHead label="Backup history" C={C}/>
|
||
{history.length===0
|
||
?<p style={{fontSize:13,color:C.muted}}>No backups yet.</p>
|
||
:<div style={{display:"flex",flexDirection:"column",gap:6,marginBottom:18}}>
|
||
{history.map(b=>{
|
||
const open=!!expanded[b.id];
|
||
return <div key={b.id} style={{background:C.surface2,border:`1px solid ${C.border}`,borderRadius:8,overflow:"hidden"}}>
|
||
{/* Full row */}
|
||
<div onClick={()=>setExpanded(p=>({...p,[b.id]:!p[b.id]}))}
|
||
style={{display:"flex",alignItems:"center",gap:10,padding:"9px 12px",cursor:"pointer"}}
|
||
onMouseEnter={e=>e.currentTarget.style.background=C.hover}
|
||
onMouseLeave={e=>e.currentTarget.style.background="transparent"}>
|
||
<i className="ti ti-database" style={{fontSize:15,color:C.accent,flexShrink:0}} aria-hidden="true"/>
|
||
<div style={{flex:1,minWidth:0}}>
|
||
<div style={{display:"flex",alignItems:"center",gap:7}}>
|
||
<span style={{fontSize:12.5,fontWeight:700,color:C.text,textTransform:"uppercase",
|
||
letterSpacing:"0.05em",background:C.accentBg||C.selectedBg,color:C.accent,
|
||
padding:"1px 6px",borderRadius:3,fontSize:11}}>FULL</span>
|
||
<span style={{fontSize:13,color:C.text,fontWeight:600}}>{b.date}</span>
|
||
{b.incrementals?.length>0&&<span style={{fontSize:12,color:C.muted}}>+{b.incrementals.length} incremental{b.incrementals.length!==1?"s":""}</span>}
|
||
</div>
|
||
<div style={{fontSize:12,color:C.muted,marginTop:2}}>{b.emails.toLocaleString()} emails · {b.size}</div>
|
||
</div>
|
||
<span style={{fontSize:12,color:b.status==="ok"?"#22c55e":C.danger,display:"flex",alignItems:"center",gap:4,fontWeight:600}}>
|
||
<i className={`ti ${b.status==="ok"?"ti-circle-check":"ti-alert-circle"}`} style={{fontSize:13}} aria-hidden="true"/>
|
||
{b.status==="ok"?"OK":"Error"}
|
||
</span>
|
||
<button onClick={e=>{e.stopPropagation();setRestoreFile(b.date+" (Full)");setShowRestore(true);setRestoreConfirm(false);setRestoreDone(false);}}
|
||
style={{background:"none",border:`1px solid ${C.inBorder}`,borderRadius:5,padding:"4px 10px",
|
||
fontSize:12,cursor:"pointer",fontFamily:FONT,color:C.muted,flexShrink:0}}
|
||
onMouseEnter={e=>e.currentTarget.style.color=C.text}
|
||
onMouseLeave={e=>e.currentTarget.style.color=C.muted}>
|
||
Restore
|
||
</button>
|
||
<i className={`ti ti-chevron-${open?"up":"down"}`} style={{fontSize:13,color:C.subtle,flexShrink:0}} aria-hidden="true"/>
|
||
</div>
|
||
{/* Incrementals */}
|
||
{open&&b.incrementals?.length>0&&<div style={{borderTop:`1px solid ${C.border}`}}>
|
||
{b.incrementals.map(inc=>(
|
||
<div key={inc.id} style={{display:"flex",alignItems:"center",gap:10,padding:"7px 12px 7px 36px",
|
||
borderBottom:`1px solid ${C.border}`}}
|
||
onMouseEnter={e=>e.currentTarget.style.background=C.hover}
|
||
onMouseLeave={e=>e.currentTarget.style.background="transparent"}>
|
||
<i className="ti ti-database-plus" style={{fontSize:13,color:C.muted,flexShrink:0}} aria-hidden="true"/>
|
||
<div style={{flex:1}}>
|
||
<div style={{display:"flex",alignItems:"center",gap:7}}>
|
||
<span style={{fontSize:11,fontWeight:700,color:C.muted,textTransform:"uppercase",letterSpacing:"0.05em",
|
||
background:C.surface3||C.surface,padding:"1px 6px",borderRadius:3}}>INC</span>
|
||
<span style={{fontSize:13,color:C.text}}>{inc.date}</span>
|
||
</div>
|
||
<div style={{fontSize:12,color:C.muted,marginTop:1}}>{inc.emails} new/changed · {inc.size}</div>
|
||
</div>
|
||
<span style={{fontSize:12,color:"#22c55e",display:"flex",alignItems:"center",gap:4,fontWeight:600}}>
|
||
<i className="ti ti-circle-check" style={{fontSize:13}} aria-hidden="true"/>OK
|
||
</span>
|
||
<button onClick={()=>{setRestoreFile(inc.date+" (Incremental)");setShowRestore(true);setRestoreConfirm(false);setRestoreDone(false);}}
|
||
style={{background:"none",border:`1px solid ${C.inBorder}`,borderRadius:5,padding:"4px 10px",
|
||
fontSize:12,cursor:"pointer",fontFamily:FONT,color:C.muted,flexShrink:0}}
|
||
onMouseEnter={e=>e.currentTarget.style.color=C.text}
|
||
onMouseLeave={e=>e.currentTarget.style.color=C.muted}>
|
||
Restore
|
||
</button>
|
||
</div>
|
||
))}
|
||
</div>}
|
||
</div>;
|
||
})}
|
||
</div>}
|
||
|
||
{/* ── Restore dialog ───────────────────────────────────────── */}
|
||
{showRestore&&<div style={{position:"fixed",inset:0,zIndex:500,background:"rgba(0,0,0,0.55)",display:"flex",alignItems:"center",justifyContent:"center"}}
|
||
onClick={e=>{if(e.target===e.currentTarget){setShowRestore(false);setRestoreRunning(false);setRestoreDone(false);}}}>
|
||
<div style={{background:C.surface,border:`1px solid ${C.border}`,borderRadius:12,padding:"28px 32px",width:420,
|
||
boxShadow:"0 20px 50px rgba(0,0,0,0.35)",fontFamily:FONT}}>
|
||
{restoreDone
|
||
?<>
|
||
<div style={{display:"flex",flexDirection:"column",alignItems:"center",gap:12,textAlign:"center"}}>
|
||
<i className="ti ti-circle-check" style={{fontSize:40,color:"#22c55e"}} aria-hidden="true"/>
|
||
<div style={{fontSize:17,fontWeight:700,color:C.text}}>Restore complete</div>
|
||
<div style={{fontSize:13,color:C.muted,lineHeight:1.6}}>DashMail has been restored to the selected backup point. Please restart the application to apply all changes.</div>
|
||
</div>
|
||
<div style={{display:"flex",justifyContent:"center",marginTop:22}}>
|
||
<button onClick={()=>{setShowRestore(false);setRestoreDone(false);}}
|
||
style={{background:C.accent,color:"#fff",border:"none",borderRadius:6,padding:"8px 28px",
|
||
fontSize:13.5,cursor:"pointer",fontFamily:FONT,fontWeight:600}}>Close</button>
|
||
</div>
|
||
</>
|
||
:!restoreConfirm
|
||
?<>
|
||
<div style={{fontSize:17,fontWeight:700,color:C.text,marginBottom:16}}>Restore from backup</div>
|
||
<div style={{background:C.accentBg||C.selectedBg,border:`1px solid ${C.accentBorder||C.border}`,borderRadius:7,
|
||
padding:"10px 14px",marginBottom:16,display:"flex",alignItems:"center",gap:9}}>
|
||
<i className="ti ti-database" style={{fontSize:16,color:C.accent,flexShrink:0}} aria-hidden="true"/>
|
||
<span style={{fontSize:13,color:C.text,fontWeight:500}}>{restoreFile}</span>
|
||
</div>
|
||
<div style={{background:"#fff3cd",border:"1px solid #ffc107",borderRadius:7,padding:"10px 14px",marginBottom:20,
|
||
display:"flex",alignItems:"flex-start",gap:9}}>
|
||
<i className="ti ti-alert-triangle" style={{fontSize:15,color:"#b8860b",flexShrink:0,marginTop:1}} aria-hidden="true"/>
|
||
<div style={{fontSize:12.5,color:"#7a5c00",lineHeight:1.6}}>
|
||
<strong>A safety backup will be created first.</strong> Your current data will be saved before anything is overwritten. The SMB share is never modified.
|
||
</div>
|
||
</div>
|
||
<div style={{marginBottom:20}}>
|
||
<label style={{display:"block",fontSize:12.5,fontWeight:600,color:C.muted,marginBottom:5}}>Or restore from a backup file</label>
|
||
<div style={{display:"flex",gap:8}}>
|
||
<input value={restoreFile} onChange={e=>setRestoreFile(e.target.value)} placeholder="C:\Backups\dashmail-2026-05-24.zip" style={inputSt}/>
|
||
<label style={{background:C.surface2,border:`1px solid ${C.inBorder}`,borderRadius:5,padding:"7px 12px",
|
||
fontSize:12.5,cursor:"pointer",fontFamily:FONT,color:C.text,whiteSpace:"nowrap",display:"flex",alignItems:"center",gap:5}}>
|
||
<input type="file" accept=".zip" style={{display:"none"}}
|
||
onChange={e=>{
|
||
const f=e.target.files?.[0];
|
||
if(f){setRestoreFile(f.name);}
|
||
e.target.value="";
|
||
}}/>
|
||
<i className="ti ti-folder-open" style={{fontSize:13}} aria-hidden="true"/> Browse
|
||
</label>
|
||
</div>
|
||
</div>
|
||
<div style={{display:"flex",justifyContent:"flex-end",gap:8}}>
|
||
<button onClick={()=>setShowRestore(false)}
|
||
style={{background:"transparent",border:`1px solid ${C.border}`,borderRadius:6,padding:"7px 18px",
|
||
fontSize:13.5,cursor:"pointer",fontFamily:FONT,color:C.muted}}>Cancel</button>
|
||
<button onClick={()=>setRestoreConfirm(true)}
|
||
style={{background:C.accent,color:"#fff",border:"none",borderRadius:6,padding:"7px 20px",
|
||
fontSize:13.5,cursor:"pointer",fontFamily:FONT,fontWeight:600}}>Next →</button>
|
||
</div>
|
||
</>
|
||
:<>
|
||
<div style={{fontSize:17,fontWeight:700,color:C.text,marginBottom:14}}>Confirm restore</div>
|
||
<div style={{fontSize:13.5,color:C.text,lineHeight:1.7,marginBottom:20}}>
|
||
This will replace your <strong>local DashMail database</strong> with data from:<br/>
|
||
<span style={{color:C.accent,fontWeight:600}}>{restoreFile}</span>
|
||
</div>
|
||
<div style={{background:"#fef2f2",border:"1px solid #fca5a5",borderRadius:7,padding:"10px 14px",marginBottom:22,
|
||
display:"flex",alignItems:"flex-start",gap:9}}>
|
||
<i className="ti ti-alert-triangle" style={{fontSize:15,color:"#c50f1f",flexShrink:0,marginTop:1}} aria-hidden="true"/>
|
||
<div style={{fontSize:12.5,color:"#7f1d1d",lineHeight:1.6}}>
|
||
Your current local database will be overwritten. A safety backup will be saved to your first backup location before restore begins. The SMB share will not be touched.
|
||
</div>
|
||
</div>
|
||
<div style={{display:"flex",justifyContent:"flex-end",gap:8}}>
|
||
<button onClick={()=>setRestoreConfirm(false)}
|
||
style={{background:"transparent",border:`1px solid ${C.border}`,borderRadius:6,padding:"7px 18px",
|
||
fontSize:13.5,cursor:"pointer",fontFamily:FONT,color:C.muted}}>Back</button>
|
||
<button onClick={doRestore} disabled={restoreRunning}
|
||
style={{background:C.danger||"#c50f1f",color:"#fff",border:"none",borderRadius:6,padding:"7px 20px",
|
||
fontSize:13.5,cursor:restoreRunning?"default":"pointer",fontFamily:FONT,fontWeight:600,
|
||
display:"flex",alignItems:"center",gap:7,opacity:restoreRunning?0.75:1}}>
|
||
{restoreRunning
|
||
?<><i className="ti ti-loader-2 ti-spin" style={{fontSize:14}} aria-hidden="true"/>Restoring…</>
|
||
:<><i className="ti ti-database-import" style={{fontSize:14}} aria-hidden="true"/>Restore now</>}
|
||
</button>
|
||
</div>
|
||
</>}
|
||
</div>
|
||
</div>}
|
||
</>;
|
||
}
|
||
|
||
// ── Contacts ──────────────────────────────────────────────────────────────────
|
||
function RecipientInput({value,onChange,contacts,fieldSt,C}){
|
||
const toChips=v=>v?v.split(",").map(s=>s.trim()).filter(Boolean):[];
|
||
const[chips,setChips]=useState(()=>toChips(value));
|
||
const[inputVal,setInputVal]=useState("");
|
||
const[open,setOpen]=useState(false);
|
||
const[candidates,setCandidates]=useState([]);
|
||
const inputRef=useRef();
|
||
const internal=useRef(false);
|
||
|
||
useEffect(()=>{
|
||
if(internal.current){internal.current=false;return;}
|
||
setChips(toChips(value));
|
||
},[value]);
|
||
|
||
function syncUp(newChips){
|
||
internal.current=true;
|
||
setChips(newChips);
|
||
onChange(newChips.join(", "));
|
||
}
|
||
function addChip(str){
|
||
const s=str.trim();
|
||
if(!s)return;
|
||
syncUp([...chips,s]);
|
||
setInputVal("");setOpen(false);
|
||
}
|
||
function removeChip(i){syncUp(chips.filter((_,idx)=>idx!==i));}
|
||
function handleKey(e){
|
||
if((e.key===","||e.key==="Enter"||e.key==="Tab")&&inputVal.trim()){
|
||
e.preventDefault();addChip(inputVal);
|
||
}
|
||
if(e.key==="Backspace"&&!inputVal&&chips.length>0) syncUp(chips.slice(0,-1));
|
||
}
|
||
function filter(v){
|
||
if(!v.trim()){setOpen(false);return;}
|
||
const m=contacts.filter(c=>
|
||
c.name.toLowerCase().includes(v.toLowerCase())||
|
||
c.email.toLowerCase().includes(v.toLowerCase())
|
||
).slice(0,6);
|
||
setCandidates(m);setOpen(m.length>0);
|
||
}
|
||
function pick(c){addChip(c.name+" <"+c.email+">");inputRef.current?.focus();}
|
||
|
||
return <div style={{flex:1,position:"relative",display:"flex",flexWrap:"wrap",alignItems:"center",
|
||
gap:"3px 5px",cursor:"text",paddingTop:3,paddingBottom:3,minHeight:28}}
|
||
onClick={()=>inputRef.current?.focus()}>
|
||
{chips.map((chip,i)=>(
|
||
<div key={i} style={{display:"flex",alignItems:"center",gap:3,
|
||
background:C.accentBg,border:`1px solid ${C.accentBorder}`,borderRadius:4,
|
||
padding:"2px 4px 2px 8px",fontSize:12.5,color:C.text,maxWidth:220,flexShrink:0}}>
|
||
<span style={{overflow:"hidden",textOverflow:"ellipsis",whiteSpace:"nowrap"}}>{chip}</span>
|
||
<button onMouseDown={e=>{e.preventDefault();removeChip(i);}}
|
||
style={{background:"transparent",border:"none",padding:"0 2px",cursor:"pointer",
|
||
color:C.muted,fontSize:11,lineHeight:1,display:"flex",alignItems:"center"}}>
|
||
<i className="ti ti-x" aria-hidden="true"/>
|
||
</button>
|
||
</div>
|
||
))}
|
||
<input ref={inputRef} value={inputVal}
|
||
onChange={e=>{setInputVal(e.target.value);filter(e.target.value);}}
|
||
onKeyDown={handleKey}
|
||
onBlur={()=>{setTimeout(()=>setOpen(false),150);if(inputVal.trim())addChip(inputVal);}}
|
||
style={{...fieldSt,flex:"1 1 80px",minWidth:60,padding:0}}/>
|
||
{open&&<div style={{position:"absolute",top:"calc(100% + 2px)",left:0,right:0,zIndex:9999,
|
||
background:C.surface,border:`1px solid ${C.border}`,borderRadius:7,
|
||
boxShadow:"0 6px 20px rgba(0,0,0,0.18)",maxHeight:220,overflowY:"auto"}}>
|
||
{candidates.map(c=>(
|
||
<div key={c.id} onMouseDown={e=>{e.preventDefault();pick(c);}}
|
||
style={{display:"flex",alignItems:"center",gap:9,padding:"8px 12px",cursor:"pointer"}}
|
||
onMouseEnter={e=>e.currentTarget.style.background=C.hover}
|
||
onMouseLeave={e=>e.currentTarget.style.background="transparent"}>
|
||
<Avatar initials={c.av} color={c.avColor} size={28}/>
|
||
<div>
|
||
<div style={{fontSize:13,fontWeight:600,color:C.text}}>{c.name}</div>
|
||
<div style={{fontSize:11.5,color:C.subtle}}>{c.email}</div>
|
||
</div>
|
||
</div>
|
||
))}
|
||
</div>}
|
||
</div>;
|
||
}
|
||
|
||
function ContactPickerModal({contacts,onClose,onAdd,C}){
|
||
const[search,setSearch]=useState("");
|
||
const[sel,setSel]=useState(new Set());
|
||
const filtered=search?contacts.filter(c=>
|
||
c.name.toLowerCase().includes(search.toLowerCase())||
|
||
c.email.toLowerCase().includes(search.toLowerCase())
|
||
):contacts;
|
||
function toggle(id){setSel(s=>{const n=new Set(s);n.has(id)?n.delete(id):n.add(id);return n;});}
|
||
return <div style={{position:"fixed",inset:0,zIndex:9998,background:"rgba(0,0,0,0.45)",display:"flex",alignItems:"center",justifyContent:"center"}}>
|
||
<div style={{width:"min(440px,92vw)",height:"min(520px,85vh)",background:C.surface,borderRadius:12,
|
||
border:`1px solid ${C.border}`,boxShadow:"0 20px 60px rgba(0,0,0,0.3)",
|
||
display:"flex",flexDirection:"column",overflow:"hidden",fontFamily:FONT}}>
|
||
<div style={{display:"flex",alignItems:"center",justifyContent:"space-between",padding:"14px 18px",borderBottom:`1px solid ${C.border}`,flexShrink:0}}>
|
||
<span style={{fontSize:16,fontWeight:700,color:C.text}}>Add from Contacts</span>
|
||
<IBtn icon="ti-x" C={C} onClick={onClose}/>
|
||
</div>
|
||
<div style={{padding:"10px 14px",borderBottom:`1px solid ${C.border}`,flexShrink:0}}>
|
||
<input value={search} onChange={e=>setSearch(e.target.value)} placeholder="Search contacts…"
|
||
style={{width:"100%",boxSizing:"border-box",padding:"7px 10px",border:`1px solid ${C.inBorder}`,
|
||
borderRadius:6,fontSize:13,fontFamily:FONT,outline:"none",color:C.text,background:C.inputBg}}/>
|
||
</div>
|
||
<div style={{flex:1,overflowY:"auto"}}>
|
||
{filtered.map(c=>{
|
||
const on=sel.has(c.id);
|
||
return <div key={c.id} onClick={()=>toggle(c.id)}
|
||
style={{display:"flex",alignItems:"center",gap:10,padding:"10px 14px",cursor:"pointer",
|
||
background:on?C.accentBg:"transparent"}}
|
||
onMouseEnter={e=>{if(!on)e.currentTarget.style.background=C.hover;}}
|
||
onMouseLeave={e=>{if(!on)e.currentTarget.style.background="transparent";}}>
|
||
<Avatar initials={c.av} color={c.avColor} size={36}/>
|
||
<div style={{flex:1}}>
|
||
<div style={{fontSize:13.5,fontWeight:600,color:C.text}}>{c.name}</div>
|
||
<div style={{fontSize:12,color:C.subtle}}>{c.email}</div>
|
||
</div>
|
||
<div style={{width:20,height:20,borderRadius:"50%",border:`2px solid ${on?C.accent:C.border}`,
|
||
background:on?C.accent:"transparent",display:"flex",alignItems:"center",justifyContent:"center",flexShrink:0}}>
|
||
{on&&<i className="ti ti-check" style={{fontSize:11,color:"#fff"}} aria-hidden="true"/>}
|
||
</div>
|
||
</div>;
|
||
})}
|
||
{filtered.length===0&&<div style={{padding:24,textAlign:"center",color:C.muted,fontSize:13}}>No contacts found</div>}
|
||
</div>
|
||
<div style={{padding:"12px 16px",borderTop:`1px solid ${C.border}`,display:"flex",gap:8,justifyContent:"flex-end",flexShrink:0,background:C.surface2}}>
|
||
<button onClick={onClose} style={{background:"transparent",border:`1px solid ${C.border}`,borderRadius:6,padding:"7px 14px",fontSize:13,cursor:"pointer",fontFamily:FONT,color:C.muted}}>Cancel</button>
|
||
<button onClick={()=>{onAdd(contacts.filter(c=>sel.has(c.id)));onClose();}}
|
||
disabled={sel.size===0}
|
||
style={{background:sel.size>0?C.accent:"#aaa",color:"#fff",border:"none",borderRadius:6,padding:"7px 18px",fontSize:13,cursor:sel.size>0?"pointer":"default",fontFamily:FONT,fontWeight:600}}>
|
||
Add{sel.size>0?` (${sel.size})`:""} to To
|
||
</button>
|
||
</div>
|
||
</div>
|
||
</div>;
|
||
}
|
||
|
||
function ContactForm({contact,onSave,onCancel,C}){
|
||
const[name,setName]=useState(contact?.name??"");
|
||
const[email,setEmail]=useState(contact?.email??"");
|
||
const[phone,setPhone]=useState(contact?.phone??"");
|
||
const[address,setAddress]=useState(contact?.address??"");
|
||
const[av,setAv]=useState(contact?.av??"");
|
||
const[avColor,setAvColor]=useState(contact?.avColor??ACCENTS[0]);
|
||
const[emailErr,setEmailErr]=useState("");
|
||
|
||
function autoInitials(n){
|
||
const parts=n.trim().split(/\s+/);
|
||
return parts.length>=2?(parts[0][0]+parts[parts.length-1][0]).toUpperCase():n.slice(0,2).toUpperCase();
|
||
}
|
||
function handleName(v){
|
||
setName(v);
|
||
if(!av||av===autoInitials(name))setAv(autoInitials(v));
|
||
}
|
||
function handleSave(){
|
||
if(!/^[^\s@]+@[^\s@]+\.[^\s@]+$/.test(email.trim())){setEmailErr("Enter a valid email address.");return;}
|
||
onSave({name:name.trim(),email:email.trim(),phone:phone.trim(),address:address.trim(),av:av.slice(0,2)||"?",avColor});
|
||
}
|
||
const inputSt={width:"100%",boxSizing:"border-box",padding:"7px 10px",border:`1px solid ${C.inBorder}`,
|
||
borderRadius:5,fontSize:13.5,fontFamily:FONT,outline:"none",color:C.text,background:C.inputBg};
|
||
return <div style={{padding:"14px 0 6px"}}>
|
||
<div style={{display:"flex",gap:14,marginBottom:12}}>
|
||
<div style={{flex:2}}>
|
||
<label style={{display:"block",fontSize:12.5,fontWeight:600,color:C.muted,marginBottom:5}}>Full name</label>
|
||
<input value={name} onChange={e=>handleName(e.target.value)} placeholder="Sarah Chen" style={inputSt}/>
|
||
</div>
|
||
<div>
|
||
<label style={{display:"block",fontSize:12.5,fontWeight:600,color:C.muted,marginBottom:5}}>Initials</label>
|
||
<input value={av} onChange={e=>setAv(e.target.value.toUpperCase().slice(0,2))} maxLength={2}
|
||
style={{width:56,padding:"7px 10px",border:`1px solid ${C.inBorder}`,borderRadius:5,
|
||
fontSize:13.5,fontFamily:FONT,outline:"none",color:C.text,background:C.inputBg,
|
||
textAlign:"center",fontWeight:700,boxSizing:"border-box"}}/>
|
||
</div>
|
||
</div>
|
||
<div style={{marginBottom:12}}>
|
||
<label style={{display:"block",fontSize:12.5,fontWeight:600,color:C.muted,marginBottom:5}}>Email address</label>
|
||
<input value={email} onChange={e=>{setEmail(e.target.value);setEmailErr("");}} placeholder="sarah@example.com" type="email"
|
||
style={{...inputSt,border:`1px solid ${emailErr?"#c50f1f":C.inBorder}`}}/>
|
||
{emailErr&&<div style={{fontSize:12,color:"#c50f1f",marginTop:4}}>{emailErr}</div>}
|
||
</div>
|
||
<div style={{display:"flex",gap:12,marginBottom:12}}>
|
||
<div style={{flex:1}}>
|
||
<label style={{display:"block",fontSize:12.5,fontWeight:600,color:C.muted,marginBottom:5}}>Phone <span style={{fontWeight:400,color:C.subtle,fontSize:11.5}}>(optional)</span></label>
|
||
<input value={phone} onChange={e=>setPhone(e.target.value)} placeholder="+1 555 000 0000" type="tel" style={inputSt}/>
|
||
</div>
|
||
</div>
|
||
<div style={{marginBottom:14}}>
|
||
<label style={{display:"block",fontSize:12.5,fontWeight:600,color:C.muted,marginBottom:5}}>Address <span style={{fontWeight:400,color:C.subtle,fontSize:11.5}}>(optional)</span></label>
|
||
<textarea value={address} onChange={e=>setAddress(e.target.value)} placeholder="123 Main St, City, State 00000" rows={2}
|
||
style={{...inputSt,resize:"vertical",lineHeight:1.5,fontFamily:FONT}}/>
|
||
</div>
|
||
<label style={{display:"block",fontSize:12.5,fontWeight:600,color:C.muted,marginBottom:7}}>Avatar color</label>
|
||
<div style={{display:"flex",gap:7,flexWrap:"wrap",marginBottom:14}}>
|
||
{ACCENTS.map(a=>(
|
||
<div key={a} onClick={()=>setAvColor(a)}
|
||
style={{width:24,height:24,borderRadius:5,background:a,cursor:"pointer",
|
||
border:`2.5px solid ${a===avColor?"#fff":"transparent"}`,
|
||
outline:a===avColor?`2px solid ${a}`:"none",transition:"transform 0.1s"}}
|
||
onMouseEnter={e=>e.currentTarget.style.transform="scale(1.15)"}
|
||
onMouseLeave={e=>e.currentTarget.style.transform="scale(1)"}/>
|
||
))}
|
||
</div>
|
||
{(name||email)&&<div style={{display:"flex",alignItems:"center",gap:10,padding:"10px 12px",
|
||
background:C.accentBg,borderRadius:7,marginBottom:14}}>
|
||
<Avatar initials={av||"?"} color={avColor} size={36}/>
|
||
<div>
|
||
<div style={{fontSize:13,fontWeight:600,color:C.text}}>{name||"Name"}</div>
|
||
<div style={{fontSize:12,color:C.subtle}}>{email||"email@example.com"}</div>
|
||
{phone&&<div style={{fontSize:11.5,color:C.subtle}}>{phone}</div>}
|
||
</div>
|
||
</div>}
|
||
<div style={{display:"flex",gap:8}}>
|
||
<button onClick={handleSave}
|
||
style={{background:C.accent,color:"#fff",border:"none",borderRadius:5,padding:"7px 18px",
|
||
fontSize:13,cursor:"pointer",fontFamily:FONT,fontWeight:600}}>
|
||
{contact?"Save changes":"Add contact"}
|
||
</button>
|
||
<button onClick={onCancel}
|
||
style={{background:"transparent",border:`1px solid ${C.border}`,borderRadius:5,
|
||
padding:"7px 12px",fontSize:13,cursor:"pointer",fontFamily:FONT,color:C.muted}}>
|
||
Cancel
|
||
</button>
|
||
</div>
|
||
</div>;
|
||
}
|
||
|
||
function parseCSVLine(line){
|
||
const result=[];let cur="";let inQ=false;
|
||
for(let i=0;i<line.length;i++){
|
||
const ch=line[i];
|
||
if(ch==='"'){inQ=!inQ;}
|
||
else if(ch===","&&!inQ){result.push(cur.trim().replace(/^"|"$/g,""));cur="";}
|
||
else{cur+=ch;}
|
||
}
|
||
result.push(cur.trim().replace(/^"|"$/g,""));
|
||
return result;
|
||
}
|
||
function importCSV(text){
|
||
const lines=text.trim().split(/\r?\n/).filter(l=>l.trim());
|
||
if(lines.length<2)return[];
|
||
const headers=parseCSVLine(lines[0]).map(h=>h.toLowerCase());
|
||
const col=(...names)=>headers.findIndex(h=>names.some(n=>h.includes(n)));
|
||
const nameCol=col("name","full name","contact name");
|
||
const emailCol=col("email","e-mail","mail");
|
||
const phoneCol=col("phone","tel","mobile","cell","number");
|
||
const addressCol=col("address","street","addr","location","city");
|
||
return lines.slice(1).map(line=>{
|
||
const cols=parseCSVLine(line);
|
||
const name=(nameCol>=0?cols[nameCol]:"").trim();
|
||
const email=(emailCol>=0?cols[emailCol]:"").trim();
|
||
if(!name&&!email)return null;
|
||
const parts=(name||"").trim().split(/\s+/);
|
||
const av=parts.length>=2?(parts[0][0]+parts[parts.length-1][0]).toUpperCase():(name||email).slice(0,2).toUpperCase();
|
||
return{name:name||email,email,phone:(phoneCol>=0?cols[phoneCol]:"").trim(),address:(addressCol>=0?cols[addressCol]:"").trim(),av,avColor:ACCENTS[Math.floor(Math.random()*ACCENTS.length)]};
|
||
}).filter(Boolean);
|
||
}
|
||
|
||
function ContactsManager({contacts,setContacts,onClose,onCompose,C}){
|
||
const[search,setSearch]=useState("");
|
||
const[editId,setEditId]=useState(null);
|
||
const[confirmDelete,setConfirmDelete]=useState(null);
|
||
const[importToast,setImportToast]=useState(null);
|
||
const csvRef=useRef();
|
||
|
||
const filtered=search
|
||
?contacts.filter(c=>c.name.toLowerCase().includes(search.toLowerCase())||c.email.toLowerCase().includes(search.toLowerCase())||(c.phone||"").includes(search))
|
||
:contacts;
|
||
|
||
function addContact(data){setContacts(p=>[...p,{id:"ct"+uid(),...data}]);setEditId(null);}
|
||
function saveContact(id,data){setContacts(p=>p.map(c=>c.id===id?{...c,...data}:c));setEditId(null);}
|
||
function deleteContact(id){setContacts(p=>p.filter(c=>c.id!==id));setConfirmDelete(null);if(editId===id)setEditId(null);}
|
||
|
||
function handleCSV(e){
|
||
const file=e.target.files[0];
|
||
if(!file)return;
|
||
const reader=new FileReader();
|
||
reader.onload=ev=>{
|
||
const parsed=importCSV(ev.target.result);
|
||
if(parsed.length===0){setImportToast({type:"err",msg:"No valid contacts found in CSV."});setTimeout(()=>setImportToast(null),3500);return;}
|
||
setContacts(p=>{
|
||
const existing=new Set(p.map(c=>c.email.toLowerCase()));
|
||
const added=parsed.filter(c=>!existing.has(c.email.toLowerCase())).map(c=>({...c,id:"ct"+uid()}));
|
||
const skipped=parsed.length-added.length;
|
||
setImportToast({type:"ok",msg:`Imported ${added.length} contact${added.length!==1?"s":""}${skipped>0?`, ${skipped} skipped (duplicate)`:"."}`});
|
||
setTimeout(()=>setImportToast(null),4000);
|
||
return[...p,...added];
|
||
});
|
||
};
|
||
reader.readAsText(file);
|
||
e.target.value="";
|
||
}
|
||
|
||
return <div style={{position:"fixed",inset:0,zIndex:350,background:"rgba(0,0,0,0.6)",
|
||
display:"flex",alignItems:"center",justifyContent:"center",fontFamily:FONT}}>
|
||
<div style={{width:"min(600px,92vw)",height:"min(700px,90vh)",background:C.surface,borderRadius:12,
|
||
border:`1px solid ${C.border}`,boxShadow:`0 20px 60px rgba(0,0,0,${C.surface==="#ffffff"?0.25:0.7})`,
|
||
display:"flex",flexDirection:"column",overflow:"hidden"}}>
|
||
|
||
{/* Header */}
|
||
<div style={{display:"flex",alignItems:"center",justifyContent:"space-between",
|
||
padding:"16px 20px",borderBottom:`1px solid ${C.border}`,flexShrink:0}}>
|
||
<div style={{display:"flex",alignItems:"center",gap:10}}>
|
||
<i className="ti ti-address-book" style={{fontSize:22,color:C.accent}} aria-hidden="true"/>
|
||
<span style={{fontSize:18,fontWeight:700,color:C.text}}>Contacts</span>
|
||
<span style={{fontSize:12,color:C.muted,background:C.surface2,border:`1px solid ${C.border}`,
|
||
borderRadius:10,padding:"1px 8px",fontWeight:600}}>{contacts.length}</span>
|
||
</div>
|
||
<IBtn icon="ti-x" title="Close" C={C} onClick={onClose} size={18}/>
|
||
</div>
|
||
|
||
{/* Search + action bar */}
|
||
<div style={{display:"flex",gap:8,padding:"10px 16px",borderBottom:`1px solid ${C.border}`,flexShrink:0}}>
|
||
<div style={{flex:1,position:"relative"}}>
|
||
<i className="ti ti-search" style={{position:"absolute",left:9,top:"50%",transform:"translateY(-50%)",fontSize:13,color:C.subtle}} aria-hidden="true"/>
|
||
<input value={search} onChange={e=>setSearch(e.target.value)} placeholder="Search contacts…"
|
||
style={{width:"100%",boxSizing:"border-box",padding:"7px 9px 7px 28px",
|
||
border:`1px solid ${C.inBorder}`,borderRadius:6,fontSize:13,
|
||
fontFamily:FONT,outline:"none",color:C.text,background:C.inputBg}}/>
|
||
</div>
|
||
<input ref={csvRef} type="file" accept=".csv,text/csv" onChange={handleCSV} style={{display:"none"}}/>
|
||
<button onClick={()=>csvRef.current.click()}
|
||
style={{background:"transparent",border:`1px solid ${C.border}`,borderRadius:6,padding:"7px 12px",
|
||
fontSize:13,cursor:"pointer",fontFamily:FONT,color:C.muted,
|
||
display:"flex",alignItems:"center",gap:6,flexShrink:0,whiteSpace:"nowrap"}}
|
||
title="Import contacts from a CSV file">
|
||
<i className="ti ti-file-import" style={{fontSize:14}} aria-hidden="true"/>Import CSV
|
||
</button>
|
||
<button onClick={()=>setEditId("__new__")}
|
||
style={{background:C.accent,color:"#fff",border:"none",borderRadius:6,padding:"7px 14px",
|
||
fontSize:13,cursor:"pointer",fontFamily:FONT,fontWeight:600,
|
||
display:"flex",alignItems:"center",gap:6,flexShrink:0,whiteSpace:"nowrap"}}>
|
||
<i className="ti ti-plus" style={{fontSize:14}} aria-hidden="true"/>New Contact
|
||
</button>
|
||
</div>
|
||
|
||
{/* Import toast */}
|
||
{importToast&&<div style={{padding:"9px 16px",background:importToast.type==="ok"?"#f0fdf4":"#fef2f2",
|
||
borderBottom:`1px solid ${importToast.type==="ok"?"#86efac":"#fecaca"}`,flexShrink:0,
|
||
display:"flex",alignItems:"center",gap:8,fontSize:13}}>
|
||
<i className={`ti ${importToast.type==="ok"?"ti-circle-check":"ti-alert-circle"}`}
|
||
style={{fontSize:15,color:importToast.type==="ok"?"#22c55e":"#ef4444",flexShrink:0}} aria-hidden="true"/>
|
||
<span style={{color:importToast.type==="ok"?"#15803d":"#dc2626"}}>{importToast.msg}</span>
|
||
<button onClick={()=>setImportToast(null)} style={{marginLeft:"auto",background:"transparent",border:"none",cursor:"pointer",color:"#94a3b8",padding:0,lineHeight:1}}>
|
||
<i className="ti ti-x" style={{fontSize:13}} aria-hidden="true"/>
|
||
</button>
|
||
</div>}
|
||
|
||
{/* New contact form */}
|
||
{editId==="__new__"&&<div style={{padding:"0 18px",borderBottom:`1px solid ${C.border}`,
|
||
background:C.surface2,flexShrink:0,overflowY:"auto",maxHeight:420}}>
|
||
<ContactForm C={C} onSave={addContact} onCancel={()=>setEditId(null)}/>
|
||
</div>}
|
||
|
||
{/* List */}
|
||
<div style={{flex:1,overflowY:"auto"}}>
|
||
{filtered.length===0&&<div style={{padding:32,textAlign:"center",color:C.muted,fontSize:13}}>
|
||
<i className="ti ti-user-off" style={{fontSize:32,display:"block",marginBottom:8,color:C.subtle}} aria-hidden="true"/>
|
||
{search?"No contacts match your search":"No contacts yet"}
|
||
</div>}
|
||
{filtered.map(c=>{
|
||
const isEditing=editId===c.id;
|
||
return <div key={c.id} style={{borderBottom:`1px solid ${C.border}`,background:isEditing?C.surface2:"transparent"}}>
|
||
<div style={{display:"flex",alignItems:"center",gap:12,padding:"10px 16px"}}
|
||
onMouseEnter={e=>{if(!isEditing)e.currentTarget.style.background=C.hover;}}
|
||
onMouseLeave={e=>{if(!isEditing)e.currentTarget.style.background="transparent";}}>
|
||
<Avatar initials={c.av} color={c.avColor} size={38}/>
|
||
<div style={{flex:1,minWidth:0}}>
|
||
<div style={{fontSize:13.5,fontWeight:600,color:C.text}}>{c.name}</div>
|
||
<div style={{fontSize:12,color:C.subtle}}>{c.email}</div>
|
||
{(c.phone||c.address)&&<div style={{display:"flex",gap:10,marginTop:2,flexWrap:"wrap"}}>
|
||
{c.phone&&<span style={{fontSize:11.5,color:C.subtle,display:"flex",alignItems:"center",gap:3}}>
|
||
<i className="ti ti-phone" style={{fontSize:11}} aria-hidden="true"/>{c.phone}
|
||
</span>}
|
||
{c.address&&<span style={{fontSize:11.5,color:C.subtle,display:"flex",alignItems:"center",gap:3,overflow:"hidden",textOverflow:"ellipsis",whiteSpace:"nowrap",maxWidth:200}}>
|
||
<i className="ti ti-map-pin" style={{fontSize:11}} aria-hidden="true"/>{c.address}
|
||
</span>}
|
||
</div>}
|
||
</div>
|
||
<div style={{display:"flex",gap:2,flexShrink:0}}>
|
||
<IBtn icon="ti-mail" title="Compose to" C={C} size={14} onClick={()=>onCompose&&onCompose(c)}/>
|
||
<IBtn icon={isEditing?"ti-chevron-up":"ti-pencil"} title={isEditing?"Collapse":"Edit"} C={C} size={14}
|
||
onClick={()=>setEditId(isEditing?null:c.id)}/>
|
||
<IBtn icon="ti-trash" title="Delete" C={C} danger size={14}
|
||
onClick={()=>setConfirmDelete(c.id)}/>
|
||
</div>
|
||
</div>
|
||
{isEditing&&<div style={{padding:"0 16px",borderTop:`1px solid ${C.border}`}}>
|
||
<ContactForm contact={c} C={C}
|
||
onSave={data=>saveContact(c.id,data)}
|
||
onCancel={()=>setEditId(null)}/>
|
||
</div>}
|
||
</div>;
|
||
})}
|
||
</div>
|
||
|
||
{/* Footer */}
|
||
<div style={{padding:"10px 16px",borderTop:`1px solid ${C.border}`,background:C.surface2,
|
||
flexShrink:0,display:"flex",alignItems:"center",justifyContent:"space-between"}}>
|
||
<span style={{fontSize:12,color:C.subtle}}>CSV columns: name, email, phone, address</span>
|
||
<button onClick={onClose}
|
||
style={{background:"transparent",border:`1px solid ${C.border}`,borderRadius:6,
|
||
padding:"7px 18px",fontSize:13,cursor:"pointer",fontFamily:FONT,color:C.muted}}>
|
||
Done
|
||
</button>
|
||
</div>
|
||
</div>
|
||
|
||
{/* Confirm delete */}
|
||
{confirmDelete&&<div style={{position:"fixed",inset:0,zIndex:400,background:"rgba(0,0,0,0.5)",
|
||
display:"flex",alignItems:"center",justifyContent:"center"}}>
|
||
<div style={{width:"min(360px,90vw)",background:C.surface,borderRadius:10,
|
||
border:`1px solid ${C.border}`,padding:"24px 22px",boxShadow:"0 12px 40px rgba(0,0,0,0.3)",fontFamily:FONT}}>
|
||
<div style={{fontSize:16,fontWeight:700,color:C.text,marginBottom:10}}>Delete contact?</div>
|
||
<div style={{fontSize:13.5,color:C.muted,marginBottom:20,lineHeight:1.6}}>
|
||
"{contacts.find(c=>c.id===confirmDelete)?.name}" will be permanently removed.
|
||
</div>
|
||
<div style={{display:"flex",gap:8,justifyContent:"flex-end"}}>
|
||
<button onClick={()=>setConfirmDelete(null)}
|
||
style={{background:"transparent",border:`1px solid ${C.border}`,borderRadius:6,
|
||
padding:"7px 14px",fontSize:13,cursor:"pointer",fontFamily:FONT,color:C.muted}}>
|
||
Cancel
|
||
</button>
|
||
<button onClick={()=>deleteContact(confirmDelete)}
|
||
style={{background:"#c50f1f",color:"#fff",border:"none",borderRadius:6,
|
||
padding:"7px 18px",fontSize:13,cursor:"pointer",fontFamily:FONT,fontWeight:600}}>
|
||
Delete
|
||
</button>
|
||
</div>
|
||
</div>
|
||
</div>}
|
||
</div>;
|
||
}
|
||
|
||
// ── Compose Modal ─────────────────────────────────────────────────────────────
|
||
function ComposeModal({mode,initial,fromEmail,onClose,onSend,C,contacts=[]}){
|
||
const TITLES={new:"New Message",reply:"Reply",replyAll:"Reply All",forward:"Forward",forwardAsAtt:"Forward as Attachment"};
|
||
const[to,setTo]=useState(initial?.to??"");
|
||
const[cc,setCc]=useState(initial?.cc??"");
|
||
const[bcc,setBcc]=useState(initial?.bcc??"");
|
||
const[subj,setSubj]=useState(initial?.subject??"");
|
||
const[showCc,setShowCc]=useState(!!(initial?.cc));
|
||
const[showBcc,setShowBcc]=useState(false);
|
||
const[minimized,setMin]=useState(false);
|
||
const[attachments,setAtts]=useState(initial?.attachments??[]);
|
||
const[dragging,setDrag]=useState(false);
|
||
const[showFmt,setShowFmt]=useState(false);
|
||
const[bodyEmpty,setBodyEmpty]=useState(!initial?.body);
|
||
const[emojiHint,setEmojiHint]=useState(false);
|
||
const[showContacts,setShowContacts]=useState(false);
|
||
const fileRef=useRef();
|
||
const bodyRef=useRef();
|
||
|
||
useEffect(()=>{
|
||
if(!bodyRef.current)return;
|
||
const html=initial?.body
|
||
?initial.body.replace(/&/g,'&').replace(/</g,'<').replace(/>/g,'>').replace(/\n/g,'<br>')
|
||
:'';
|
||
bodyRef.current.innerHTML=html;
|
||
setBodyEmpty(!html);
|
||
},[]);
|
||
|
||
function applyFormat(cmd){bodyRef.current?.focus();document.execCommand(cmd,false,null);}
|
||
function getBody(){return bodyRef.current?.innerHTML??'';}
|
||
|
||
function handleFiles(e){
|
||
const files=Array.from(e.target.files);
|
||
setAtts(prev=>{const ex=new Set(prev.map(f=>f.name));return[...prev,...files.filter(f=>!ex.has(f.name))];});
|
||
e.target.value="";
|
||
}
|
||
function handleDrop(e){
|
||
e.preventDefault();setDrag(false);
|
||
const files=Array.from(e.dataTransfer.files);
|
||
setAtts(prev=>{const ex=new Set(prev.map(f=>f.name));return[...prev,...files.filter(f=>!ex.has(f.name))];});
|
||
}
|
||
function removeAtt(name){setAtts(prev=>prev.filter(f=>f.name!==name));}
|
||
|
||
function getFileIcon(name){
|
||
const ext=(name.split(".").pop()||"").toLowerCase();
|
||
if(["jpg","jpeg","png","gif","svg","webp"].includes(ext))return "ti-photo";
|
||
if(["pdf"].includes(ext))return "ti-file-type-pdf";
|
||
if(["doc","docx"].includes(ext))return "ti-file-type-doc";
|
||
if(["xls","xlsx"].includes(ext))return "ti-file-type-xls";
|
||
if(["zip","gz","tar","7z"].includes(ext))return "ti-file-zip";
|
||
if(["eml","msg"].includes(ext))return "ti-mail";
|
||
return "ti-file";
|
||
}
|
||
|
||
const fieldSt={flex:1,border:"none",outline:"none",fontSize:13,fontFamily:FONT,background:"transparent",color:C.text};
|
||
const rowSt={display:"flex",alignItems:"center",borderBottom:`1px solid ${C.border}`,padding:"6px 0"};
|
||
|
||
return <div style={{position:"fixed",top:"50%",left:"50%",transform:"translate(-50%,-50%)",width:"min(660px,88vw)",zIndex:200,background:C.surface,
|
||
border:`1px solid ${C.border}`,borderRadius:10,
|
||
boxShadow:`0 16px 56px rgba(0,0,0,${C.surface==="#ffffff"?0.22:0.6})`,
|
||
display:"flex",flexDirection:"column",overflow:"hidden",fontFamily:FONT,
|
||
height:minimized?42:"80vh",transition:"height 0.2s"}}>
|
||
|
||
{/* Title bar */}
|
||
<div style={{background:C.accent,padding:"10px 14px",display:"flex",justifyContent:"space-between",alignItems:"center",flexShrink:0,cursor:"pointer"}}
|
||
onClick={()=>setMin(m=>!m)}>
|
||
<span style={{color:"#fff",fontSize:13.5,fontWeight:600}}>{TITLES[mode]??"Compose"}</span>
|
||
<div style={{display:"flex",gap:4}} onClick={e=>e.stopPropagation()}>
|
||
<button onClick={()=>setMin(m=>!m)} style={{background:"transparent",border:"none",color:"rgba(255,255,255,0.85)",cursor:"pointer",padding:"2px 6px",fontSize:16}}>
|
||
<i className={`ti ti-${minimized?"chevron-up":"minus"}`} aria-hidden="true"/>
|
||
</button>
|
||
<button onClick={onClose} style={{background:"transparent",border:"none",color:"rgba(255,255,255,0.85)",cursor:"pointer",padding:"2px 6px",fontSize:16}}>
|
||
<i className="ti ti-x" aria-hidden="true"/>
|
||
</button>
|
||
</div>
|
||
</div>
|
||
|
||
{!minimized&&<>
|
||
{/* Header fields */}
|
||
<div style={{padding:"0 14px",background:C.surface,flexShrink:0}}>
|
||
<div style={rowSt}>
|
||
<span style={{fontSize:12.5,color:C.muted,width:50,flexShrink:0}}>From</span>
|
||
<input value={fromEmail??""} readOnly style={{...fieldSt,color:C.muted}}/>
|
||
</div>
|
||
{[["To",to,setTo],...(showCc?[["Cc",cc,setCc]]:[]),...(showBcc?[["Bcc",bcc,setBcc]]:[])].map(([lbl,val,set])=>(
|
||
<div key={lbl} style={{...rowSt,alignItems:"flex-start",paddingTop:6,paddingBottom:4}}>
|
||
<span style={{fontSize:12.5,color:C.muted,width:50,flexShrink:0,paddingTop:4}}>{lbl}</span>
|
||
<RecipientInput value={val} onChange={set} contacts={contacts} fieldSt={fieldSt} C={C}/>
|
||
</div>
|
||
))}
|
||
<div style={rowSt}>
|
||
<span style={{fontSize:12.5,color:C.muted,width:50,flexShrink:0}}>Subject</span>
|
||
<input value={subj} onChange={e=>setSubj(e.target.value)}
|
||
style={{...fieldSt,fontWeight:600}}/>
|
||
<div style={{display:"flex",gap:3,flexShrink:0}}>
|
||
{!showCc &&<button onClick={()=>setShowCc(true)} style={{fontSize:11,color:C.muted,background:"transparent",border:`1px solid ${C.border}`,borderRadius:3,padding:"1px 6px",cursor:"pointer",fontFamily:FONT}}>Cc</button>}
|
||
{!showBcc&&<button onClick={()=>setShowBcc(true)} style={{fontSize:11,color:C.muted,background:"transparent",border:`1px solid ${C.border}`,borderRadius:3,padding:"1px 6px",cursor:"pointer",fontFamily:FONT}}>Bcc</button>}
|
||
</div>
|
||
</div>
|
||
</div>
|
||
|
||
{/* Attachment pills */}
|
||
{attachments.length>0&&<div style={{padding:"8px 14px",borderBottom:`1px solid ${C.border}`,background:C.surface2,display:"flex",flexWrap:"wrap",gap:6,flexShrink:0}}>
|
||
{attachments.map(f=>(
|
||
<div key={f.name} style={{display:"flex",alignItems:"center",gap:5,background:C.surface,
|
||
border:`1px solid ${C.accentBorder}`,borderRadius:5,padding:"4px 8px",fontSize:12}}>
|
||
<i className={`ti ${getFileIcon(f.name)}`} style={{fontSize:14,color:C.accent}} aria-hidden="true"/>
|
||
<span style={{color:C.text,maxWidth:140,overflow:"hidden",textOverflow:"ellipsis",whiteSpace:"nowrap"}}>{f.name}</span>
|
||
<span style={{color:C.subtle,flexShrink:0}}>{formatSize(f.size)}</span>
|
||
<button onClick={()=>removeAtt(f.name)} style={{background:"transparent",border:"none",cursor:"pointer",color:C.muted,padding:0,lineHeight:1}}>
|
||
<i className="ti ti-x" style={{fontSize:13}} aria-hidden="true"/>
|
||
</button>
|
||
</div>
|
||
))}
|
||
</div>}
|
||
|
||
{/* Body + drop zone */}
|
||
<div style={{flex:1,position:"relative"}}
|
||
onDragOver={e=>{e.preventDefault();setDrag(true);}}
|
||
onDragLeave={()=>setDrag(false)}
|
||
onDrop={handleDrop}>
|
||
{bodyEmpty&&<div style={{position:"absolute",top:12,left:14,color:C.subtle,fontSize:13.5,fontFamily:FONT,pointerEvents:"none",userSelect:"none",zIndex:1}}>Write your message here…</div>}
|
||
<div ref={bodyRef} contentEditable suppressContentEditableWarning
|
||
onInput={e=>{const t=e.currentTarget;setBodyEmpty(!t.textContent.trim());}}
|
||
style={{width:"100%",height:"100%",minHeight:180,border:"none",outline:"none",
|
||
padding:"12px 14px",fontSize:13.5,fontFamily:FONT,
|
||
color:C.text,background:"transparent",boxSizing:"border-box",
|
||
overflowY:"auto",lineHeight:1.65}}/>
|
||
{dragging&&<div style={{position:"absolute",inset:0,background:C.accentBg,
|
||
border:`2px dashed ${C.accent}`,borderRadius:4,display:"flex",
|
||
alignItems:"center",justifyContent:"center",pointerEvents:"none"}}>
|
||
<span style={{color:C.accent,fontSize:15,fontWeight:600}}>Drop files to attach</span>
|
||
</div>}
|
||
</div>
|
||
|
||
{showFmt&&<div style={{display:"flex",gap:3,padding:"5px 12px",borderTop:`1px solid ${C.border}`,background:C.surface2,flexShrink:0,alignItems:"center"}}>
|
||
{[{lbl:"B",cmd:"bold",st:{fontWeight:800}},{lbl:"I",cmd:"italic",st:{fontStyle:"italic"}},{lbl:"U",cmd:"underline",st:{textDecoration:"underline"}},{lbl:"S",cmd:"strikeThrough",st:{textDecoration:"line-through"}}].map(({lbl,cmd,st})=>(
|
||
<button key={cmd} onMouseDown={e=>{e.preventDefault();applyFormat(cmd);}}
|
||
style={{fontFamily:FONT,fontSize:13,width:28,height:28,border:`1px solid ${C.border}`,borderRadius:4,cursor:"pointer",background:C.surface,color:C.text,display:"flex",alignItems:"center",justifyContent:"center",...st}}>
|
||
{lbl}</button>
|
||
))}
|
||
<div style={{width:1,height:18,background:C.border,margin:"0 2px"}}/>
|
||
<button onMouseDown={e=>{e.preventDefault();applyFormat('justifyLeft');}} title="Align left" style={{background:"transparent",border:"none",cursor:"pointer",color:C.muted,padding:"0 4px",fontSize:15}}><i className="ti ti-align-left"/></button>
|
||
<button onMouseDown={e=>{e.preventDefault();applyFormat('justifyCenter');}} title="Align center" style={{background:"transparent",border:"none",cursor:"pointer",color:C.muted,padding:"0 4px",fontSize:15}}><i className="ti ti-align-center"/></button>
|
||
<button onMouseDown={e=>{e.preventDefault();applyFormat('justifyRight');}} title="Align right" style={{background:"transparent",border:"none",cursor:"pointer",color:C.muted,padding:"0 4px",fontSize:15}}><i className="ti ti-align-right"/></button>
|
||
<div style={{width:1,height:18,background:C.border,margin:"0 2px"}}/>
|
||
<button onMouseDown={e=>{e.preventDefault();applyFormat('insertUnorderedList');}} title="Bullet list" style={{background:"transparent",border:"none",cursor:"pointer",color:C.muted,padding:"0 4px",fontSize:15}}><i className="ti ti-list"/></button>
|
||
<button onMouseDown={e=>{e.preventDefault();applyFormat('insertOrderedList');}} title="Numbered list" style={{background:"transparent",border:"none",cursor:"pointer",color:C.muted,padding:"0 4px",fontSize:15}}><i className="ti ti-list-numbers"/></button>
|
||
</div>}
|
||
{/* Footer */}
|
||
<div style={{display:"flex",alignItems:"center",gap:6,padding:"8px 12px",
|
||
borderTop:`1px solid ${C.border}`,background:C.surface2,flexShrink:0,flexWrap:"wrap"}}>
|
||
<button onClick={()=>{onSend({to,cc,bcc,subject:subj,body:getBody(),attachments});onClose();}}
|
||
style={{background:C.accent,color:"#fff",border:"none",borderRadius:5,
|
||
padding:"7px 18px",fontSize:13.5,cursor:"pointer",fontFamily:FONT,fontWeight:600,
|
||
display:"flex",alignItems:"center",gap:6}}>
|
||
<i className="ti ti-send" style={{fontSize:14}} aria-hidden="true"/>Send
|
||
</button>
|
||
<button onClick={onClose} style={{background:"transparent",border:`1px solid ${C.border}`,
|
||
borderRadius:5,padding:"7px 12px",fontSize:13,cursor:"pointer",fontFamily:FONT,color:C.muted}}>Discard</button>
|
||
<div style={{flex:1}}/>
|
||
{/* Hidden file input */}
|
||
<input ref={fileRef} type="file" multiple onChange={handleFiles} style={{display:"none"}}/>
|
||
<button onClick={()=>fileRef.current.click()}
|
||
title="Attach files" style={{display:"flex",alignItems:"center",gap:5,
|
||
background:"transparent",border:`1px solid ${C.border}`,borderRadius:5,
|
||
padding:"6px 10px",cursor:"pointer",fontFamily:FONT,color:C.muted,fontSize:13}}>
|
||
<i className="ti ti-paperclip" style={{fontSize:15}} aria-hidden="true"/>
|
||
Attach
|
||
</button>
|
||
<IBtn icon="ti-address-book" title="Contacts" C={C} onClick={()=>setShowContacts(true)}/>
|
||
<IBtn icon="ti-text-size" title="Format text" active={showFmt} C={C} onClick={()=>setShowFmt(f=>!f)}/>
|
||
<div style={{position:"relative"}}>
|
||
<IBtn icon="ti-mood-smile" title="Emoji" C={C} onClick={()=>{bodyRef.current?.focus();setEmojiHint(true);setTimeout(()=>setEmojiHint(false),2500);}}/>
|
||
{emojiHint&&<div style={{position:"absolute",bottom:"calc(100% + 8px)",right:0,background:"#1c1c1c",color:"#fff",fontSize:12,padding:"6px 10px",borderRadius:6,whiteSpace:"nowrap",pointerEvents:"none",boxShadow:"0 4px 12px rgba(0,0,0,0.3)",zIndex:9999}}>
|
||
Press <kbd style={{background:"#444",borderRadius:3,padding:"1px 5px",fontFamily:"monospace",fontSize:11}}>⊞ Win</kbd> + <kbd style={{background:"#444",borderRadius:3,padding:"1px 5px",fontFamily:"monospace",fontSize:11}}>.</kbd> to open emoji picker
|
||
<div style={{position:"absolute",bottom:-5,right:12,width:10,height:10,background:"#1c1c1c",transform:"rotate(45deg)"}}/>
|
||
</div>}
|
||
</div>
|
||
<IBtn icon="ti-dots" title="More options" C={C}/>
|
||
</div>
|
||
</>}
|
||
{showContacts&&<ContactPickerModal contacts={contacts} C={C} onClose={()=>setShowContacts(false)}
|
||
onAdd={picked=>{
|
||
const emails=picked.map(c=>c.name+" <"+c.email+">").join(", ");
|
||
setTo(prev=>prev?(prev+", "+emails):emails);
|
||
}}/>}
|
||
</div>;
|
||
}
|
||
|
||
// ── Rules Panel ───────────────────────────────────────────────────────────────
|
||
const RULE_FIELDS=[{v:"from",l:"From"},{v:"to",l:"To"},{v:"subject",l:"Subject"},{v:"body",l:"Body"},{v:"hasAtt",l:"Has attachment"}];
|
||
const RULE_OPS=[{v:"contains",l:"contains"},{v:"notContains",l:"does not contain"},{v:"equals",l:"equals"},{v:"startsWith",l:"starts with"},{v:"endsWith",l:"ends with"}];
|
||
|
||
|
||
// ── Logo component ────────────────────────────────────────────────────────────
|
||
function DashMailLogo({size=56}){
|
||
const id=React.useId().replace(/:/g,"");
|
||
return(
|
||
<svg width={size} height={size} viewBox="0 0 56 56" fill="none" xmlns="http://www.w3.org/2000/svg">
|
||
<defs>
|
||
<linearGradient id={"g"+id} x1="0" y1="0" x2="1" y2="1">
|
||
<stop offset="0%" stopColor="#0a1e3d"/>
|
||
<stop offset="100%" stopColor="#0099b8"/>
|
||
</linearGradient>
|
||
</defs>
|
||
<rect width="56" height="56" rx="13" fill={"url(#g"+id+")"}/>
|
||
<polygon points="10,12 46,28 10,44" fill="rgba(255,255,255,0.62)"/>
|
||
<polygon points="10,12 46,28 22,33" fill="rgba(255,255,255,0.97)"/>
|
||
<line x1="10" y1="44" x2="46" y2="28" stroke="rgba(255,255,255,0.2)" strokeWidth="0.9"/>
|
||
</svg>
|
||
);
|
||
}
|
||
|
||
|
||
// ── Mountain login background ─────────────────────────────────────────────────
|
||
function MountainScene(){
|
||
return <svg width="100%" height="100%" viewBox="0 0 800 1000" preserveAspectRatio="xMidYMid slice" xmlns="http://www.w3.org/2000/svg" style={{display:"block",position:"absolute",inset:0,width:"100%",height:"100%"}}>
|
||
<defs>
|
||
<linearGradient id="mlsky" x1="0" y1="0" x2="0" y2="1">
|
||
<stop offset="0%" stopColor="#3d5060"/><stop offset="45%" stopColor="#6a8898"/><stop offset="100%" stopColor="#9ab4c0"/>
|
||
</linearGradient>
|
||
<linearGradient id="mlfarmt" x1="0" y1="0" x2="0" y2="1">
|
||
<stop offset="0%" stopColor="#bdd0da" stopOpacity="0.55"/><stop offset="100%" stopColor="#7898a8" stopOpacity="0.3"/>
|
||
</linearGradient>
|
||
<linearGradient id="mlmainL" x1="0.15" y1="0" x2="0.55" y2="1">
|
||
<stop offset="0%" stopColor="#f2f6f9"/><stop offset="22%" stopColor="#ccdde8"/><stop offset="58%" stopColor="#6a8ea0"/><stop offset="100%" stopColor="#304858"/>
|
||
</linearGradient>
|
||
<linearGradient id="mlmainR" x1="0.5" y1="0" x2="0.9" y2="1">
|
||
<stop offset="0%" stopColor="#b8d0e0"/><stop offset="32%" stopColor="#3e5c6e"/><stop offset="100%" stopColor="#1e3040"/>
|
||
</linearGradient>
|
||
<linearGradient id="mlleftmt" x1="0" y1="0" x2="0.35" y2="1">
|
||
<stop offset="0%" stopColor="#d8ecf4"/><stop offset="48%" stopColor="#5878a0"/><stop offset="100%" stopColor="#283c50"/>
|
||
</linearGradient>
|
||
<linearGradient id="mlrightmt" x1="0.65" y1="0" x2="1" y2="1">
|
||
<stop offset="0%" stopColor="#cce0ec"/><stop offset="48%" stopColor="#5070a0"/><stop offset="100%" stopColor="#243848"/>
|
||
</linearGradient>
|
||
<linearGradient id="mlsnow" x1="0" y1="0" x2="0" y2="1">
|
||
<stop offset="0%" stopColor="#b4c8d4"/><stop offset="100%" stopColor="#6a7e8c"/>
|
||
</linearGradient>
|
||
<linearGradient id="mlmist" x1="0" y1="0" x2="0" y2="1">
|
||
<stop offset="0%" stopColor="#9ab4c0" stopOpacity="0"/><stop offset="100%" stopColor="#9ab4c0" stopOpacity="0.6"/>
|
||
</linearGradient>
|
||
</defs>
|
||
<rect width="800" height="1000" fill="url(#mlsky)"/>
|
||
{/* Distant hazy range */}
|
||
<polygon points="0,560 90,400 190,470 295,345 400,425 505,370 610,415 720,375 800,400 800,590 0,590" fill="url(#mlfarmt)"/>
|
||
{/* Left mountain */}
|
||
<polygon points="-30,960 65,255 170,490 250,355 310,960" fill="url(#mlleftmt)"/>
|
||
<polygon points="65,255 38,345 67,322 96,347 65,255" fill="#eef5f9"/>
|
||
<polygon points="65,255 34,410 69,374 104,410 65,255" fill="#ddeaf2" opacity="0.6"/>
|
||
<polygon points="148,455 172,424 200,460 178,482" fill="#182e3c" opacity="0.42"/>
|
||
{/* Right mountain */}
|
||
<polygon points="490,960 635,278 728,460 808,342 850,960" fill="url(#mlrightmt)"/>
|
||
<polygon points="635,278 608,362 636,340 664,364 635,278" fill="#e8f2f8"/>
|
||
<polygon points="635,278 604,418 638,382 672,418 635,278" fill="#d8e8f2" opacity="0.62"/>
|
||
{/* Central main peak – lit face */}
|
||
<polygon points="160,1000 395,42 548,1000" fill="url(#mlmainL)"/>
|
||
{/* Central main peak – shadow face */}
|
||
<polygon points="395,42 548,1000 660,1000 546,268" fill="url(#mlmainR)"/>
|
||
{/* Summit cap */}
|
||
<polygon points="395,42 362,142 390,122 395,144 400,122 428,142 395,42" fill="#ffffff"/>
|
||
<polygon points="395,42 354,218 390,188 396,210 402,186 436,218 395,42" fill="#edf5fb" opacity="0.86"/>
|
||
{/* Snow streaks */}
|
||
<polygon points="370,256 355,338 376,305 396,336 386,256" fill="#e2f0f8" opacity="0.52"/>
|
||
<polygon points="416,245 407,318 425,290 442,318 432,245" fill="#ddeef8" opacity="0.48"/>
|
||
{/* Rock bands */}
|
||
<polygon points="325,375 358,350 394,382 376,404 338,398" fill="#182e3c" opacity="0.52"/>
|
||
<polygon points="418,334 452,308 486,345 470,366 430,358" fill="#162a38" opacity="0.48"/>
|
||
<polygon points="295,478 342,450 380,486 360,510 304,502" fill="#142838" opacity="0.40"/>
|
||
{/* Mist */}
|
||
<rect y="690" width="800" height="310" fill="url(#mlmist)"/>
|
||
{/* Foreground snow slope */}
|
||
<polygon points="-20,1000 -20,748 70,706 175,738 290,706 400,726 510,706 620,726 730,706 820,730 840,1000" fill="url(#mlsnow)"/>
|
||
<polygon points="0,740 120,716 260,742 400,718 540,742 680,718 800,736 800,754 0,754" fill="#c0d0dc" opacity="0.38"/>
|
||
</svg>;
|
||
}
|
||
|
||
// ── Account Dropdown (login page) ─────────────────────────────────────────────
|
||
function AccountDropdown({profiles,selectedId,onSelect}){
|
||
const[open,setOpen]=useState(false);
|
||
const selected=profiles.find(p=>p.id===selectedId);
|
||
return <div style={{position:"relative"}}>
|
||
<button onClick={()=>setOpen(v=>!v)}
|
||
style={{display:"flex",alignItems:"center",gap:10,width:"100%",padding:"11px 14px",
|
||
border:`1.5px solid ${open?"#22d3ee":"rgba(0,0,0,0.12)"}`,borderRadius:8,
|
||
background:"#fff",cursor:"pointer",fontFamily:FONT,textAlign:"left",
|
||
boxSizing:"border-box",transition:"all 0.15s"}}
|
||
onMouseEnter={e=>{if(!open)e.currentTarget.style.borderColor="#22d3ee";}}
|
||
onMouseLeave={e=>{if(!open)e.currentTarget.style.borderColor="rgba(0,0,0,0.12)";}}>
|
||
{selected?<>
|
||
<div style={{width:28,height:28,borderRadius:"50%",background:selected.color+"20",border:`2px solid ${selected.color}`,display:"flex",alignItems:"center",justifyContent:"center",fontSize:11,fontWeight:700,color:selected.color,flexShrink:0}}>{selected.initials}</div>
|
||
<div style={{flex:1,minWidth:0}}>
|
||
<div style={{fontSize:14,fontWeight:600,color:"#0d1728",lineHeight:1.2}}>{selected.name}</div>
|
||
<div style={{fontSize:11.5,color:"#94a3b8",overflow:"hidden",textOverflow:"ellipsis",whiteSpace:"nowrap"}}>{selected.email??"Default account"}</div>
|
||
</div>
|
||
</>:<span style={{color:"#94a3b8",fontSize:14}}>Select account…</span>}
|
||
<i className={`ti ti-chevron-${open?"up":"down"}`} style={{fontSize:15,color:"#94a3b8",flexShrink:0}} aria-hidden="true"/>
|
||
</button>
|
||
{open&&<>
|
||
<div style={{position:"fixed",inset:0,zIndex:40}} onClick={()=>setOpen(false)}/>
|
||
<div style={{position:"absolute",top:"calc(100% + 6px)",left:0,right:0,zIndex:50,background:"#fff",border:"1px solid rgba(0,0,0,0.1)",borderRadius:10,boxShadow:"0 8px 32px rgba(0,0,0,0.12)",overflow:"hidden",padding:"4px"}}>
|
||
{profiles.map(p=>{const isSel=p.id===selectedId;return <div key={p.id} onClick={()=>{onSelect(p.id);setOpen(false);}}
|
||
style={{display:"flex",alignItems:"center",gap:10,padding:"9px 12px",borderRadius:7,cursor:"pointer",background:isSel?"rgba(34,211,238,0.08)":"transparent",transition:"background 0.1s"}}
|
||
onMouseEnter={e=>e.currentTarget.style.background=isSel?"rgba(34,211,238,0.1)":"rgba(0,0,0,0.04)"}
|
||
onMouseLeave={e=>e.currentTarget.style.background=isSel?"rgba(34,211,238,0.08)":"transparent"}>
|
||
<div style={{width:32,height:32,borderRadius:"50%",background:p.color+"20",border:`2px solid ${p.color}`,display:"flex",alignItems:"center",justifyContent:"center",fontSize:12,fontWeight:700,color:p.color,flexShrink:0}}>{p.initials}</div>
|
||
<div style={{flex:1,minWidth:0}}>
|
||
<div style={{fontSize:13.5,fontWeight:600,color:"#0d1728"}}>{p.name}</div>
|
||
<div style={{fontSize:11.5,color:"#94a3b8",overflow:"hidden",textOverflow:"ellipsis",whiteSpace:"nowrap"}}>{p.email??"Default account"}</div>
|
||
</div>
|
||
{isSel&&<i className="ti ti-check" style={{fontSize:15,color:"#22d3ee",flexShrink:0}} aria-hidden="true"/>}
|
||
</div>;})}
|
||
</div>
|
||
</>}
|
||
</div>;
|
||
}
|
||
|
||
// ── Background Customizer ─────────────────────────────────────────────────────
|
||
const BG_PRESETS=["#0d1728","#1e1b4b","#14532d","#7f1d1d","#0c4a6e","#1e293b","#0f3d3d","#2e1065","#f1f5f9","#292524"];
|
||
function BgCustomizer({bgColor,setBgColor,bgImage,setBgImage,onClose}){
|
||
const[imgInput,setImgInput]=useState(bgImage);
|
||
return <div style={{position:"fixed",bottom:70,right:16,width:280,background:"#fff",borderRadius:12,boxShadow:"0 8px 40px rgba(0,0,0,0.18)",border:"1px solid rgba(0,0,0,0.08)",padding:"18px",fontFamily:FONT,zIndex:100}}>
|
||
<div style={{display:"flex",justifyContent:"space-between",alignItems:"center",marginBottom:14}}>
|
||
<span style={{fontSize:14,fontWeight:700,color:"#0d1728"}}>Background</span>
|
||
<button onClick={onClose} style={{background:"transparent",border:"none",cursor:"pointer",color:"#94a3b8",padding:2}}><i className="ti ti-x" style={{fontSize:17}} aria-hidden="true"/></button>
|
||
</div>
|
||
<div style={{fontSize:11.5,color:"#64748b",fontWeight:600,marginBottom:8,letterSpacing:"0.05em"}}>SOLID COLOR</div>
|
||
<div style={{display:"grid",gridTemplateColumns:"repeat(5,1fr)",gap:7,marginBottom:14}}>
|
||
{BG_PRESETS.map(c=><div key={c} onClick={()=>{setBgColor(c);setBgImage("");}}
|
||
style={{width:"100%",aspectRatio:"1",borderRadius:6,background:c,cursor:"pointer",border:`2.5px solid ${c===bgColor&&!bgImage?"#22d3ee":"transparent"}`,transition:"transform 0.1s"}}
|
||
onMouseEnter={e=>e.currentTarget.style.transform="scale(1.1)"}
|
||
onMouseLeave={e=>e.currentTarget.style.transform="scale(1)"}/>)}
|
||
</div>
|
||
<div style={{fontSize:11.5,color:"#64748b",fontWeight:600,marginBottom:8,letterSpacing:"0.05em"}}>IMAGE URL</div>
|
||
<div style={{display:"flex",gap:6}}>
|
||
<input value={imgInput} onChange={e=>setImgInput(e.target.value)} placeholder="https://…"
|
||
style={{flex:1,padding:"7px 9px",border:"1.5px solid rgba(0,0,0,0.12)",borderRadius:7,fontSize:12.5,fontFamily:FONT,outline:"none",color:"#0d1728",minWidth:0}}
|
||
onFocus={e=>e.target.style.borderColor="#22d3ee"} onBlur={e=>e.target.style.borderColor="rgba(0,0,0,0.12)"}/>
|
||
<button onClick={()=>setBgImage(imgInput)} style={{background:"#22d3ee",color:"#0d1728",border:"none",borderRadius:7,padding:"0 12px",fontSize:13,fontWeight:700,cursor:"pointer",fontFamily:FONT,flexShrink:0}}>Set</button>
|
||
</div>
|
||
</div>;
|
||
}
|
||
|
||
// ── Login Screen ──────────────────────────────────────────────────────────────
|
||
function LoginScreen({profiles,onLogin,loginBg,loginDark=true,onSetupNew}){
|
||
const[password,setPassword]=useState("");
|
||
const[showPw,setShowPw]=useState(false);
|
||
const lp=loginDark
|
||
?{bg:"#0c0c0c",text:"#ffffff",sub:"rgba(255,255,255,0.38)",label:"rgba(255,255,255,0.38)",
|
||
inBg:"rgba(255,255,255,0.06)",inBorder:"rgba(255,255,255,0.1)",inBorderFocus:"rgba(34,211,238,0.55)",
|
||
inFocusShadow:"rgba(34,211,238,0.12)",inText:"#f0f0f0",eyeColor:"rgba(255,255,255,0.35)",
|
||
btnEmpty:"rgba(255,255,255,0.07)",btnEmptyText:"rgba(255,255,255,0.22)",
|
||
btnFill:"#22d3ee",btnFillHov:"#06b6d4",btnFillText:"#0d1728",
|
||
footer:"rgba(255,255,255,0.1)",footerAccent:"rgba(0,196,216,0.2)"}
|
||
:{bg:"#f0f0f0",text:"#1c1c1c",sub:"rgba(0,0,0,0.42)",label:"rgba(0,0,0,0.45)",
|
||
inBg:"#ffffff",inBorder:"rgba(0,0,0,0.14)",inBorderFocus:"rgba(0,99,177,0.5)",
|
||
inFocusShadow:"rgba(0,99,177,0.1)",inText:"#1c1c1c",eyeColor:"rgba(0,0,0,0.35)",
|
||
btnEmpty:"rgba(0,0,0,0.07)",btnEmptyText:"rgba(0,0,0,0.25)",
|
||
btnFill:"#0078d4",btnFillHov:"#0063b1",btnFillText:"#ffffff",
|
||
footer:"rgba(0,0,0,0.15)",footerAccent:"rgba(0,153,184,0.35)"};
|
||
const[error,setError]=useState("");
|
||
const[shake,setShake]=useState(false);
|
||
const pwRef=useRef();
|
||
|
||
function doLogin(){
|
||
const pw=password.trim();
|
||
if(!pw){pwRef.current?.focus();return;}
|
||
const matched=profiles.find(p=>p.password===pw||pw==="demo");
|
||
if(matched){onLogin(matched);}
|
||
else{setError("Incorrect password.");setPassword("");setShake(true);setTimeout(()=>setShake(false),500);setTimeout(()=>pwRef.current?.focus(),50);}
|
||
}
|
||
|
||
return <>
|
||
<style>{`@keyframes shake{0%,100%{transform:translateX(0)}15%{transform:translateX(-7px)}35%{transform:translateX(7px)}55%{transform:translateX(-5px)}75%{transform:translateX(5px)}} @keyframes loginIn{from{opacity:0;transform:translateY(16px)}to{opacity:1;transform:translateY(0)}}`}</style>
|
||
<div style={{display:"flex",height:"100vh",overflow:"hidden",fontFamily:FONT,background:lp.bg,transition:"background 0.3s"}}>
|
||
|
||
{/* ── Left panel ─── */}
|
||
<div style={{width:"50%",minWidth:340,background:lp.bg,display:"flex",flexDirection:"column",alignItems:"center",padding:"40px 56px",flexShrink:0,position:"relative",zIndex:1,transition:"background 0.3s"}}>
|
||
|
||
{/* Logo + Wordmark */}
|
||
<div style={{display:"flex",flexDirection:"column",alignItems:"center",gap:14,marginBottom:"auto",paddingTop:60,paddingBottom:8}}>
|
||
<DashMailLogo size={64}/>
|
||
<div style={{display:"flex",alignItems:"baseline",gap:4}}>
|
||
<span style={{fontSize:38,fontWeight:900,color:lp.text,letterSpacing:"-1px"}}>Dash</span>
|
||
<span style={{fontSize:38,fontWeight:200,color:"#00c4d8",letterSpacing:"-0.6px"}}>Mail</span>
|
||
</div>
|
||
</div>
|
||
|
||
{/* Form */}
|
||
<div style={{flex:1,display:"flex",flexDirection:"column",justifyContent:"center",maxWidth:300,width:"100%",animation:shake?"shake 0.45s ease":"loginIn 0.4s ease both"}}>
|
||
<h1 style={{fontSize:26,fontWeight:700,color:lp.text,margin:"0 0 6px",letterSpacing:"-0.5px"}}>Sign in</h1>
|
||
<p style={{fontSize:13.5,color:lp.sub,margin:"0 0 30px",lineHeight:1.5}}>Enter your password to access your mailbox</p>
|
||
|
||
{/* Password */}
|
||
<div style={{marginBottom:error?12:20}}>
|
||
<label style={{display:"block",fontSize:11.5,fontWeight:700,color:lp.label,marginBottom:7,letterSpacing:"0.08em",textTransform:"uppercase"}}>Password</label>
|
||
<div style={{position:"relative"}}>
|
||
<input ref={pwRef} type={showPw?"text":"password"} value={password}
|
||
onChange={e=>{setPassword(e.target.value);setError("");}}
|
||
onKeyDown={e=>e.key==="Enter"&&doLogin()}
|
||
placeholder="Enter your password"
|
||
style={{width:"100%",boxSizing:"border-box",padding:"11px 44px 11px 14px",
|
||
border:`1.5px solid ${error?"rgba(248,113,113,0.5)":lp.inBorder}`,
|
||
borderRadius:9,fontSize:14,fontFamily:FONT,outline:"none",
|
||
color:lp.inText,background:lp.inBg,transition:"border-color 0.15s,box-shadow 0.15s"}}
|
||
onFocus={e=>{e.target.style.borderColor=lp.inBorderFocus;e.target.style.boxShadow=`0 0 0 3px ${lp.inFocusShadow}`;}}
|
||
onBlur={e=>{e.target.style.borderColor=error?"rgba(248,113,113,0.5)":lp.inBorder;e.target.style.boxShadow="none";}}/>
|
||
<button onClick={()=>setShowPw(v=>!v)}
|
||
style={{position:"absolute",right:12,top:"50%",transform:"translateY(-50%)",
|
||
background:"transparent",border:"none",cursor:"pointer",
|
||
color:lp.eyeColor,padding:4,lineHeight:1}}>
|
||
<i className={`ti ti-eye${showPw?"-off":""}`} style={{fontSize:18}} aria-hidden="true"/>
|
||
</button>
|
||
</div>
|
||
</div>
|
||
|
||
{/* Error */}
|
||
{error&&<div style={{display:"flex",alignItems:"center",gap:8,padding:"9px 12px",
|
||
background:"rgba(239,68,68,0.12)",border:"1px solid rgba(239,68,68,0.28)",
|
||
borderRadius:8,marginBottom:14}}>
|
||
<i className="ti ti-alert-circle" style={{fontSize:15,color:"#f87171",flexShrink:0}} aria-hidden="true"/>
|
||
<span style={{fontSize:13,color:loginDark?"#fca5a5":"#c50f1f"}}>{error}</span>
|
||
</div>}
|
||
|
||
{/* Login button */}
|
||
<button onClick={doLogin} disabled={!password.trim()}
|
||
style={{width:"100%",padding:"13px 0",border:"none",borderRadius:9,
|
||
background:!password.trim()?lp.btnEmpty:lp.btnFill,
|
||
color:!password.trim()?lp.btnEmptyText:lp.btnFillText,
|
||
fontSize:14.5,fontWeight:700,cursor:!password.trim()?"default":"pointer",
|
||
fontFamily:FONT,display:"flex",alignItems:"center",justifyContent:"center",gap:8,
|
||
transition:"background 0.15s",letterSpacing:"-0.1px"}}
|
||
onMouseEnter={e=>{if(password.trim())e.currentTarget.style.background=lp.btnFillHov;}}
|
||
onMouseLeave={e=>{e.currentTarget.style.background=!password.trim()?lp.btnEmpty:lp.btnFill;}}>
|
||
<svg width="17" height="17" viewBox="0 0 24 24" fill="none" stroke="currentColor" strokeWidth="2" strokeLinecap="round" strokeLinejoin="round" aria-hidden="true">
|
||
<line x1="2" y1="12" x2="14" y2="12"/>
|
||
<path d="M10 8l4 4-4 4"/>
|
||
<path d="M17 5h3v14h-3"/>
|
||
</svg>Sign In
|
||
</button>
|
||
|
||
{/* Add new account */}
|
||
{onSetupNew&&<button onClick={onSetupNew}
|
||
style={{width:"100%",marginTop:12,padding:"11px 0",border:`1px solid ${lp.inBorder}`,borderRadius:9,
|
||
background:"transparent",color:lp.sub,
|
||
fontSize:13.5,fontWeight:500,cursor:"pointer",fontFamily:FONT,
|
||
display:"flex",alignItems:"center",justifyContent:"center",gap:7,
|
||
transition:"border-color 0.15s,color 0.15s"}}
|
||
onMouseEnter={e=>{e.currentTarget.style.borderColor=lp.inBorderFocus;e.currentTarget.style.color=lp.text;}}
|
||
onMouseLeave={e=>{e.currentTarget.style.borderColor=lp.inBorder;e.currentTarget.style.color=lp.sub;}}>
|
||
<i className="ti ti-user-plus" style={{fontSize:15}} aria-hidden="true"/>
|
||
Set up new account
|
||
</button>}
|
||
</div>
|
||
|
||
{/* Footer */}
|
||
<div style={{marginTop:"auto",paddingTop:20,textAlign:"center"}}>
|
||
<p style={{fontSize:10,color:lp.footer,margin:0,letterSpacing:"1.5px",textTransform:"uppercase"}}>Dash<span style={{color:lp.footerAccent}}>Mail</span> • The Secure Faxmail Client</p>
|
||
</div>
|
||
</div>
|
||
|
||
{/* ── Right panel: mountain photo ─── */}
|
||
<div style={{flex:1,padding:"18px 18px 18px 0",display:"flex",background:lp.bg,transition:"background 0.3s"}}>
|
||
<div style={{flex:1,borderRadius:18,overflow:"hidden",
|
||
backgroundImage:`url(${loginBg||DEFAULT_LOGIN_BG})`,
|
||
backgroundSize:"cover",backgroundPosition:"center",
|
||
boxShadow:"0 12px 40px rgba(0,0,0,0.45)"}}/>
|
||
</div>
|
||
|
||
</div>
|
||
</>;
|
||
}
|
||
|
||
const ACTION_TYPES=[{v:"move",l:"Move to folder"},{v:"markRead",l:"Mark as read"},{v:"star",l:"Star / flag"},{v:"delete",l:"Move to trash"},{v:"forward",l:"Forward to"}];
|
||
|
||
function RulesInline({C,rules,setRules,allFolders}){
|
||
const[editId,setEditId]=useState(null);
|
||
const[draft,setDraft]=useState(null);
|
||
|
||
function startNew(){
|
||
const r={id:uid(),name:"New rule",enabled:true,condLogic:"all",
|
||
conditions:[{id:uid(),field:"from",op:"contains",value:""}],
|
||
actions:[{id:uid(),type:"move",folder:"inbox",forwardTo:""}]};
|
||
setDraft(r);setEditId("__new__");
|
||
}
|
||
function startEdit(rule){setDraft({...rule,conditions:rule.conditions.map(c=>({...c})),actions:rule.actions.map(a=>({...a}))});setEditId(rule.id);}
|
||
function saveRule(){
|
||
if(!draft)return;
|
||
if(editId==="__new__") setRules(prev=>[...prev,draft]);
|
||
else setRules(prev=>prev.map(r=>r.id===editId?draft:r));
|
||
setEditId(null);setDraft(null);
|
||
}
|
||
function deleteRule(id){setRules(prev=>prev.filter(r=>r.id!==id));}
|
||
function toggleRule(id){setRules(prev=>prev.map(r=>r.id===id?{...r,enabled:!r.enabled}:r));}
|
||
|
||
function addCond(){setDraft(d=>({...d,conditions:[...d.conditions,{id:uid(),field:"from",op:"contains",value:""}]}));}
|
||
function removeCond(cid){setDraft(d=>({...d,conditions:d.conditions.filter(c=>c.id!==cid)}));}
|
||
function setCond(cid,key,val){setDraft(d=>({...d,conditions:d.conditions.map(c=>c.id===cid?{...c,[key]:val}:c)}));}
|
||
function addAction(){setDraft(d=>({...d,actions:[...d.actions,{id:uid(),type:"move",folder:"inbox",forwardTo:""}]}));}
|
||
function removeAction(aid){setDraft(d=>({...d,actions:d.actions.filter(a=>a.id!==aid)}));}
|
||
function setAction(aid,key,val){setDraft(d=>({...d,actions:d.actions.map(a=>a.id===aid?{...a,[key]:val}:a)}));}
|
||
|
||
const selSt={background:C.inputBg,border:`1px solid ${C.inBorder}`,borderRadius:5,
|
||
padding:"4px 7px",color:C.text,fontFamily:FONT,fontSize:12.5,outline:"none"};
|
||
|
||
function condSummary(rule){
|
||
const parts=rule.conditions.map(c=>`${RULE_FIELDS.find(f=>f.v===c.field)?.l??"?"} ${RULE_OPS.find(o=>o.v===c.op)?.l??""} "${c.value}"`);
|
||
return parts.slice(0,2).join(` ${rule.condLogic==="all"?"AND":"OR"} `)+(parts.length>2?"…":"");
|
||
}
|
||
function actionSummary(rule){
|
||
return rule.actions.map(a=>ACTION_TYPES.find(t=>t.v===a.type)?.l??a.type).join(", ");
|
||
}
|
||
|
||
return <>
|
||
<button onClick={startNew}
|
||
style={{width:"100%",background:C.accentBg,border:`1px solid ${C.accentBorder}`,
|
||
borderRadius:7,padding:"9px 0",color:C.accent,fontFamily:FONT,fontSize:13.5,
|
||
cursor:"pointer",display:"flex",alignItems:"center",justifyContent:"center",gap:7,marginBottom:16,fontWeight:600}}>
|
||
<i className="ti ti-plus" style={{fontSize:16}} aria-hidden="true"/>New Rule
|
||
</button>
|
||
|
||
{rules.map(rule=>(
|
||
<div key={rule.id} style={{background:C.surface2,border:`1px solid ${C.border}`,
|
||
borderRadius:8,marginBottom:10,overflow:"hidden"}}>
|
||
<div style={{display:"flex",alignItems:"center",gap:10,padding:"10px 12px"}}>
|
||
<Toggle on={rule.enabled} onChange={()=>toggleRule(rule.id)} C={C}/>
|
||
<div style={{flex:1,minWidth:0}}>
|
||
<div style={{fontSize:13.5,fontWeight:600,color:rule.enabled?C.text:C.muted,
|
||
textDecoration:rule.enabled?"none":"line-through"}}>{rule.name}</div>
|
||
<div style={{fontSize:11.5,color:C.subtle,marginTop:2,overflow:"hidden",textOverflow:"ellipsis",whiteSpace:"nowrap"}}>
|
||
When: {condSummary(rule)}
|
||
</div>
|
||
<div style={{fontSize:11.5,color:C.subtle,overflow:"hidden",textOverflow:"ellipsis",whiteSpace:"nowrap"}}>
|
||
Then: {actionSummary(rule)}
|
||
</div>
|
||
</div>
|
||
<IBtn icon="ti-pencil" title="Edit" C={C} onClick={()=>startEdit(rule)}/>
|
||
<IBtn icon="ti-trash" title="Delete" C={C} danger onClick={()=>deleteRule(rule.id)}/>
|
||
</div>
|
||
{editId===rule.id&&draft&&<div style={{borderTop:`1px solid ${C.border}`,padding:"12px",background:C.surface}}>
|
||
<RuleEditor draft={draft} setDraft={setDraft} allFolders={allFolders} selSt={selSt} C={C}
|
||
addCond={addCond} removeCond={removeCond} setCond={setCond}
|
||
addAction={addAction} removeAction={removeAction} setAction={setAction}
|
||
onSave={saveRule} onCancel={()=>{setEditId(null);setDraft(null);}}/>
|
||
</div>}
|
||
</div>
|
||
))}
|
||
|
||
{editId==="__new__"&&draft&&<div style={{background:C.surface2,border:`1px solid ${C.accentBorder}`,borderRadius:8,padding:"14px",marginTop:8}}>
|
||
<div style={{fontSize:13,fontWeight:700,color:C.accent,marginBottom:10}}>New Rule</div>
|
||
<RuleEditor draft={draft} setDraft={setDraft} allFolders={allFolders} selSt={selSt} C={C}
|
||
addCond={addCond} removeCond={removeCond} setCond={setCond}
|
||
addAction={addAction} removeAction={removeAction} setAction={setAction}
|
||
onSave={saveRule} onCancel={()=>{setEditId(null);setDraft(null);}}/>
|
||
</div>}
|
||
|
||
<p style={{margin:"16px 0 0",fontSize:11.5,color:C.subtle,lineHeight:1.6}}>
|
||
Rules run on new mail arrival, applied top to bottom via the Electron chokidar watcher.
|
||
</p>
|
||
</>;
|
||
}
|
||
|
||
function RulesPanel({C,rules,setRules,allFolders,onClose}){
|
||
return <div style={{position:"fixed",top:32,right:0,bottom:28,width:440,zIndex:150,
|
||
background:C.surface,borderLeft:`1px solid ${C.border}`,
|
||
boxShadow:`-4px 0 24px rgba(0,0,0,${C.surface==="#ffffff"?0.12:0.5})`,
|
||
display:"flex",flexDirection:"column",fontFamily:FONT,overflow:"hidden"}}>
|
||
<PanelHeader title="Rules & Filters" onClose={onClose} C={C}/>
|
||
<div style={{flex:1,overflowY:"auto",padding:"14px 16px"}}>
|
||
<RulesInline C={C} rules={rules} setRules={setRules} allFolders={allFolders}/>
|
||
</div>
|
||
</div>;
|
||
}
|
||
|
||
function RuleEditor({draft,setDraft,allFolders,selSt,C,addCond,removeCond,setCond,addAction,removeAction,setAction,onSave,onCancel}){
|
||
return <>
|
||
<div style={{marginBottom:10}}>
|
||
<div style={{fontSize:12,color:C.muted,marginBottom:4,fontWeight:600}}>Rule name</div>
|
||
<input value={draft.name} onChange={e=>setDraft(d=>({...d,name:e.target.value}))}
|
||
style={{...selSt,width:"100%",boxSizing:"border-box",padding:"6px 9px",fontSize:13}}/>
|
||
</div>
|
||
<div style={{marginBottom:10}}>
|
||
<div style={{display:"flex",alignItems:"center",gap:8,marginBottom:8}}>
|
||
<span style={{fontSize:12.5,color:C.text,fontWeight:600}}>Apply when</span>
|
||
<select value={draft.condLogic} onChange={e=>setDraft(d=>({...d,condLogic:e.target.value}))} style={selSt}>
|
||
<option value="all">ALL</option><option value="any">ANY</option>
|
||
</select>
|
||
<span style={{fontSize:12.5,color:C.text}}>of these are true:</span>
|
||
</div>
|
||
{draft.conditions.map(c=>(
|
||
<div key={c.id} style={{display:"flex",gap:5,alignItems:"center",marginBottom:5}}>
|
||
<select value={c.field} onChange={e=>setCond(c.id,"field",e.target.value)} style={{...selSt,flex:1}}>
|
||
{RULE_FIELDS.map(f=><option key={f.v} value={f.v}>{f.l}</option>)}
|
||
</select>
|
||
{c.field!=="hasAtt"&&<>
|
||
<select value={c.op} onChange={e=>setCond(c.id,"op",e.target.value)} style={{...selSt,flex:1}}>
|
||
{RULE_OPS.map(o=><option key={o.v} value={o.v}>{o.l}</option>)}
|
||
</select>
|
||
<input value={c.value} onChange={e=>setCond(c.id,"value",e.target.value)}
|
||
placeholder="value…" style={{...selSt,flex:2,padding:"4px 7px"}}/>
|
||
</>}
|
||
<button onClick={()=>removeCond(c.id)} style={{background:"transparent",border:"none",cursor:"pointer",color:C.muted,padding:"3px",flexShrink:0}}>
|
||
<i className="ti ti-x" style={{fontSize:14}} aria-hidden="true"/>
|
||
</button>
|
||
</div>
|
||
))}
|
||
<button onClick={addCond} style={{background:"transparent",border:"none",cursor:"pointer",color:C.accent,fontSize:12.5,fontFamily:FONT,padding:"2px 0",display:"flex",alignItems:"center",gap:4}}>
|
||
<i className="ti ti-plus" style={{fontSize:13}} aria-hidden="true"/>Add condition
|
||
</button>
|
||
</div>
|
||
<div style={{marginBottom:12}}>
|
||
<div style={{fontSize:12.5,color:C.text,fontWeight:600,marginBottom:8}}>Do the following:</div>
|
||
{draft.actions.map(a=>(
|
||
<div key={a.id} style={{display:"flex",gap:5,alignItems:"center",marginBottom:5}}>
|
||
<select value={a.type} onChange={e=>setAction(a.id,"type",e.target.value)} style={{...selSt,flex:2}}>
|
||
{ACTION_TYPES.map(t=><option key={t.v} value={t.v}>{t.l}</option>)}
|
||
</select>
|
||
{a.type==="move"&&<select value={a.folder||"inbox"} onChange={e=>setAction(a.id,"folder",e.target.value)} style={{...selSt,flex:2}}>
|
||
{allFolders.map(f=><option key={f} value={f}>{f}</option>)}
|
||
</select>}
|
||
{a.type==="forward"&&<input value={a.forwardTo||""} onChange={e=>setAction(a.id,"forwardTo",e.target.value)}
|
||
placeholder="email@example.com" style={{...selSt,flex:2}}/>}
|
||
<button onClick={()=>removeAction(a.id)} style={{background:"transparent",border:"none",cursor:"pointer",color:C.muted,padding:"3px",flexShrink:0}}>
|
||
<i className="ti ti-x" style={{fontSize:14}} aria-hidden="true"/>
|
||
</button>
|
||
</div>
|
||
))}
|
||
<button onClick={addAction} style={{background:"transparent",border:"none",cursor:"pointer",color:C.accent,fontSize:12.5,fontFamily:FONT,padding:"2px 0",display:"flex",alignItems:"center",gap:4}}>
|
||
<i className="ti ti-plus" style={{fontSize:13}} aria-hidden="true"/>Add action
|
||
</button>
|
||
</div>
|
||
<div style={{display:"flex",gap:8}}>
|
||
<button onClick={onSave} style={{background:C.accent,color:"#fff",border:"none",borderRadius:5,padding:"7px 16px",fontSize:13,cursor:"pointer",fontFamily:FONT,fontWeight:600}}>Save rule</button>
|
||
<button onClick={onCancel} style={{background:"transparent",border:`1px solid ${C.border}`,borderRadius:5,padding:"7px 12px",fontSize:13,cursor:"pointer",fontFamily:FONT,color:C.muted}}>Cancel</button>
|
||
</div>
|
||
</>;
|
||
}
|
||
|
||
// ── Onboarding Wizard ─────────────────────────────────────────────────────────
|
||
function OnboardingWizard({accountId,onComplete,C}){
|
||
const[step,setStep]=useState(0);
|
||
const[displayName,setDisplayName]=useState("");
|
||
const[fromName,setFromName]=useState("");
|
||
const[avatarColor,setAvatarColor]=useState(C.accent);
|
||
const[signature,setSig]=useState("");
|
||
|
||
const detectedEmail=accountId==="default"?"Default Account":accountId;
|
||
const initials=detectedEmail.slice(0,2).toUpperCase();
|
||
const totalSteps=4;
|
||
|
||
const steps=[
|
||
{
|
||
icon:"ti-mail-plus",
|
||
title:"New mailbox detected",
|
||
subtitle:detectedEmail,
|
||
content:<>
|
||
<p style={{margin:"0 0 16px",fontSize:14,color:C.muted,lineHeight:1.7,textAlign:"center"}}>
|
||
A new email account was found on your mail server at<br/>
|
||
<code style={{fontSize:13,background:C.surface3,padding:"2px 8px",borderRadius:4,color:C.accent}}>.Mails/{accountId}/</code>
|
||
</p>
|
||
<p style={{margin:0,fontSize:13.5,color:C.muted,textAlign:"center",lineHeight:1.7}}>
|
||
Let's take a moment to set it up so it's ready to use.
|
||
</p>
|
||
</>,
|
||
nextLabel:"Get Started",
|
||
},
|
||
{
|
||
icon:"ti-user-circle",
|
||
title:"Set up your identity",
|
||
subtitle:"How will this account appear?",
|
||
content:<>
|
||
<div style={{marginBottom:14}}>
|
||
<label style={{display:"block",fontSize:12.5,fontWeight:600,color:C.muted,marginBottom:5}}>Your name (shown to recipients)</label>
|
||
<input value={fromName} onChange={e=>setFromName(e.target.value)} placeholder="e.g. Alex Johnson"
|
||
style={{width:"100%",boxSizing:"border-box",padding:"8px 11px",border:`1px solid ${C.inBorder}`,
|
||
borderRadius:5,fontSize:13.5,fontFamily:FONT,outline:"none",color:C.text,background:C.inputBg}}/>
|
||
</div>
|
||
<div style={{marginBottom:14}}>
|
||
<label style={{display:"block",fontSize:12.5,fontWeight:600,color:C.muted,marginBottom:5}}>Display name in app</label>
|
||
<input value={displayName} onChange={e=>setDisplayName(e.target.value)} placeholder="e.g. Alex"
|
||
style={{width:"100%",boxSizing:"border-box",padding:"8px 11px",border:`1px solid ${C.inBorder}`,
|
||
borderRadius:5,fontSize:13.5,fontFamily:FONT,outline:"none",color:C.text,background:C.inputBg}}/>
|
||
</div>
|
||
<div>
|
||
<label style={{display:"block",fontSize:12.5,fontWeight:600,color:C.muted,marginBottom:8}}>Avatar color</label>
|
||
<div style={{display:"flex",gap:8,flexWrap:"wrap"}}>
|
||
{ACCENTS.map(a=>(
|
||
<div key={a} onClick={()=>setAvatarColor(a)} style={{width:28,height:28,borderRadius:6,background:a,
|
||
border:`2.5px solid ${a===avatarColor?"#fff":"transparent"}`,outline:a===avatarColor?`2px solid ${a}`:"none",
|
||
cursor:"pointer",transition:"transform 0.1s"}}
|
||
onMouseEnter={e=>e.currentTarget.style.transform="scale(1.15)"}
|
||
onMouseLeave={e=>e.currentTarget.style.transform="scale(1)"}/>
|
||
))}
|
||
</div>
|
||
</div>
|
||
{(fromName||displayName)&&<div style={{marginTop:14,padding:"10px 14px",background:C.accentBg,borderRadius:7,display:"flex",alignItems:"center",gap:10}}>
|
||
<Avatar initials={(displayName||fromName||"?").slice(0,2).toUpperCase()} color={avatarColor} size={36}/>
|
||
<div>
|
||
<div style={{fontSize:13,fontWeight:600,color:C.text}}>{displayName||fromName}</div>
|
||
<div style={{fontSize:12,color:C.subtle}}>{detectedEmail}</div>
|
||
</div>
|
||
</div>}
|
||
</>,
|
||
nextLabel:"Continue",
|
||
},
|
||
{
|
||
icon:"ti-signature",
|
||
title:"Email signature",
|
||
subtitle:"Optional — appended to outgoing messages",
|
||
content:<>
|
||
<textarea value={signature} onChange={e=>setSig(e.target.value)}
|
||
placeholder={"— \nAlex Johnson\nExample Corp\nalex@example-corp.com"}
|
||
style={{width:"100%",boxSizing:"border-box",height:130,padding:"10px 12px",
|
||
border:`1px solid ${C.inBorder}`,borderRadius:6,fontSize:13,fontFamily:FONT,
|
||
resize:"none",outline:"none",color:C.text,background:C.inputBg}}/>
|
||
<p style={{margin:"8px 0 0",fontSize:12,color:C.subtle}}>You can change this later in Settings → Accounts.</p>
|
||
</>,
|
||
nextLabel:"Continue",
|
||
skipLabel:"Skip",
|
||
},
|
||
{
|
||
icon:"ti-circle-check",
|
||
title:"All set!",
|
||
subtitle:`${displayName||fromName||detectedEmail} is ready`,
|
||
content:<div style={{textAlign:"center"}}>
|
||
<div style={{width:64,height:64,borderRadius:"50%",background:C.accentBg,
|
||
border:`2px solid ${C.accentBorder}`,display:"flex",alignItems:"center",
|
||
justifyContent:"center",margin:"0 auto 16px"}}>
|
||
<i className="ti ti-check" style={{fontSize:32,color:C.accent}} aria-hidden="true"/>
|
||
</div>
|
||
<p style={{margin:"0 0 12px",fontSize:14,color:C.text,lineHeight:1.7}}>
|
||
Your mailbox has been configured and is ready to use.
|
||
</p>
|
||
<div style={{background:C.surface2,border:`1px solid ${C.border}`,borderRadius:8,padding:"12px 14px",textAlign:"left"}}>
|
||
{[["Account",detectedEmail],["Display name",displayName||fromName||"—"],["Signature",signature?signature.slice(0,40)+(signature.length>40?"…":""):"None set"]].map(([k,v])=>(
|
||
<div key={k} style={{display:"flex",gap:8,padding:"4px 0",fontSize:13}}>
|
||
<span style={{color:C.muted,width:100,flexShrink:0}}>{k}</span>
|
||
<span style={{color:C.text,overflow:"hidden",textOverflow:"ellipsis",whiteSpace:"nowrap"}}>{v}</span>
|
||
</div>
|
||
))}
|
||
</div>
|
||
</div>,
|
||
nextLabel:"Finish",
|
||
},
|
||
];
|
||
|
||
const curr=steps[step];
|
||
|
||
return <div style={{position:"fixed",inset:0,zIndex:300,background:"rgba(0,0,0,0.55)",
|
||
display:"flex",alignItems:"center",justifyContent:"center",fontFamily:FONT}}>
|
||
<div style={{width:480,background:C.surface,borderRadius:12,border:`1px solid ${C.border}`,
|
||
boxShadow:`0 16px 60px rgba(0,0,0,${C.surface==="#ffffff"?0.22:0.7})`,overflow:"hidden"}}>
|
||
|
||
{/* Progress bar */}
|
||
<div style={{height:3,background:C.border}}>
|
||
<div style={{height:"100%",background:C.accent,width:`${((step+1)/totalSteps)*100}%`,transition:"width 0.4s"}}/>
|
||
</div>
|
||
|
||
{/* Step dots */}
|
||
<div style={{display:"flex",justifyContent:"center",gap:7,padding:"16px 0 4px"}}>
|
||
{steps.map((_,i)=>(
|
||
<div key={i} style={{width:i===step?20:8,height:8,borderRadius:4,
|
||
background:i<=step?C.accent:C.border,transition:"all 0.3s"}}/>
|
||
))}
|
||
</div>
|
||
|
||
{/* Content */}
|
||
<div style={{padding:"16px 32px 24px"}}>
|
||
<div style={{textAlign:"center",marginBottom:20}}>
|
||
<div style={{width:52,height:52,borderRadius:12,background:C.accentBg,
|
||
border:`1.5px solid ${C.accentBorder}`,display:"flex",alignItems:"center",
|
||
justifyContent:"center",margin:"0 auto 12px"}}>
|
||
<i className={`ti ${curr.icon}`} style={{fontSize:26,color:C.accent}} aria-hidden="true"/>
|
||
</div>
|
||
<h2 style={{margin:"0 0 4px",fontSize:18,fontWeight:700,color:C.text}}>{curr.title}</h2>
|
||
<p style={{margin:0,fontSize:13,color:C.accent,fontWeight:600}}>{curr.subtitle}</p>
|
||
</div>
|
||
|
||
<div style={{marginBottom:22}}>{curr.content}</div>
|
||
|
||
<div style={{display:"flex",gap:8,justifyContent:"flex-end"}}>
|
||
{step>0&&<button onClick={()=>setStep(s=>s-1)} style={{background:"transparent",
|
||
border:`1px solid ${C.border}`,borderRadius:6,padding:"8px 16px",
|
||
fontSize:13.5,cursor:"pointer",fontFamily:FONT,color:C.muted}}>Back</button>}
|
||
{curr.skipLabel&&<button onClick={()=>setStep(s=>s+1)} style={{background:"transparent",
|
||
border:`1px solid ${C.border}`,borderRadius:6,padding:"8px 16px",
|
||
fontSize:13.5,cursor:"pointer",fontFamily:FONT,color:C.muted}}>{curr.skipLabel}</button>}
|
||
<button onClick={()=>step===totalSteps-1?onComplete({accountId,displayName:displayName||fromName,fromName,avatarColor,signature}):setStep(s=>s+1)}
|
||
style={{background:C.accent,color:"#fff",border:"none",borderRadius:6,
|
||
padding:"8px 20px",fontSize:13.5,cursor:"pointer",fontFamily:FONT,
|
||
fontWeight:600,display:"flex",alignItems:"center",gap:6}}>
|
||
{curr.nextLabel}
|
||
{step<totalSteps-1&&<i className="ti ti-arrow-right" style={{fontSize:14}} aria-hidden="true"/>}
|
||
</button>
|
||
</div>
|
||
</div>
|
||
</div>
|
||
</div>;
|
||
}
|
||
|
||
|
||
|
||
// ── Email Toolbar ─────────────────────────────────────────────────────────────
|
||
function EmailToolbar({email,folder,C,onReply,onReplyAll,onForward,onArchive,onDelete,onJunk,onStar,onUnread,onPrint}){
|
||
const[moreOpen,setMore]=useState(false);
|
||
const isSent=folder==="sent"||folder==="drafts"||folder==="out";
|
||
return <div style={{display:"flex",alignItems:"center",gap:2,padding:"6px 14px",
|
||
borderBottom:`1px solid ${C.border}`,background:C.surface,flexShrink:0,flexWrap:"wrap"}}>
|
||
{!isSent&&<>
|
||
<IBtn icon="ti-arrow-back-up" title="Reply" C={C} onClick={onReply} label="Reply"/>
|
||
<IBtn icon="ti-arrows-left" title="Reply All" C={C} onClick={onReplyAll} label="Reply All"/>
|
||
<Sep C={C}/>
|
||
</>}
|
||
<IBtn icon="ti-arrow-forward-up" title="Forward" C={C} onClick={onForward} label="Forward"/>
|
||
<Sep C={C}/>
|
||
{!isSent&&<IBtn icon="ti-archive" title="Archive" C={C} onClick={onArchive} label="Archive"/>}
|
||
<IBtn icon="ti-trash" title="Delete" C={C} onClick={onDelete} danger/>
|
||
<Sep C={C}/>
|
||
<IBtn icon="ti-star" title="Star" C={C} onClick={onStar} active={email?.starred}/>
|
||
<IBtn icon="ti-mail" title="Mark unread" C={C} onClick={onUnread}/>
|
||
<IBtn icon="ti-printer" title="Print" C={C} onClick={onPrint}/>
|
||
<div style={{position:"relative",marginLeft:"auto"}}>
|
||
<IBtn icon="ti-dots" title="More" C={C} onClick={()=>setMore(o=>!o)}/>
|
||
{moreOpen&&<div style={{position:"absolute",top:"100%",right:0,width:200,
|
||
background:C.surface,border:`1px solid ${C.border}`,borderRadius:8,zIndex:50,
|
||
boxShadow:`0 4px 20px rgba(0,0,0,${C.surface==="#ffffff"?0.15:0.4})`,padding:"4px 0"}}>
|
||
{[["ti-alert-triangle","Mark as junk",()=>{onJunk();setMore(false);}],
|
||
["ti-copy","Copy to folder",()=>setMore(false)],
|
||
["ti-code","View source",()=>setMore(false)],
|
||
["ti-user-plus","Add to contacts",()=>setMore(false)]].map(([ic,lbl,fn])=>(
|
||
<button key={lbl} onClick={fn} style={{display:"flex",alignItems:"center",gap:9,
|
||
width:"100%",padding:"9px 14px",background:"transparent",border:"none",
|
||
cursor:"pointer",fontFamily:FONT,fontSize:13,color:C.text,textAlign:"left"}}
|
||
onMouseEnter={e=>e.currentTarget.style.background=C.hover}
|
||
onMouseLeave={e=>e.currentTarget.style.background="transparent"}>
|
||
<i className={`ti ${ic}`} style={{fontSize:15,color:C.muted}} aria-hidden="true"/>{lbl}
|
||
</button>
|
||
))}
|
||
</div>}
|
||
</div>
|
||
</div>;
|
||
}
|
||
|
||
// ── Context Menu ──────────────────────────────────────────────────────────────
|
||
function AttachmentViewer({name,srcEmail,C,onDownload,onPrint,onClose}){
|
||
const ext=(name.split(".").pop()||"").toLowerCase();
|
||
const isImg=["jpg","jpeg","png","gif","svg","webp"].includes(ext);
|
||
const isEml=["eml","msg"].includes(ext);
|
||
const isPdf=ext==="pdf";
|
||
const isText=["txt","md","csv"].includes(ext);
|
||
|
||
useEffect(()=>{
|
||
function key(e){if(e.key==="Escape")onClose();}
|
||
document.addEventListener("keydown",key);
|
||
return()=>document.removeEventListener("keydown",key);
|
||
},[]);
|
||
|
||
function PreviewBody(){
|
||
if(isEml&&srcEmail){
|
||
return <div style={{fontFamily:FONT,fontSize:14,color:C.text}}>
|
||
<div style={{background:C.surface2,borderRadius:8,padding:"14px 18px",marginBottom:16,border:`1px solid ${C.border}`}}>
|
||
{[["From",`${srcEmail.from||""} <${srcEmail.from_email||""}>`],
|
||
["To",srcEmail.to||""],["Subject",srcEmail.subject||""],["Date",srcEmail.date||""]
|
||
].map(([lbl,val])=>(
|
||
<div key={lbl} style={{display:"flex",gap:8,marginBottom:8,alignItems:"flex-start"}}>
|
||
<span style={{fontSize:11.5,fontWeight:700,color:C.subtle,textTransform:"uppercase",letterSpacing:"0.06em",minWidth:58,paddingTop:1}}>{lbl}</span>
|
||
<span style={{flex:1,color:C.text,wordBreak:"break-word"}}>{val}</span>
|
||
</div>
|
||
))}
|
||
</div>
|
||
<pre style={{fontFamily:FONT,fontSize:13.5,color:C.text,lineHeight:1.7,whiteSpace:"pre-wrap",margin:0}}>{srcEmail.body||"(empty body)"}</pre>
|
||
</div>;
|
||
}
|
||
if(isImg){
|
||
return <div style={{display:"flex",flexDirection:"column",alignItems:"center",gap:16,padding:"20px 0"}}>
|
||
<div style={{width:240,height:160,borderRadius:10,background:`linear-gradient(135deg,${C.surface2} 0%,${C.hover} 100%)`,border:`1px solid ${C.border}`,display:"flex",flexDirection:"column",alignItems:"center",justifyContent:"center",gap:8}}>
|
||
<i className="ti ti-photo" style={{fontSize:44,color:C.accent}} aria-hidden="true"/>
|
||
<span style={{fontSize:12,color:C.muted}}>Image preview</span>
|
||
</div>
|
||
<span style={{fontSize:12,color:C.subtle}}>In a live deployment this image would be fetched from the SMB share.</span>
|
||
</div>;
|
||
}
|
||
if(isPdf){
|
||
return <div style={{display:"flex",flexDirection:"column",alignItems:"center",gap:16,padding:"20px 0"}}>
|
||
<div style={{width:160,height:200,borderRadius:8,background:C.surface2,border:`1px solid ${C.border}`,display:"flex",flexDirection:"column",alignItems:"center",justifyContent:"center",gap:10,boxShadow:`0 4px 18px rgba(0,0,0,0.12)`}}>
|
||
<i className="ti ti-file-type-pdf" style={{fontSize:52,color:"#e05252"}} aria-hidden="true"/>
|
||
<span style={{fontSize:12,color:C.muted,fontWeight:600}}>{name}</span>
|
||
</div>
|
||
<span style={{fontSize:12,color:C.subtle}}>PDF viewer requires live SMB connection.</span>
|
||
</div>;
|
||
}
|
||
if(isText){
|
||
const lines=srcEmail?.body?srcEmail.body.split("\n").slice(0,12).join("\n")+"…":"(preview not available)";
|
||
return <pre style={{fontFamily:"'Cascadia Code','Consolas',monospace",fontSize:13,color:C.text,lineHeight:1.7,whiteSpace:"pre-wrap",background:C.surface2,borderRadius:8,padding:"14px 16px",border:`1px solid ${C.border}`,margin:0}}>{lines}</pre>;
|
||
}
|
||
// generic
|
||
return <div style={{display:"flex",flexDirection:"column",alignItems:"center",gap:14,padding:"32px 0"}}>
|
||
<i className={`ti ${getAttIcon(name)}`} style={{fontSize:56,color:C.accent}} aria-hidden="true"/>
|
||
<div style={{textAlign:"center"}}>
|
||
<div style={{fontSize:15,fontWeight:600,color:C.text,marginBottom:6}}>{name}</div>
|
||
<div style={{fontSize:13,color:C.muted}}>No preview available for .{ext} files.</div>
|
||
<div style={{fontSize:12,color:C.subtle,marginTop:4}}>Download or print to access this file.</div>
|
||
</div>
|
||
</div>;
|
||
}
|
||
|
||
return <div style={{position:"fixed",inset:0,zIndex:10000,background:"rgba(0,0,0,0.55)",display:"flex",alignItems:"center",justifyContent:"center",fontFamily:FONT}}
|
||
onMouseDown={onClose}>
|
||
<div style={{width:"min(720px,92vw)",maxHeight:"85vh",background:C.surface,borderRadius:12,border:`1px solid ${C.border}`,
|
||
boxShadow:"0 24px 64px rgba(0,0,0,0.35)",display:"flex",flexDirection:"column",overflow:"hidden"}}
|
||
onMouseDown={e=>e.stopPropagation()}>
|
||
{/* Header */}
|
||
<div style={{display:"flex",alignItems:"center",gap:10,padding:"13px 16px",borderBottom:`1px solid ${C.border}`,flexShrink:0}}>
|
||
<i className={`ti ${getAttIcon(name)}`} style={{fontSize:18,color:C.accent,flexShrink:0}} aria-hidden="true"/>
|
||
<span style={{flex:1,fontSize:14,fontWeight:600,color:C.text,overflow:"hidden",textOverflow:"ellipsis",whiteSpace:"nowrap"}}>{name}</span>
|
||
<div style={{display:"flex",gap:4,flexShrink:0}}>
|
||
<button onClick={onDownload} title="Download"
|
||
style={{display:"flex",alignItems:"center",gap:5,padding:"5px 11px",borderRadius:6,border:`1px solid ${C.border}`,background:C.surface2,color:C.text,cursor:"pointer",fontFamily:FONT,fontSize:12.5}}>
|
||
<i className="ti ti-download" style={{fontSize:13}} aria-hidden="true"/>Download
|
||
</button>
|
||
<button onClick={onPrint} title="Print"
|
||
style={{display:"flex",alignItems:"center",gap:5,padding:"5px 11px",borderRadius:6,border:`1px solid ${C.border}`,background:C.surface2,color:C.text,cursor:"pointer",fontFamily:FONT,fontSize:12.5}}>
|
||
<i className="ti ti-printer" style={{fontSize:13}} aria-hidden="true"/>Print
|
||
</button>
|
||
<button onClick={onClose} title="Close"
|
||
style={{width:28,height:28,borderRadius:6,border:"none",background:"transparent",color:C.muted,cursor:"pointer",display:"flex",alignItems:"center",justifyContent:"center"}}>
|
||
<i className="ti ti-x" style={{fontSize:16}} aria-hidden="true"/>
|
||
</button>
|
||
</div>
|
||
</div>
|
||
{/* Body */}
|
||
<div style={{flex:1,overflowY:"auto",padding:"18px 20px"}}>
|
||
<PreviewBody/>
|
||
</div>
|
||
</div>
|
||
</div>;
|
||
}
|
||
|
||
function AttachmentContextMenu({x,y,name,srcEmail,C,onClose,onView}){
|
||
const ref=useRef(null);
|
||
useEffect(()=>{
|
||
function down(e){if(ref.current&&!ref.current.contains(e.target))onClose();}
|
||
function key(e){if(e.key==="Escape")onClose();}
|
||
document.addEventListener("mousedown",down);
|
||
document.addEventListener("keydown",key);
|
||
return()=>{document.removeEventListener("mousedown",down);document.removeEventListener("keydown",key);};
|
||
},[]);
|
||
const W=180,H=140;
|
||
const cx=x+W>window.innerWidth?x-W:x;
|
||
const cy=y+H>window.innerHeight?y-H:y;
|
||
function item(icon,label,action){
|
||
return <div onMouseDown={e=>{e.stopPropagation();action();onClose();}}
|
||
style={{display:"flex",alignItems:"center",gap:9,padding:"7px 14px",cursor:"pointer",fontSize:13,color:C.text}}
|
||
onMouseEnter={e=>e.currentTarget.style.background=C.hover}
|
||
onMouseLeave={e=>e.currentTarget.style.background="transparent"}>
|
||
<i className={`ti ${icon}`} style={{fontSize:14,width:16,textAlign:"center",flexShrink:0}} aria-hidden="true"/>
|
||
{label}
|
||
</div>;
|
||
}
|
||
return <div ref={ref} onMouseDown={e=>e.stopPropagation()}
|
||
style={{position:"fixed",left:cx,top:cy,zIndex:9999,
|
||
background:C.surface,border:`1px solid ${C.border}`,borderRadius:8,
|
||
boxShadow:"0 8px 32px rgba(0,0,0,0.22)",padding:"4px 0",minWidth:W,pointerEvents:"all"}}>
|
||
<div style={{padding:"5px 14px 6px",borderBottom:`1px solid ${C.border}`,marginBottom:2}}>
|
||
<div style={{display:"flex",alignItems:"center",gap:7}}>
|
||
<i className={`ti ${getAttIcon(name)}`} style={{fontSize:13,color:C.accent,flexShrink:0}} aria-hidden="true"/>
|
||
<span style={{fontSize:12,color:C.muted,overflow:"hidden",textOverflow:"ellipsis",whiteSpace:"nowrap",maxWidth:140}}>{name}</span>
|
||
</div>
|
||
</div>
|
||
{item("ti-eye","View",()=>onView&&onView())}
|
||
{item("ti-download","Download",()=>downloadAttachment(name,srcEmail))}
|
||
{item("ti-printer","Print",()=>printAttachment(name,srcEmail))}
|
||
</div>;
|
||
}
|
||
|
||
function ContextMenu({x,y,email,C,onClose,onOpen,onReply,onReplyAll,onForward,onForwardAsAtt,onToggleRead,onToggleStar,onArchive,onDelete,onPrint,moveFolders,onMove}){
|
||
const ref=useRef(null);
|
||
const[moveOpen,setMoveOpen]=useState(false);
|
||
useEffect(()=>{
|
||
function down(e){if(ref.current&&!ref.current.contains(e.target))onClose();}
|
||
function key(e){if(e.key==="Escape")onClose();}
|
||
document.addEventListener("mousedown",down);
|
||
document.addEventListener("keydown",key);
|
||
return()=>{document.removeEventListener("mousedown",down);document.removeEventListener("keydown",key);};
|
||
},[]);
|
||
const W=200,H=360;
|
||
const cx=x+W>window.innerWidth?x-W:x;
|
||
const cy=y+H>window.innerHeight?y-H:y;
|
||
// Submenu placement: to the right of main menu unless that overflows
|
||
const subW=210;
|
||
const subOnLeft=cx+W+subW>window.innerWidth;
|
||
function item(icon,label,action,danger){
|
||
return <div onMouseDown={e=>{e.stopPropagation();action();onClose();}}
|
||
style={{display:"flex",alignItems:"center",gap:9,padding:"7px 14px",cursor:"pointer",fontSize:13,color:danger?"#e05252":C.text}}
|
||
onMouseEnter={e=>e.currentTarget.style.background=C.hover}
|
||
onMouseLeave={e=>e.currentTarget.style.background="transparent"}>
|
||
<i className={`ti ${icon}`} style={{fontSize:14,width:16,textAlign:"center",flexShrink:0}} aria-hidden="true"/>
|
||
{label}
|
||
</div>;
|
||
}
|
||
function sep(){return <div style={{height:1,background:C.border,margin:"3px 0"}}/>;}
|
||
return <div ref={ref} onMouseDown={e=>e.stopPropagation()}
|
||
style={{position:"fixed",left:cx,top:cy,zIndex:9999,
|
||
background:C.surface,border:`1px solid ${C.border}`,borderRadius:8,
|
||
boxShadow:"0 8px 32px rgba(0,0,0,0.22)",padding:"4px 0",minWidth:W,pointerEvents:"all"}}>
|
||
{item("ti-mail-opened","Open",onOpen)}
|
||
{sep()}
|
||
{item("ti-arrow-back-up","Reply",onReply)}
|
||
{item("ti-arrows-left","Reply All",onReplyAll)}
|
||
{item("ti-arrow-forward-up","Forward",onForward)}
|
||
{item("ti-mail-forward","Forward as attachment",onForwardAsAtt)}
|
||
{sep()}
|
||
{item(email.read?"ti-mail":"ti-mail-opened",email.read?"Mark as unread":"Mark as read",onToggleRead)}
|
||
{item(email.starred?"ti-star":"ti-star-filled",email.starred?"Unstar":"Star",onToggleStar)}
|
||
{sep()}
|
||
{/* Move to folder */}
|
||
<div onMouseDown={e=>{e.stopPropagation();setMoveOpen(v=>!v);}}
|
||
style={{display:"flex",alignItems:"center",gap:9,padding:"7px 14px",cursor:"pointer",fontSize:13,color:C.text,background:moveOpen?C.hover:"transparent"}}
|
||
onMouseEnter={e=>e.currentTarget.style.background=C.hover}
|
||
onMouseLeave={e=>e.currentTarget.style.background=moveOpen?C.hover:"transparent"}>
|
||
<i className="ti ti-folder-symlink" style={{fontSize:14,width:16,textAlign:"center",flexShrink:0}} aria-hidden="true"/>
|
||
<span style={{flex:1}}>Move to folder</span>
|
||
<i className="ti ti-chevron-right" style={{fontSize:13,color:C.subtle}} aria-hidden="true"/>
|
||
</div>
|
||
{moveOpen&&<div style={{position:"absolute",top:0,
|
||
[subOnLeft?"right":"left"]:"100%",
|
||
marginLeft:subOnLeft?0:2,marginRight:subOnLeft?2:0,
|
||
background:C.surface,border:`1px solid ${C.border}`,borderRadius:8,
|
||
boxShadow:"0 8px 32px rgba(0,0,0,0.22)",padding:"4px 0",minWidth:subW,
|
||
maxHeight:340,overflowY:"auto"}}>
|
||
{(moveFolders||[]).length===0
|
||
?<div style={{padding:"7px 14px",fontSize:12.5,color:C.subtle,fontStyle:"italic"}}>No other folders</div>
|
||
:(moveFolders||[]).map(f=>(
|
||
<div key={f.key} onMouseDown={e=>{e.stopPropagation();onMove&&onMove(f.key);onClose();}}
|
||
style={{display:"flex",alignItems:"center",gap:9,padding:"7px 14px",cursor:"pointer",fontSize:13,color:C.text}}
|
||
onMouseEnter={e=>e.currentTarget.style.background=C.hover}
|
||
onMouseLeave={e=>e.currentTarget.style.background="transparent"}>
|
||
<i className={`ti ${f.icon||"ti-folder"}`} style={{fontSize:14,width:16,textAlign:"center",flexShrink:0,color:f.custom?C.accent:C.muted}} aria-hidden="true"/>
|
||
<span style={{flex:1}}>{f.label}</span>
|
||
{f.custom&&<span style={{fontSize:10,color:C.subtle,letterSpacing:0.4,textTransform:"uppercase"}}>Custom</span>}
|
||
</div>
|
||
))}
|
||
</div>}
|
||
{sep()}
|
||
{item("ti-printer","Print",onPrint)}
|
||
{sep()}
|
||
{item("ti-archive","Archive",onArchive)}
|
||
{item("ti-trash","Move to Trash",onDelete,true)}
|
||
</div>;
|
||
}
|
||
|
||
// ── Email Row ─────────────────────────────────────────────────────────────────
|
||
function EmailRow({email,selected,C,onClick,onContextMenu}){
|
||
const[h,setH]=useState(false);
|
||
return <div onClick={onClick} onContextMenu={e=>{e.preventDefault();onContextMenu&&onContextMenu(e,email);}} onMouseEnter={()=>setH(true)} onMouseLeave={()=>setH(false)}
|
||
style={{display:"flex",gap:10,padding:"10px 12px",
|
||
background:selected?C.selectedBg:h?C.hover:"transparent",
|
||
borderLeft:`3px solid ${selected?C.accent:"transparent"}`,
|
||
cursor:"pointer",borderBottom:`1px solid ${C.border}`,transition:"background 0.1s"}}>
|
||
<div style={{paddingTop:2,flexShrink:0}}><Avatar initials={email.av} color={email.avColor} size={34}/></div>
|
||
<div style={{flex:1,minWidth:0}}>
|
||
<div style={{display:"flex",justifyContent:"space-between",marginBottom:2}}>
|
||
<span style={{fontSize:13.5,fontWeight:email.read?400:700,color:C.text,overflow:"hidden",textOverflow:"ellipsis",whiteSpace:"nowrap",maxWidth:160}}>{email.from}</span>
|
||
<span style={{fontSize:11.5,color:C.subtle,flexShrink:0}}>{email.date}</span>
|
||
</div>
|
||
<div style={{fontSize:13,color:email.read?C.muted:C.text,fontWeight:email.read?400:600,overflow:"hidden",textOverflow:"ellipsis",whiteSpace:"nowrap",marginBottom:2}}>{email.subject}</div>
|
||
<div style={{fontSize:12,color:C.subtle,overflow:"hidden",textOverflow:"ellipsis",whiteSpace:"nowrap"}}>{email.preview}</div>
|
||
</div>
|
||
<div style={{display:"flex",flexDirection:"column",alignItems:"center",gap:4,paddingTop:2,flexShrink:0}}>
|
||
{!email.read&&<div style={{width:8,height:8,borderRadius:"50%",background:C.accent}}/>}
|
||
{email.starred&&<i className="ti ti-star-filled" style={{fontSize:12,color:"#f7c600"}} aria-hidden="true"/>}
|
||
</div>
|
||
</div>;
|
||
}
|
||
|
||
// ── First-run Setup Wizard ────────────────────────────────────────────────────
|
||
const MOCK_DISCOVERED=[
|
||
{email:"alex@example-corp.com", count:1842, size:"312 MB", checked:true},
|
||
{email:"sales@example-corp.com", count:234, size:"41 MB", checked:true},
|
||
{email:"j.doe@mailbox.org", count:89, size:"14 MB", checked:false},
|
||
{email:"default", count:12, size:"2 MB", checked:false},
|
||
];
|
||
|
||
function SetupWizard({onComplete,mode="setup",prefillSmb=null}){
|
||
const isDiscover=mode==="discover";
|
||
const[step,setStep]=useState(isDiscover?2:1); // 1 welcome, 2 smb, 3 scanning, 4 accounts, 5 profile, 6 import, 7 done
|
||
const TOTAL=5; // visible progress steps
|
||
|
||
// SMB fields — pre-fill from existing config in discover mode
|
||
const[host,setHost]=useState(prefillSmb?.host||"");
|
||
const[share,setShare]=useState(prefillSmb?.share||"");
|
||
const[subfolder,setSubfolder]=useState(prefillSmb?.subfolder||"");
|
||
const[smbu,setSmbu]=useState(prefillSmb?.username||"");
|
||
const[smbp,setSmbp]=useState(prefillSmb?.password||"");
|
||
// If we have a pre-filled host, treat connection as already verified
|
||
const[connStatus,setConnStatus]=useState(prefillSmb?.host?.trim()?"ok":null);
|
||
const[connErr,setConnErr]=useState("");
|
||
|
||
// Discovery
|
||
const[scanning,setScanning]=useState(false);
|
||
const[discovered,setDiscovered]=useState([]);
|
||
|
||
// Account selection
|
||
const[accounts,setAccounts]=useState([]);
|
||
|
||
// Profile
|
||
const[profName,setProfName]=useState("");
|
||
const[profPw,setProfPw]=useState("");
|
||
const[profPwConfirm,setProfPwConfirm]=useState("");
|
||
const[profErr,setProfErr]=useState("");
|
||
|
||
// Import
|
||
const[importProgress,setImportProgress]=useState(0);
|
||
const[importCurrent,setImportCurrent]=useState("");
|
||
const[importDone,setImportDone]=useState(false);
|
||
|
||
function testConn(){
|
||
setConnStatus("testing");setConnErr("");
|
||
setTimeout(()=>{
|
||
if(host.trim()&&share.trim()){setConnStatus("ok");}
|
||
else{setConnStatus("err");setConnErr("Host and share name are required.");}
|
||
},1400);
|
||
}
|
||
|
||
function startScan(){
|
||
setStep(3);setScanning(true);setDiscovered([]);
|
||
setTimeout(()=>{
|
||
setScanning(false);
|
||
setDiscovered(MOCK_DISCOVERED);
|
||
setAccounts(MOCK_DISCOVERED.map(d=>({...d})));
|
||
setStep(4);
|
||
},2200);
|
||
}
|
||
|
||
function toggleAccount(i){
|
||
setAccounts(prev=>prev.map((a,idx)=>idx===i?{...a,checked:!a.checked}:a));
|
||
}
|
||
|
||
function validateProfile(){
|
||
if(!profName.trim()){setProfErr("Display name is required.");return false;}
|
||
if(!profPw){setProfErr("Password is required.");return false;}
|
||
if(profPw!==profPwConfirm){setProfErr("Passwords don't match.");return false;}
|
||
setProfErr("");return true;
|
||
}
|
||
|
||
function startImport(){
|
||
setStep(6);
|
||
const selected=accounts.filter(a=>a.checked);
|
||
let idx=0;
|
||
let done=0;
|
||
const total=selected.reduce((s,a)=>s+a.count,0);
|
||
function tick(){
|
||
if(idx>=selected.length){setImportProgress(100);setImportDone(true);setStep(7);return;}
|
||
const acct=selected[idx];
|
||
setImportCurrent(acct.email);
|
||
let acctDone=0;
|
||
const acctInterval=setInterval(()=>{
|
||
acctDone+=Math.floor(acct.count/8)+1;
|
||
done+=Math.floor(acct.count/8)+1;
|
||
if(acctDone>=acct.count){
|
||
done=done-(acctDone-acct.count);
|
||
clearInterval(acctInterval);
|
||
idx++;tick();
|
||
}
|
||
setImportProgress(Math.min(99,Math.round(done/total*100)));
|
||
},120);
|
||
}
|
||
tick();
|
||
}
|
||
|
||
const C=mkTheme(true,"#0099b8"); // wizard always dark
|
||
const inp={width:"100%",boxSizing:"border-box",padding:"9px 12px",
|
||
border:`1px solid rgba(255,255,255,0.12)`,borderRadius:7,fontSize:13.5,
|
||
fontFamily:FONT,outline:"none",color:"#f0f0f0",background:"rgba(255,255,255,0.07)",
|
||
marginBottom:12};
|
||
const label={display:"block",fontSize:11.5,fontWeight:600,color:"rgba(255,255,255,0.45)",
|
||
marginBottom:5,letterSpacing:"0.07em",textTransform:"uppercase"};
|
||
const stepNames=["Connect","Accounts","Profile","Import","Done"];
|
||
const stepMap={2:0,3:0,4:1,5:2,6:3,7:4};
|
||
const activeStep=stepMap[step]??-1;
|
||
|
||
return <div style={{minHeight:"100vh",background:"#0c0c0c",display:"flex",flexDirection:"column",
|
||
alignItems:"center",justifyContent:"center",fontFamily:FONT,padding:"32px 16px"}}>
|
||
|
||
{/* Logo */}
|
||
<div style={{display:"flex",alignItems:"center",gap:10,marginBottom:32}}>
|
||
<DashMailLogo size={36}/>
|
||
<span style={{fontSize:22,fontWeight:900,color:"#fff",letterSpacing:"-0.5px"}}>Dash<span style={{fontWeight:200,color:"#00c4d8"}}>Mail</span></span>
|
||
</div>
|
||
|
||
{/* Progress bar — visible from step 2+ */}
|
||
{step>=2&&step<=7&&<div style={{display:"flex",alignItems:"center",gap:0,marginBottom:32,width:"min(460px,90vw)"}}>
|
||
{stepNames.map((name,i)=>{
|
||
const done=i<activeStep;
|
||
const active=i===activeStep;
|
||
return <React.Fragment key={i}>
|
||
<div style={{display:"flex",flexDirection:"column",alignItems:"center",gap:5}}>
|
||
<div style={{width:28,height:28,borderRadius:"50%",
|
||
background:done?"#00c4d8":active?"#00c4d8":"rgba(255,255,255,0.1)",
|
||
border:`2px solid ${done||active?"#00c4d8":"rgba(255,255,255,0.15)"}`,
|
||
display:"flex",alignItems:"center",justifyContent:"center",transition:"all 0.3s"}}>
|
||
{done
|
||
?<i className="ti ti-check" style={{fontSize:13,color:"#0c0c0c"}} aria-hidden="true"/>
|
||
:<span style={{fontSize:11,fontWeight:700,color:active?"#0c0c0c":"rgba(255,255,255,0.4)"}}>{i+1}</span>}
|
||
</div>
|
||
<span style={{fontSize:10,color:active||done?"#00c4d8":"rgba(255,255,255,0.3)",fontWeight:active?700:400,whiteSpace:"nowrap"}}>{name}</span>
|
||
</div>
|
||
{i<stepNames.length-1&&<div style={{flex:1,height:2,background:done?"#00c4d8":"rgba(255,255,255,0.1)",margin:"0 4px 18px",transition:"background 0.3s"}}/>}
|
||
</React.Fragment>;
|
||
})}
|
||
</div>}
|
||
|
||
{/* Card */}
|
||
<div style={{width:"min(460px,90vw)",background:"#181818",borderRadius:14,
|
||
border:"1px solid rgba(255,255,255,0.08)",padding:"32px",
|
||
boxShadow:"0 24px 60px rgba(0,0,0,0.6)"}}>
|
||
|
||
{/* ── Step 1: Welcome ── */}
|
||
{step===1&&<>
|
||
<div style={{textAlign:"center",marginBottom:28}}>
|
||
<div style={{fontSize:22,fontWeight:700,color:"#fff",marginBottom:8}}>Welcome to DashMail</div>
|
||
<div style={{fontSize:13.5,color:"rgba(255,255,255,0.45)",lineHeight:1.7}}>
|
||
Let's get you connected to your mail server.<br/>
|
||
This takes about 2 minutes.
|
||
</div>
|
||
</div>
|
||
<div style={{display:"flex",flexDirection:"column",gap:10,marginBottom:24}}>
|
||
{[
|
||
{icon:"ti-server", text:"Connect to your SMB mail server"},
|
||
{icon:"ti-mail-search", text:"Auto-discover your email accounts"},
|
||
{icon:"ti-database", text:"Import existing emails locally"},
|
||
{icon:"ti-shield-lock", text:"Set up your profile and password"},
|
||
].map((r,i)=>(
|
||
<div key={i} style={{display:"flex",alignItems:"center",gap:12,padding:"10px 14px",
|
||
background:"rgba(255,255,255,0.04)",borderRadius:8,border:"1px solid rgba(255,255,255,0.06)"}}>
|
||
<i className={`ti ${r.icon}`} style={{fontSize:18,color:"#00c4d8",flexShrink:0}} aria-hidden="true"/>
|
||
<span style={{fontSize:13,color:"rgba(255,255,255,0.7)"}}>{r.text}</span>
|
||
</div>
|
||
))}
|
||
</div>
|
||
<button onClick={()=>setStep(2)}
|
||
style={{width:"100%",padding:"13px",border:"none",borderRadius:9,background:"#00c4d8",
|
||
color:"#0c0c0c",fontSize:15,fontWeight:700,cursor:"pointer",fontFamily:FONT,
|
||
display:"flex",alignItems:"center",justifyContent:"center",gap:8}}>
|
||
Get started <i className="ti ti-arrow-right" style={{fontSize:15}} aria-hidden="true"/>
|
||
</button>
|
||
</>}
|
||
|
||
{/* ── Step 2: SMB connection ── */}
|
||
{step===2&&<>
|
||
<div style={{fontSize:18,fontWeight:700,color:"#fff",marginBottom:6}}>
|
||
{isDiscover?"Verify mail server connection":"Connect to mail server"}
|
||
</div>
|
||
<div style={{fontSize:13,color:"rgba(255,255,255,0.4)",marginBottom:22,lineHeight:1.6}}>
|
||
{isDiscover
|
||
?"Confirm your SMB share details then scan for new account folders."
|
||
:"Enter your SMB share details. DashMail will scan for email account folders automatically."}
|
||
</div>
|
||
<label style={label}>Server host</label>
|
||
<input value={host} onChange={e=>setHost(e.target.value)} placeholder="192.168.1.100 or mailserver.local" style={inp}/>
|
||
<div style={{display:"flex",gap:10}}>
|
||
<div style={{flex:2}}>
|
||
<label style={label}>Share name</label>
|
||
<input value={share} onChange={e=>setShare(e.target.value)} placeholder="mail" style={inp}/>
|
||
</div>
|
||
<div style={{flex:1}}>
|
||
<label style={label}>Subfolder</label>
|
||
<input value={subfolder} onChange={e=>setSubfolder(e.target.value)} placeholder=".Mails" style={inp}/>
|
||
</div>
|
||
</div>
|
||
<div style={{display:"flex",gap:10}}>
|
||
<div style={{flex:1}}>
|
||
<label style={label}>Username</label>
|
||
<input value={smbu} onChange={e=>setSmbu(e.target.value)} placeholder="domain\\user" style={inp} autoComplete="off"/>
|
||
</div>
|
||
<div style={{flex:1}}>
|
||
<label style={label}>Password</label>
|
||
<input type="password" value={smbp} onChange={e=>setSmbp(e.target.value)} style={inp} autoComplete="new-password"/>
|
||
</div>
|
||
</div>
|
||
<div style={{display:"flex",alignItems:"center",gap:10,marginBottom:20}}>
|
||
<button onClick={testConn} disabled={connStatus==="testing"}
|
||
style={{background:"transparent",border:"1px solid rgba(0,196,216,0.5)",borderRadius:6,
|
||
padding:"7px 16px",fontSize:13,cursor:"pointer",fontFamily:FONT,color:"#00c4d8",
|
||
display:"flex",alignItems:"center",gap:6,opacity:connStatus==="testing"?0.6:1}}>
|
||
{connStatus==="testing"
|
||
?<><i className="ti ti-loader-2 ti-spin" style={{fontSize:13}} aria-hidden="true"/>Testing…</>
|
||
:<><i className="ti ti-plug" style={{fontSize:13}} aria-hidden="true"/>Test connection</>}
|
||
</button>
|
||
{connStatus==="ok"&&<span style={{fontSize:13,color:"#22c55e",display:"flex",alignItems:"center",gap:5,fontWeight:600}}>
|
||
<i className="ti ti-circle-check" style={{fontSize:14}} aria-hidden="true"/>Connected
|
||
</span>}
|
||
{connStatus==="err"&&<span style={{fontSize:13,color:"#f87171"}}>{connErr}</span>}
|
||
</div>
|
||
<div style={{display:"flex",gap:8}}>
|
||
<button onClick={()=>setStep(1)}
|
||
style={{flex:1,padding:"11px",border:"1px solid rgba(255,255,255,0.1)",borderRadius:9,
|
||
background:"transparent",color:"rgba(255,255,255,0.45)",fontSize:14,cursor:"pointer",fontFamily:FONT}}>
|
||
Back
|
||
</button>
|
||
<button onClick={startScan} disabled={connStatus!=="ok"}
|
||
style={{flex:2,padding:"11px",border:"none",borderRadius:9,
|
||
background:connStatus==="ok"?"#00c4d8":"rgba(0,196,216,0.2)",
|
||
color:connStatus==="ok"?"#0c0c0c":"rgba(255,255,255,0.2)",
|
||
fontSize:14,fontWeight:700,cursor:connStatus==="ok"?"pointer":"default",fontFamily:FONT,
|
||
display:"flex",alignItems:"center",justifyContent:"center",gap:8}}>
|
||
Scan for accounts <i className="ti ti-scan" style={{fontSize:15}} aria-hidden="true"/>
|
||
</button>
|
||
</div>
|
||
</>}
|
||
|
||
{/* ── Step 3: Scanning ── */}
|
||
{step===3&&<div style={{textAlign:"center",padding:"20px 0"}}>
|
||
<i className="ti ti-loader-2 ti-spin" style={{fontSize:40,color:"#00c4d8",display:"block",marginBottom:16}} aria-hidden="true"/>
|
||
<div style={{fontSize:16,fontWeight:700,color:"#fff",marginBottom:8}}>Scanning mail server…</div>
|
||
<div style={{fontSize:13,color:"rgba(255,255,255,0.4)"}}>
|
||
Searching <code style={{color:"#00c4d8",fontSize:12}}>\\{host||"mailserver"}\\{share||"mail"}\\{subfolder||".Mails"}</code> for account folders
|
||
</div>
|
||
</div>}
|
||
|
||
{/* ── Step 4: Select accounts ── */}
|
||
{step===4&&<>
|
||
<div style={{fontSize:18,fontWeight:700,color:"#fff",marginBottom:6}}>Accounts found</div>
|
||
<div style={{fontSize:13,color:"rgba(255,255,255,0.4)",marginBottom:20,lineHeight:1.6}}>
|
||
DashMail found <strong style={{color:"#00c4d8"}}>{discovered.length} account folders</strong> on the share.
|
||
Select which ones to import. Your emails on the server are never modified.
|
||
</div>
|
||
<div style={{display:"flex",flexDirection:"column",gap:8,marginBottom:20}}>
|
||
{accounts.map((a,i)=>(
|
||
<div key={i} onClick={()=>toggleAccount(i)}
|
||
style={{display:"flex",alignItems:"center",gap:12,padding:"12px 14px",
|
||
background:a.checked?"rgba(0,196,216,0.08)":"rgba(255,255,255,0.04)",
|
||
border:`1px solid ${a.checked?"rgba(0,196,216,0.3)":"rgba(255,255,255,0.06)"}`,
|
||
borderRadius:8,cursor:"pointer",transition:"all 0.15s"}}>
|
||
<div style={{width:18,height:18,borderRadius:4,flexShrink:0,
|
||
background:a.checked?"#00c4d8":"transparent",
|
||
border:`2px solid ${a.checked?"#00c4d8":"rgba(255,255,255,0.25)"}`,
|
||
display:"flex",alignItems:"center",justifyContent:"center",transition:"all 0.15s"}}>
|
||
{a.checked&&<i className="ti ti-check" style={{fontSize:11,color:"#0c0c0c"}} aria-hidden="true"/>}
|
||
</div>
|
||
<i className="ti ti-mail" style={{fontSize:16,color:a.checked?"#00c4d8":"rgba(255,255,255,0.3)",flexShrink:0}} aria-hidden="true"/>
|
||
<div style={{flex:1,minWidth:0}}>
|
||
<div style={{fontSize:13.5,color:a.checked?"#fff":"rgba(255,255,255,0.5)",fontWeight:a.checked?500:400,
|
||
overflow:"hidden",textOverflow:"ellipsis",whiteSpace:"nowrap"}}>{a.email}</div>
|
||
<div style={{fontSize:11.5,color:"rgba(255,255,255,0.3)",marginTop:2}}>
|
||
{a.count.toLocaleString()} emails · {a.size}
|
||
</div>
|
||
</div>
|
||
</div>
|
||
))}
|
||
</div>
|
||
<div style={{fontSize:12,color:"rgba(255,255,255,0.25)",marginBottom:20,display:"flex",alignItems:"center",gap:6}}>
|
||
<i className="ti ti-shield-check" style={{fontSize:13,color:"rgba(0,196,216,0.5)"}} aria-hidden="true"/>
|
||
All emails are copied to a local database. The SMB share is read-only — nothing is deleted or moved.
|
||
</div>
|
||
<div style={{display:"flex",gap:8}}>
|
||
<button onClick={()=>setStep(2)}
|
||
style={{flex:1,padding:"11px",border:"1px solid rgba(255,255,255,0.1)",borderRadius:9,
|
||
background:"transparent",color:"rgba(255,255,255,0.45)",fontSize:14,cursor:"pointer",fontFamily:FONT}}>
|
||
Back
|
||
</button>
|
||
<button onClick={()=>setStep(5)} disabled={!accounts.some(a=>a.checked)}
|
||
style={{flex:2,padding:"11px",border:"none",borderRadius:9,
|
||
background:accounts.some(a=>a.checked)?"#00c4d8":"rgba(0,196,216,0.2)",
|
||
color:accounts.some(a=>a.checked)?"#0c0c0c":"rgba(255,255,255,0.2)",
|
||
fontSize:14,fontWeight:700,cursor:accounts.some(a=>a.checked)?"pointer":"default",
|
||
fontFamily:FONT,display:"flex",alignItems:"center",justifyContent:"center",gap:8}}>
|
||
Continue <i className="ti ti-arrow-right" style={{fontSize:15}} aria-hidden="true"/>
|
||
</button>
|
||
</div>
|
||
</>}
|
||
|
||
{/* ── Step 5: Profile setup ── */}
|
||
{step===5&&<>
|
||
<div style={{fontSize:18,fontWeight:700,color:"#fff",marginBottom:6}}>Create your profile</div>
|
||
<div style={{fontSize:13,color:"rgba(255,255,255,0.4)",marginBottom:22,lineHeight:1.6}}>
|
||
Your profile password protects access to DashMail on this device. It is stored locally only.
|
||
</div>
|
||
<label style={label}>Display name</label>
|
||
<input value={profName} onChange={e=>{setProfName(e.target.value);setProfErr("");}} placeholder="e.g. Alex Johnson" style={inp}/>
|
||
<label style={label}>Password</label>
|
||
<input type="password" value={profPw} onChange={e=>{setProfPw(e.target.value);setProfErr("");}} style={inp} autoComplete="new-password"/>
|
||
<label style={label}>Confirm password</label>
|
||
<input type="password" value={profPwConfirm} onChange={e=>{setProfPwConfirm(e.target.value);setProfErr("");}} style={inp} autoComplete="new-password"/>
|
||
{profErr&&<div style={{fontSize:12.5,color:"#f87171",marginBottom:10,marginTop:-6}}>{profErr}</div>}
|
||
<div style={{display:"flex",gap:8,marginTop:4}}>
|
||
<button onClick={()=>setStep(4)}
|
||
style={{flex:1,padding:"11px",border:"1px solid rgba(255,255,255,0.1)",borderRadius:9,
|
||
background:"transparent",color:"rgba(255,255,255,0.45)",fontSize:14,cursor:"pointer",fontFamily:FONT}}>
|
||
Back
|
||
</button>
|
||
<button onClick={()=>{if(validateProfile())startImport();}}
|
||
style={{flex:2,padding:"11px",border:"none",borderRadius:9,background:"#00c4d8",
|
||
color:"#0c0c0c",fontSize:14,fontWeight:700,cursor:"pointer",fontFamily:FONT,
|
||
display:"flex",alignItems:"center",justifyContent:"center",gap:8}}>
|
||
Start import <i className="ti ti-database-import" style={{fontSize:15}} aria-hidden="true"/>
|
||
</button>
|
||
</div>
|
||
</>}
|
||
|
||
{/* ── Step 6: Importing ── */}
|
||
{step===6&&<div style={{textAlign:"center",padding:"12px 0"}}>
|
||
<i className="ti ti-database-import" style={{fontSize:36,color:"#00c4d8",display:"block",marginBottom:16}} aria-hidden="true"/>
|
||
<div style={{fontSize:16,fontWeight:700,color:"#fff",marginBottom:6}}>Importing emails…</div>
|
||
<div style={{fontSize:13,color:"rgba(255,255,255,0.4)",marginBottom:20}}>
|
||
Copying from SMB share to local database.<br/>This may take a few minutes.
|
||
</div>
|
||
<div style={{background:"rgba(255,255,255,0.06)",borderRadius:8,height:8,overflow:"hidden",marginBottom:10}}>
|
||
<div style={{height:"100%",background:"#00c4d8",borderRadius:8,
|
||
width:`${importProgress}%`,transition:"width 0.3s"}}/>
|
||
</div>
|
||
<div style={{fontSize:12.5,color:"rgba(255,255,255,0.4)",marginBottom:6}}>{importProgress}% complete</div>
|
||
{importCurrent&&<div style={{fontSize:12,color:"rgba(255,255,255,0.25)",overflow:"hidden",
|
||
textOverflow:"ellipsis",whiteSpace:"nowrap"}}>{importCurrent}</div>}
|
||
</div>}
|
||
|
||
{/* ── Step 7: Done ── */}
|
||
{step===7&&<div style={{textAlign:"center",padding:"12px 0"}}>
|
||
<div style={{width:60,height:60,borderRadius:"50%",background:"rgba(34,197,94,0.12)",
|
||
display:"flex",alignItems:"center",justifyContent:"center",margin:"0 auto 16px"}}>
|
||
<i className="ti ti-circle-check" style={{fontSize:34,color:"#22c55e"}} aria-hidden="true"/>
|
||
</div>
|
||
<div style={{fontSize:18,fontWeight:700,color:"#fff",marginBottom:8}}>All set!</div>
|
||
<div style={{fontSize:13,color:"rgba(255,255,255,0.45)",lineHeight:1.7,marginBottom:24}}>
|
||
{accounts.filter(a=>a.checked).reduce((s,a)=>s+a.count,0).toLocaleString()} emails imported successfully.<br/>
|
||
Your SMB share was not modified in any way.
|
||
</div>
|
||
<div style={{display:"flex",flexDirection:"column",gap:6,marginBottom:24}}>
|
||
{accounts.filter(a=>a.checked).map((a,i)=>(
|
||
<div key={i} style={{display:"flex",alignItems:"center",gap:8,padding:"8px 12px",
|
||
background:"rgba(34,197,94,0.06)",borderRadius:7,border:"1px solid rgba(34,197,94,0.15)"}}>
|
||
<i className="ti ti-circle-check" style={{fontSize:13,color:"#22c55e",flexShrink:0}} aria-hidden="true"/>
|
||
<span style={{fontSize:13,color:"rgba(255,255,255,0.65)",flex:1,textAlign:"left",overflow:"hidden",textOverflow:"ellipsis",whiteSpace:"nowrap"}}>{a.email}</span>
|
||
<span style={{fontSize:12,color:"rgba(255,255,255,0.3)",flexShrink:0}}>{a.count.toLocaleString()} emails</span>
|
||
</div>
|
||
))}
|
||
</div>
|
||
<button onClick={onComplete}
|
||
style={{width:"100%",padding:"13px",border:"none",borderRadius:9,background:"#00c4d8",
|
||
color:"#0c0c0c",fontSize:15,fontWeight:700,cursor:"pointer",fontFamily:FONT,
|
||
display:"flex",alignItems:"center",justifyContent:"center",gap:8}}>
|
||
Launch DashMail <i className="ti ti-arrow-right" style={{fontSize:15}} aria-hidden="true"/>
|
||
</button>
|
||
</div>}
|
||
|
||
</div>
|
||
<div style={{marginTop:20,fontSize:11,color:"rgba(255,255,255,0.12)",letterSpacing:"1.5px",textTransform:"uppercase"}}>
|
||
Dash<span style={{color:"rgba(0,196,216,0.25)"}}>Mail</span> • The Secure Faxmail Client
|
||
</div>
|
||
</div>;
|
||
}
|
||
|
||
// ── Main App ──────────────────────────────────────────────────────────────────
|
||
|
||
export default function DashMailClient(){
|
||
const[profiles,setProfiles]=useState(INIT_PROFILES);
|
||
const[activeProfile,setActiveProfile]=useState(null);
|
||
const[loginBg,setLoginBg]=useState(null);
|
||
const[loginDark,setLoginDark]=useState(true);
|
||
const[firstRun,setFirstRun]=useState(false);
|
||
|
||
// Check SMB share for folders not yet covered by any profile
|
||
const[newFoldersFound,setNewFoldersFound]=useState(false);
|
||
useEffect(()=>{
|
||
// Simulate async share scan on app start (~1.5s)
|
||
const knownAccounts=new Set(INIT_PROFILES.flatMap(p=>p.accountIds));
|
||
const t=setTimeout(()=>{
|
||
const newOnes=MOCK_NEW_FOLDERS.filter(f=>!knownAccounts.has(f));
|
||
if(newOnes.length>0) setNewFoldersFound(true);
|
||
},1500);
|
||
return ()=>clearTimeout(t);
|
||
},[]);
|
||
|
||
function selectProfile(p){setActiveProfile(p);}
|
||
function saveProfile(updated){
|
||
setProfiles(prev=>prev.map(p=>p.id===updated.id?updated:p));
|
||
setActiveProfile(updated);
|
||
}
|
||
|
||
if(firstRun)
|
||
return <SetupWizard onComplete={()=>setFirstRun(false)}/>;
|
||
|
||
if(!activeProfile)
|
||
return <LoginScreen profiles={profiles} onLogin={selectProfile} loginBg={loginBg} loginDark={loginDark}
|
||
onSetupNew={newFoldersFound?()=>setFirstRun(true):undefined}/>;
|
||
|
||
return <EmailApp activeProfile={activeProfile} profiles={profiles}
|
||
setActiveProfile={setActiveProfile} saveProfile={saveProfile}
|
||
loginBg={loginBg} setLoginBg={setLoginBg}
|
||
loginDark={loginDark} setLoginDark={setLoginDark}/>;
|
||
}
|
||
|
||
function EmailApp({activeProfile,profiles,setActiveProfile,saveProfile,loginBg,setLoginBg,loginDark,setLoginDark}){
|
||
const[isDark,setDark]=useState(activeProfile.settings?.isDark??false);
|
||
const[accent,setAccent]=useState(activeProfile.settings?.accent??"#0099bb");
|
||
const[bg,setBg]=useState(activeProfile.settings?.bg??"white");
|
||
const C=mkTheme(isDark,accent);
|
||
|
||
// Accounts visible to this profile
|
||
// Editable account display data (persists across settings opens)
|
||
const[accountData,setAccountData]=useState(
|
||
Object.fromEntries(ALL_ACCOUNTS.map(a=>[a.id,{display:a.display,email:a.email}]))
|
||
);
|
||
function updateAccount(id,updates){
|
||
setAccountData(prev=>({...prev,[id]:{...(prev[id]||{}), ...updates}}));
|
||
}
|
||
|
||
const ACCOUNTS=ALL_ACCOUNTS
|
||
.filter(a=>activeProfile.accountIds.length===0||activeProfile.accountIds.includes(a.id))
|
||
.map(a=>({...a,...(accountData[a.id]||{})}));
|
||
|
||
// SMB config (shared across profiles)
|
||
const[sentToast,setSentToast]=useState(null);
|
||
const[smbConfig,setSmbConfig]=useState({host:"",share:"mail",driveLetter:"Z",subfolder:".Mails",username:"",password:""});
|
||
|
||
// Sync theme state when active profile settings change (e.g. after saving settings)
|
||
useEffect(()=>{
|
||
setDark(activeProfile.settings?.isDark??false);
|
||
setAccent(activeProfile.settings?.accent??"#0099bb");
|
||
setBg(activeProfile.settings?.bg??"white");
|
||
},[activeProfile]);
|
||
|
||
// In-app setup wizard (for adding accounts after initial setup)
|
||
const[wizardOpen,setWizardOpen]=useState(false);
|
||
// Startup: check for new folders on the mail server
|
||
const[newFolderBanner,setNewFolderBanner]=useState(null);
|
||
useEffect(()=>{
|
||
// Simulate discovering a new folder ~3s after login
|
||
const t=setTimeout(()=>{
|
||
setNewFolderBanner({count:1,folder:"invoices@example-corp.com"});
|
||
},3000);
|
||
return ()=>clearTimeout(t);
|
||
},[]);
|
||
|
||
// Live theme preview callback (called by SettingsTheme immediately on change)
|
||
function handleLiveTheme(dark,accent,bgId){
|
||
setDark(dark); setAccent(accent); setBg(bgId);
|
||
}
|
||
|
||
function selectProfileSwitch(){setActiveProfile(null);}
|
||
|
||
// ── Email app state ─────────────────────────────────────────────────────────
|
||
const[sidebarCollapsed,setSidebarCollapsed]=useState(false);
|
||
const[selAccountId,setSelAccount]=useState(ACCOUNTS[0]?.id??null);
|
||
const[selFolder,setSelFolder]=useState("inbox");
|
||
const[selEmailId,setSelEmail]=useState(null);
|
||
const[emails,setEmails]=useState(SEED);
|
||
const[rules,setRules]=useState(INIT_RULES);
|
||
const[contacts,setContacts]=useState(INIT_CONTACTS);
|
||
const[customFolders,setCF]=useState({
|
||
"alex@example-corp.com":[{id:"cf1",name:"Clients",icon:"ti-building"},{id:"cf2",name:"Projects",icon:"ti-folder"}],
|
||
"sales@example-corp.com":[{id:"cf3",name:"Leads",icon:"ti-user-circle"}],
|
||
"j.doe@mailbox.org":[],"default":[]
|
||
});
|
||
const[search,setSearch]=useState("");
|
||
const[collapsed,setCollapsed]=useState({});
|
||
const[fullSettings,setFullSettings]=useState(false);
|
||
const[contactsOpen,setContactsOpen]=useState(false);
|
||
const[rulesOpen,setRulesOpen]=useState(false);
|
||
const[compose,setCompose]=useState(null);
|
||
const[syncing,setSyncing]=useState(false);
|
||
const[syncDone,setSyncDone]=useState(false);
|
||
const[showFilter,setShowFilter]=useState(false);
|
||
const[filterUnread,setFilterUnread]=useState(false);
|
||
const[filterStarred,setFilterStarred]=useState(false);
|
||
const[filterHasAtt,setFilterHasAtt]=useState(false);
|
||
const[filterFrom,setFilterFrom]=useState("");
|
||
const[filterSubject,setFilterSubject]=useState("");
|
||
const[onboarding,setOnboarding]=useState(null);
|
||
const[addFolderFor,setAddFolderFor]=useState(null);
|
||
const[newFolderName,setNewFolderName]=useState("");
|
||
const[ctxMenu,setCtxMenu]=useState(null);
|
||
const[attCtxMenu,setAttCtxMenu]=useState(null); // {x,y,name,email}
|
||
const[attViewer,setAttViewer]=useState(null); // {name,email}
|
||
const[emailListWidth,setEmailListWidth]=useState(320);
|
||
const dragRef=useRef(null);
|
||
|
||
const selAccount=ACCOUNTS.find(a=>a.id===selAccountId);
|
||
const selEmail=emails.find(e=>e.id===selEmailId)??null;
|
||
const cfForAccount=customFolders[selAccountId]??[];
|
||
const allFolderNames=[...STD_FOLDERS.map(f=>f.id),...Object.values(customFolders).flat().map(f=>f.name.toLowerCase())];
|
||
|
||
const visibleEmails=emails.filter(e=>{
|
||
if(e.account!==selAccountId||e.folder!==selFolder)return false;
|
||
if(filterUnread&&e.read)return false;
|
||
if(filterStarred&&!e.starred)return false;
|
||
if(filterHasAtt&&!e.atts?.length)return false;
|
||
if(filterFrom&&!e.from.toLowerCase().includes(filterFrom.toLowerCase()))return false;
|
||
if(filterSubject&&!e.subject.toLowerCase().includes(filterSubject.toLowerCase()))return false;
|
||
if(!search)return true;
|
||
const q=search.toLowerCase();
|
||
return e.from.toLowerCase().includes(q)||e.subject.toLowerCase().includes(q)||e.preview.toLowerCase().includes(q);
|
||
});
|
||
const activeFilterCount=[filterUnread,filterStarred,filterHasAtt,filterFrom,filterSubject].filter(Boolean).length;
|
||
|
||
function unread(accountId,folderId){return emails.filter(e=>e.account===accountId&&e.folder===folderId&&!e.read).length;}
|
||
function openEmail(email){setSelEmail(email.id);setEmails(p=>p.map(e=>e.id===email.id?{...e,read:true}:e));}
|
||
function doArchive(){if(selEmail){setEmails(p=>p.map(e=>e.id===selEmail.id?{...e,folder:"archive"}:e));setSelEmail(null);}}
|
||
function doDelete(){if(selEmail){setEmails(p=>p.map(e=>e.id===selEmail.id?{...e,folder:"trash"}:e));setSelEmail(null);}}
|
||
function doJunk(){if(selEmail){setEmails(p=>p.map(e=>e.id===selEmail.id?{...e,folder:"trash"}:e));setSelEmail(null);}}
|
||
function doStar(){if(selEmail)setEmails(p=>p.map(e=>e.id===selEmail.id?{...e,starred:!e.starred}:e));}
|
||
function doUnread(){if(selEmail){setEmails(p=>p.map(e=>e.id===selEmail.id?{...e,read:false}:e));setSelEmail(null);}}
|
||
function openReply(mode){
|
||
if(!selEmail)return;
|
||
const fe=selAccount?.email??selAccountId;
|
||
const reSubj=selEmail.subject.match(/^Re:/i)?selEmail.subject:`Re: ${selEmail.subject}`;
|
||
const fwSubj=selEmail.subject.match(/^Fwd:/i)?selEmail.subject:`Fwd: ${selEmail.subject}`;
|
||
if(mode==="reply") setCompose({mode,fromEmail:fe,initial:{to:selEmail.from_email,subject:reSubj,body:quoteBody(selEmail.body,selEmail.from)}});
|
||
if(mode==="replyAll")setCompose({mode,fromEmail:fe,initial:{to:selEmail.from_email,cc:selEmail.to,subject:reSubj,body:quoteBody(selEmail.body,selEmail.from)}});
|
||
if(mode==="forward") setCompose({mode,fromEmail:fe,initial:{subject:fwSubj,body:fwdBody(selEmail)}});
|
||
}
|
||
function handleSend(msg){
|
||
setEmails(p=>[{id:Date.now(),account:selAccountId,folder:"out",
|
||
from:selAccount?.display??"Me",from_email:selAccount?.email??"",
|
||
to:msg.to,subject:msg.subject||"(no subject)",preview:msg.body.slice(0,120),
|
||
body:msg.body,date:"Just now",read:true,starred:false,
|
||
atts:(msg.attachments||[]).map(f=>f.name),
|
||
av:selAccount?.av??"ME",avColor:selAccount?.avColor??accent},...p]);
|
||
if(!msg.draft){
|
||
setSentToast({to:msg.to,subject:msg.subject||"(no subject)"});
|
||
setTimeout(()=>setSentToast(null),3500);
|
||
}
|
||
}
|
||
function createCustomFolder(){
|
||
if(!newFolderName.trim()||!addFolderFor)return;
|
||
setCF(p=>({...p,[addFolderFor]:[...(p[addFolderFor]??[]),{id:uid(),name:newFolderName.trim(),icon:"ti-folder"}]}));
|
||
setNewFolderName("");setAddFolderFor(null);
|
||
}
|
||
function archiveEmail(email){setEmails(p=>p.map(e=>e.id===email.id?{...e,folder:"archive"}:e));if(selEmailId===email.id)setSelEmail(null);}
|
||
function deleteEmail(email){setEmails(p=>p.map(e=>e.id===email.id?{...e,folder:"trash"}:e));if(selEmailId===email.id)setSelEmail(null);}
|
||
function moveEmail(email,folderKey){setEmails(p=>p.map(e=>e.id===email.id?{...e,folder:folderKey}:e));if(selEmailId===email.id)setSelEmail(null);}
|
||
function moveFoldersFor(email){
|
||
if(!email)return[];
|
||
const cur=email.folder;
|
||
const std=STD_FOLDERS.filter(f=>f.id!==cur).map(f=>({key:f.id,label:f.label,icon:f.icon,custom:false}));
|
||
const cfs=(customFolders[email.account]??[]).map(cf=>({key:`__cf_${cf.id}`,label:cf.name,icon:cf.icon,custom:true})).filter(f=>f.key!==cur);
|
||
return[...std,...cfs];
|
||
}
|
||
function toggleStarEmail(email){setEmails(p=>p.map(e=>e.id===email.id?{...e,starred:!e.starred}:e));}
|
||
function toggleReadEmail(email){setEmails(p=>p.map(e=>e.id===email.id?{...e,read:!e.read}:e));}
|
||
function replyEmail(mode,email){
|
||
const fe=selAccount?.email??selAccountId;
|
||
const reSubj=email.subject.match(/^Re:/i)?email.subject:`Re: ${email.subject}`;
|
||
const fwSubj=email.subject.match(/^Fwd:/i)?email.subject:`Fwd: ${email.subject}`;
|
||
if(mode==="reply") setCompose({mode,fromEmail:fe,initial:{to:email.from_email,subject:reSubj,body:quoteBody(email.body,email.from)}});
|
||
if(mode==="replyAll")setCompose({mode,fromEmail:fe,initial:{to:email.from_email,cc:email.to,subject:reSubj,body:quoteBody(email.body,email.from)}});
|
||
if(mode==="forward") setCompose({mode,fromEmail:fe,initial:{subject:fwSubj,body:fwdBody(email)}});
|
||
if(mode==="forwardAsAtt"){
|
||
const safe=(email.subject||"message").replace(/[\\/:*?"<>|]+/g,"_").replace(/\s+/g,"_").slice(0,80)||"message";
|
||
const emlSize=(email.body?.length??0)+(email.subject?.length??0)+(email.from?.length??0)+512;
|
||
const emlAtt={name:`${safe}.eml`,size:emlSize,isEmail:true,source:{id:email.id,from:email.from,from_email:email.from_email,to:email.to,subject:email.subject,date:email.date}};
|
||
setCompose({mode,fromEmail:fe,initial:{subject:fwSubj,body:"",attachments:[emlAtt]}});
|
||
}
|
||
}
|
||
|
||
const totalUnread=emails.filter(e=>!e.read&&ACCOUNTS.some(a=>a.id===e.account)).length;
|
||
const bgStyle=getBgStyle(bg,activeProfile.settings?.bgCustomUrl);
|
||
const bgDark=bgIsDark(bg)||bgIsImage(bg);
|
||
const readingTextColor=bgDark?"rgba(255,255,255,0.9)":C.text;
|
||
const readingMutedColor=bgDark?"rgba(255,255,255,0.55)":C.muted;
|
||
|
||
useEffect(()=>{
|
||
function onResize(){setEmailListWidth(w=>w);}// nudge React to re-layout
|
||
window.addEventListener("resize",onResize);
|
||
return()=>window.removeEventListener("resize",onResize);
|
||
},[]);
|
||
|
||
return <div style={{display:"flex",flexDirection:"column",height:"100%",fontFamily:FONT,background:C.bg,position:"relative",overflow:"hidden"}}>
|
||
|
||
{/* Title bar */}
|
||
<div style={{height:32,background:C.titleBar,display:"flex",alignItems:"center",justifyContent:"space-between",flexShrink:0}}>
|
||
<div style={{display:"flex",alignItems:"center",gap:8,paddingLeft:12}}>
|
||
<span style={{fontSize:16,letterSpacing:"-0.3px"}}><span style={{color:"#fff",fontWeight:800}}>Dash</span><span style={{color:"#00c4d8",fontWeight:200}}>Mail</span></span>
|
||
</div>
|
||
<div style={{display:"flex",alignItems:"center"}}>
|
||
{/* Logout button */}
|
||
<div onClick={()=>setActiveProfile(null)}
|
||
title="Sign out"
|
||
style={{height:32,padding:"0 14px",display:"flex",alignItems:"center",gap:6,cursor:"pointer",color:"rgba(255,255,255,0.55)",fontSize:12,fontWeight:500,fontFamily:FONT,borderRight:"1px solid rgba(255,255,255,0.07)"}}
|
||
onMouseEnter={e=>{e.currentTarget.style.background="rgba(255,255,255,0.08)";e.currentTarget.style.color="rgba(255,255,255,0.9)";}}
|
||
onMouseLeave={e=>{e.currentTarget.style.background="transparent";e.currentTarget.style.color="rgba(255,255,255,0.55)";}}>
|
||
<i className="ti ti-logout" style={{fontSize:14}} aria-hidden="true"/>
|
||
<span>Sign out</span>
|
||
</div>
|
||
{["ti-minus","ti-square","ti-x"].map((ic,i)=>(
|
||
<div key={ic} style={{width:46,height:32,display:"flex",alignItems:"center",justifyContent:"center",cursor:"pointer"}}
|
||
onMouseEnter={e=>e.currentTarget.style.background=i===2?"#c42b1c":"rgba(255,255,255,0.1)"}
|
||
onMouseLeave={e=>e.currentTarget.style.background="transparent"}>
|
||
<i className={`ti ${ic}`} style={{fontSize:14,color:"#c8c8c8"}} aria-hidden="true"/>
|
||
</div>
|
||
))}
|
||
</div>
|
||
</div>
|
||
|
||
{/* Main row */}
|
||
<div style={{display:"flex",flex:1,minHeight:0}}>
|
||
|
||
{/* ── Sidebar ───────────────────────────────────────────────────────── */}
|
||
<div style={{width:sidebarCollapsed?56:264,background:C.sidebar,borderRight:`1px solid ${C.border}`,display:"flex",flexDirection:"column",flexShrink:0,overflow:"hidden",transition:"width 0.22s cubic-bezier(0.4,0,0.2,1)",position:"relative"}}>
|
||
<div style={{padding:"12px 8px 8px",flexShrink:0}}>
|
||
<button onClick={()=>setCompose({mode:"new",fromEmail:selAccount?.email??selAccountId,initial:{}})}
|
||
style={{width:"100%",background:accent,color:"#fff",border:"none",borderRadius:6,padding:"9px 0",fontSize:13.5,fontWeight:600,cursor:"pointer",fontFamily:FONT,display:"flex",alignItems:"center",justifyContent:"center",gap:7,overflow:"hidden",whiteSpace:"nowrap"}}
|
||
onMouseEnter={e=>e.currentTarget.style.background=C.accentHov}
|
||
onMouseLeave={e=>e.currentTarget.style.background=accent}>
|
||
<i className="ti ti-edit" style={{fontSize:16,flexShrink:0}} aria-hidden="true"/>
|
||
{!sidebarCollapsed&&"New Message"}
|
||
</button>
|
||
</div>
|
||
|
||
<div style={{flex:1,overflowY:"auto",padding:"0 8px",overflowX:"hidden"}}>
|
||
{ACCOUNTS.map(acc=>{
|
||
const isColl=collapsed[acc.id];
|
||
const isSelAcc=acc.id===selAccountId;
|
||
return <div key={acc.id}>
|
||
<div onClick={()=>{setCollapsed(p=>({...p,[acc.id]:!isColl}));setSelAccount(acc.id);}}
|
||
style={{display:"flex",alignItems:"center",gap:8,padding:"7px 8px",borderRadius:6,cursor:"pointer",marginTop:6,justifyContent:sidebarCollapsed?"center":"flex-start"}}
|
||
onMouseEnter={e=>e.currentTarget.style.background=C.hover}
|
||
onMouseLeave={e=>e.currentTarget.style.background="transparent"}>
|
||
<Avatar initials={acc.av} color={acc.avColor} size={26}/>
|
||
{!sidebarCollapsed&&<><div style={{flex:1,minWidth:0}}>
|
||
<div style={{fontSize:12.5,fontWeight:700,color:isSelAcc?accent:C.text,overflow:"hidden",textOverflow:"ellipsis",whiteSpace:"nowrap"}}>{acc.display}</div>
|
||
{acc.email&&<div style={{fontSize:11,color:C.subtle,overflow:"hidden",textOverflow:"ellipsis",whiteSpace:"nowrap"}}>{acc.email}</div>}
|
||
</div>
|
||
<i className={`ti ti-chevron-${isColl?"right":"down"}`} style={{fontSize:12,color:C.subtle,flexShrink:0}} aria-hidden="true"/></>}
|
||
</div>
|
||
{!isColl&&<>
|
||
{STD_FOLDERS.map(f=>{
|
||
const ur=unread(acc.id,f.id);
|
||
const isSel=isSelAcc&&selFolder===f.id;
|
||
return <FolderItem key={f.id} icon={f.icon} label={sidebarCollapsed?"":f.label} badge={!sidebarCollapsed&&f.badge&&ur>0?ur:0}
|
||
selected={isSel} C={C} indent={sidebarCollapsed?8:28} collapsed={sidebarCollapsed}
|
||
onClick={()=>{setSelAccount(acc.id);setSelFolder(f.id);setSelEmail(null);}}/>;
|
||
})}
|
||
{!sidebarCollapsed&&<div style={{marginTop:6}}>
|
||
<div style={{display:"flex",alignItems:"center",padding:"4px 8px 4px 28px"}}>
|
||
<span style={{flex:1,fontSize:10.5,fontWeight:700,color:C.subtle,letterSpacing:"0.07em",textTransform:"uppercase"}}>My Folders</span>
|
||
<button title="Scan" onClick={e=>{e.stopPropagation();setCF(p=>({...p,[acc.id]:[...(p[acc.id]??[]),{id:uid(),name:"New-"+uid().slice(0,4),icon:"ti-folder-search"}]}));}}
|
||
style={{background:"transparent",border:"none",cursor:"pointer",color:C.subtle,padding:"2px 3px"}}><i className="ti ti-folder-search" style={{fontSize:13}} aria-hidden="true"/></button>
|
||
<button title="New folder" onClick={e=>{e.stopPropagation();setAddFolderFor(acc.id);setNewFolderName("");}}
|
||
style={{background:"transparent",border:"none",cursor:"pointer",color:C.subtle,padding:"2px 3px"}}><i className="ti ti-plus" style={{fontSize:13}} aria-hidden="true"/></button>
|
||
</div>
|
||
{addFolderFor===acc.id&&<div style={{padding:"4px 8px 6px 28px",display:"flex",gap:5}}>
|
||
<input autoFocus value={newFolderName} onChange={e=>setNewFolderName(e.target.value)}
|
||
onKeyDown={e=>{if(e.key==="Enter")createCustomFolder();if(e.key==="Escape")setAddFolderFor(null);}}
|
||
placeholder="Folder name…"
|
||
style={{flex:1,padding:"5px 8px",border:`1px solid ${C.accent}`,borderRadius:5,fontSize:12.5,fontFamily:FONT,outline:"none",color:C.text,background:C.inputBg}}/>
|
||
<button onClick={createCustomFolder} style={{background:accent,color:"#fff",border:"none",borderRadius:5,padding:"4px 9px",cursor:"pointer",fontFamily:FONT,fontSize:12}}>Add</button>
|
||
<button onClick={()=>setAddFolderFor(null)} style={{background:"transparent",border:`1px solid ${C.border}`,borderRadius:5,padding:"4px 7px",cursor:"pointer",color:C.muted,fontSize:12,fontFamily:FONT}}>✕</button>
|
||
</div>}
|
||
{(customFolders[acc.id]??[]).map(cf=>{
|
||
const cfKey=`__cf_${cf.id}`;
|
||
const isSel=isSelAcc&&selFolder===cfKey;
|
||
return <div key={cf.id} style={{display:"flex",alignItems:"center",padding:"6px 8px 6px 28px",borderRadius:6,cursor:"pointer",marginBottom:1,background:isSel?C.selectedBg:"transparent",borderLeft:`3px solid ${isSel?accent:"transparent"}`}}
|
||
onClick={()=>{setSelAccount(acc.id);setSelFolder(cfKey);setSelEmail(null);}}
|
||
onMouseEnter={e=>{if(!isSel){e.currentTarget.style.background=C.hover;e.currentTarget.querySelector(".cfx")&&(e.currentTarget.querySelector(".cfx").style.opacity="1");}}}
|
||
onMouseLeave={e=>{if(!isSel){e.currentTarget.style.background="transparent";e.currentTarget.querySelector(".cfx")&&(e.currentTarget.querySelector(".cfx").style.opacity="0");}}}>
|
||
<i className={`ti ${cf.icon}`} style={{fontSize:14,color:isSel?accent:C.muted,width:18,textAlign:"center",marginRight:7}} aria-hidden="true"/>
|
||
<span style={{flex:1,fontSize:13,color:isSel?accent:C.text,fontWeight:isSel?600:400}}>{cf.name}</span>
|
||
<button className="cfx" onClick={e=>{e.stopPropagation();setCF(p=>({...p,[acc.id]:(p[acc.id]??[]).filter(f=>f.id!==cf.id)}));}}
|
||
style={{opacity:0,background:"transparent",border:"none",cursor:"pointer",color:C.subtle,padding:"1px 3px",transition:"opacity 0.1s"}}>
|
||
<i className="ti ti-x" style={{fontSize:12}} aria-hidden="true"/>
|
||
</button>
|
||
</div>;
|
||
})}
|
||
{(customFolders[acc.id]??[]).length===0&&addFolderFor!==acc.id&&<div style={{padding:"3px 8px 6px 28px"}}><span style={{fontSize:11.5,color:C.subtle,fontStyle:"italic"}}>No custom folders</span></div>}
|
||
</div>}
|
||
</>}
|
||
</div>;
|
||
})}
|
||
</div>
|
||
|
||
{/* Sidebar bottom */}
|
||
<div style={{padding:"8px",borderTop:`1px solid ${C.border}`,flexShrink:0}}>
|
||
{/* Profile switcher */}
|
||
<div style={{display:"flex",alignItems:"center",gap:9,padding:"7px 8px",borderRadius:7,background:C.surface2,border:`1px solid ${C.border}`,marginBottom:6,cursor:"pointer",justifyContent:sidebarCollapsed?"center":"flex-start"}}
|
||
onClick={()=>setActiveProfile(null)}>
|
||
<div style={{width:28,height:28,borderRadius:"50%",background:activeProfile.color+"30",border:`2px solid ${activeProfile.color}`,display:"flex",alignItems:"center",justifyContent:"center",fontSize:11,fontWeight:700,color:activeProfile.color,flexShrink:0}}>{activeProfile.initials}</div>
|
||
{!sidebarCollapsed&&<><div style={{flex:1,minWidth:0}}>
|
||
<div style={{fontSize:12.5,fontWeight:600,color:C.text,overflow:"hidden",textOverflow:"ellipsis",whiteSpace:"nowrap"}}>{activeProfile.name}</div>
|
||
<div style={{fontSize:11,color:C.subtle}}>Switch profile</div>
|
||
</div>
|
||
<i className="ti ti-chevron-up-down" style={{fontSize:13,color:C.subtle,flexShrink:0}} aria-hidden="true"/></>}
|
||
</div>
|
||
{[{icon:"ti-address-book",label:"Contacts",fn:()=>setContactsOpen(true)},{icon:"ti-settings",label:"Settings",fn:()=>setFullSettings(true)}].map(item=>(
|
||
<div key={item.label} onClick={item.fn} title={sidebarCollapsed?item.label:undefined}
|
||
style={{display:"flex",alignItems:"center",gap:8,padding:"6px 8px",borderRadius:6,cursor:"pointer",justifyContent:sidebarCollapsed?"center":"flex-start"}}
|
||
onMouseEnter={e=>e.currentTarget.style.background=C.hover}
|
||
onMouseLeave={e=>e.currentTarget.style.background="transparent"}>
|
||
<i className={`ti ${item.icon}`} style={{fontSize:15,color:C.muted,flexShrink:0}} aria-hidden="true"/>
|
||
{!sidebarCollapsed&&<span style={{fontSize:12.5,color:C.muted,overflow:"hidden",textOverflow:"ellipsis",whiteSpace:"nowrap"}}>{item.label}</span>}
|
||
</div>
|
||
))}
|
||
{/* Sidebar collapse toggle */}
|
||
<div onClick={()=>setSidebarCollapsed(v=>!v)}
|
||
title={sidebarCollapsed?"Expand sidebar":"Collapse sidebar"}
|
||
style={{display:"flex",alignItems:"center",justifyContent:sidebarCollapsed?"center":"flex-end",padding:"6px 8px",borderRadius:6,cursor:"pointer",marginTop:2}}
|
||
onMouseEnter={e=>e.currentTarget.style.background=C.hover}
|
||
onMouseLeave={e=>e.currentTarget.style.background="transparent"}>
|
||
{/* Panel-toggle icon: two vertical rectangles */}
|
||
<svg width="18" height="18" viewBox="0 0 18 18" fill="none" xmlns="http://www.w3.org/2000/svg"
|
||
style={{flexShrink:0,transform:sidebarCollapsed?"scaleX(-1)":"scaleX(1)",transition:"transform 0.22s"}}>
|
||
<rect x="1.5" y="2.5" width="4" height="13" rx="1.2" stroke={C.muted} strokeWidth="1.4"/>
|
||
<rect x="7.5" y="2.5" width="9" height="13" rx="1.2" stroke={C.muted} strokeWidth="1.4" strokeOpacity="0.45"/>
|
||
</svg>
|
||
</div>
|
||
</div>
|
||
</div>
|
||
|
||
{/* ── Email list ──────────────────────────────────────────────────────── */}
|
||
<div style={{width:emailListWidth,background:C.surface,display:"flex",flexDirection:"column",flexShrink:0,minWidth:200,maxWidth:600}}>
|
||
<div style={{padding:"12px 12px 8px",borderBottom:`1px solid ${C.border}`,flexShrink:0}}>
|
||
<div style={{display:"flex",alignItems:"center",justifyContent:"space-between",marginBottom:8}}>
|
||
<div style={{display:"flex",alignItems:"center",gap:8}}>
|
||
<span style={{fontSize:15,fontWeight:600,color:C.text}}>
|
||
{selFolder.startsWith("__cf_")
|
||
?cfForAccount.find(f=>`__cf_${f.id}`===selFolder)?.name??"Folder"
|
||
:STD_FOLDERS.find(f=>f.id===selFolder)?.label??"Folder"}
|
||
</span>
|
||
{syncing&&<span style={{fontSize:11.5,color:C.accent,fontWeight:500,animation:"fadeInUp 0.15s ease"}}>Syncing…</span>}
|
||
{syncDone&&!syncing&&<span style={{fontSize:11.5,color:"#22c55e",fontWeight:500,animation:"fadeInUp 0.15s ease"}}>Synced ✓</span>}
|
||
</div>
|
||
<div style={{display:"flex"}}>
|
||
<div style={{position:"relative",display:"flex",alignItems:"center"}}>
|
||
{/* Sync button with done indicator */}
|
||
<div style={{position:"relative"}}>
|
||
<IBtn icon={`ti-refresh${syncing?" ti-spin":""}`} title="Sync" C={C}
|
||
onClick={()=>{setSyncing(true);setSyncDone(false);setTimeout(()=>{setSyncing(false);setSyncDone(true);setTimeout(()=>setSyncDone(false),2200);},1600);}}/>
|
||
{syncDone&&<div style={{position:"absolute",top:-4,right:-4,width:16,height:16,
|
||
borderRadius:"50%",background:"#22c55e",
|
||
display:"flex",alignItems:"center",justifyContent:"center",pointerEvents:"none",
|
||
animation:"popIn 0.2s ease",zIndex:5}}>
|
||
<i className="ti ti-check" style={{fontSize:9,color:"#fff"}} aria-hidden="true"/>
|
||
</div>}
|
||
</div>
|
||
{/* Filter button with active badge */}
|
||
<div style={{position:"relative"}}>
|
||
<IBtn icon="ti-adjustments-horizontal" title="Filter"
|
||
C={{...C,text:activeFilterCount>0?C.accent:C.text}}
|
||
onClick={()=>setShowFilter(v=>!v)}/>
|
||
{activeFilterCount>0&&<div style={{position:"absolute",top:-4,right:-4,width:16,height:16,
|
||
borderRadius:"50%",background:C.accent,
|
||
display:"flex",alignItems:"center",justifyContent:"center",pointerEvents:"none",fontSize:9,
|
||
fontWeight:700,color:"#fff",fontFamily:FONT,zIndex:5}}>
|
||
{activeFilterCount}
|
||
</div>}
|
||
</div>
|
||
</div>
|
||
</div>
|
||
</div>
|
||
<div style={{position:"relative"}}>
|
||
<i className="ti ti-search" style={{position:"absolute",left:9,top:"50%",transform:"translateY(-50%)",fontSize:14,color:C.subtle}} aria-hidden="true"/>
|
||
<input value={search} onChange={e=>setSearch(e.target.value)} placeholder="Search…"
|
||
style={{width:"100%",boxSizing:"border-box",padding:"7px 9px 7px 30px",border:`1px solid ${C.inBorder}`,borderRadius:6,fontSize:13,fontFamily:FONT,background:C.inputBg,color:C.text,outline:"none"}}/>
|
||
</div>
|
||
</div>
|
||
{/* Filter dropdown panel */}
|
||
{showFilter&&<div style={{margin:"6px 0",background:C.surface2,
|
||
border:`1px solid ${C.border}`,borderRadius:8,padding:"10px 12px",fontSize:13,fontFamily:FONT}}>
|
||
<div style={{display:"flex",justifyContent:"space-between",alignItems:"center",marginBottom:8}}>
|
||
<span style={{fontSize:12,fontWeight:700,color:C.muted,letterSpacing:"0.04em"}}>FILTER MESSAGES</span>
|
||
{activeFilterCount>0&&<button onClick={()=>{setFilterUnread(false);setFilterStarred(false);setFilterHasAtt(false);setFilterFrom("");setFilterSubject("");}}
|
||
style={{fontSize:11.5,color:C.accent,background:"transparent",border:"none",cursor:"pointer",fontFamily:FONT,padding:0}}>
|
||
Clear all
|
||
</button>}
|
||
</div>
|
||
<div style={{display:"flex",gap:6,flexWrap:"wrap",marginBottom:10}}>
|
||
{[{label:"Unread",active:filterUnread,fn:()=>setFilterUnread(v=>!v),icon:"ti-mail"},
|
||
{label:"Starred",active:filterStarred,fn:()=>setFilterStarred(v=>!v),icon:"ti-star"},
|
||
{label:"Has attachment",active:filterHasAtt,fn:()=>setFilterHasAtt(v=>!v),icon:"ti-paperclip"},
|
||
].map(chip=>(
|
||
<button key={chip.label} onClick={chip.fn}
|
||
style={{display:"flex",alignItems:"center",gap:5,padding:"4px 10px",borderRadius:20,
|
||
border:`1px solid ${chip.active?C.accent:C.border}`,
|
||
background:chip.active?C.accentBg:"transparent",
|
||
color:chip.active?C.accent:C.muted,fontSize:12,cursor:"pointer",fontFamily:FONT,
|
||
fontWeight:chip.active?600:400,transition:"all 0.12s"}}>
|
||
<i className={`ti ${chip.icon}`} style={{fontSize:12}} aria-hidden="true"/>
|
||
{chip.label}
|
||
</button>
|
||
))}
|
||
</div>
|
||
<div style={{display:"grid",gridTemplateColumns:"1fr 1fr",gap:8}}>
|
||
{[["From",filterFrom,setFilterFrom,"sender name or email"],
|
||
["Subject",filterSubject,setFilterSubject,"keywords"]].map(([lbl,val,set,ph])=>(
|
||
<div key={lbl}>
|
||
<label style={{display:"block",fontSize:11,fontWeight:600,color:C.subtle,marginBottom:3}}>{lbl.toUpperCase()}</label>
|
||
<input value={val} onChange={e=>set(e.target.value)} placeholder={ph}
|
||
style={{width:"100%",boxSizing:"border-box",padding:"5px 8px",
|
||
border:`1px solid ${val?C.accent:C.inBorder}`,borderRadius:6,
|
||
fontSize:12.5,fontFamily:FONT,outline:"none",color:C.text,background:C.inputBg}}
|
||
onFocus={e=>e.target.style.borderColor=C.accent}
|
||
onBlur={e=>e.target.style.borderColor=val?C.accent:C.inBorder}/>
|
||
</div>
|
||
))}
|
||
</div>
|
||
</div>}
|
||
<div style={{flex:1,overflowY:"auto",minHeight:0}}>
|
||
{visibleEmails.length===0
|
||
?<div style={{padding:32,textAlign:"center",color:C.subtle}}><i className="ti ti-inbox-off" style={{fontSize:36,display:"block",marginBottom:8}} aria-hidden="true"/><div style={{fontSize:13}}>No messages</div></div>
|
||
:visibleEmails.map(email=><EmailRow key={email.id} email={email} selected={selEmailId===email.id} C={C} onClick={()=>openEmail(email)} onContextMenu={(e,em)=>setCtxMenu({x:e.clientX,y:e.clientY,email:em})}/>)}
|
||
</div>
|
||
</div>
|
||
|
||
{/* ── Resize handle ───────────────────────────────────────────────────── */}
|
||
<div style={{width:5,flexShrink:0,cursor:"col-resize",position:"relative",zIndex:10,background:"transparent"}}
|
||
onMouseDown={e=>{
|
||
e.preventDefault();
|
||
const startX=e.clientX;
|
||
const startW=emailListWidth;
|
||
function onMove(ev){
|
||
const next=Math.min(600,Math.max(200,startW+(ev.clientX-startX)));
|
||
setEmailListWidth(next);
|
||
}
|
||
function onUp(){
|
||
document.removeEventListener("mousemove",onMove);
|
||
document.removeEventListener("mouseup",onUp);
|
||
document.body.style.cursor="";
|
||
document.body.style.userSelect="";
|
||
}
|
||
document.addEventListener("mousemove",onMove);
|
||
document.addEventListener("mouseup",onUp);
|
||
document.body.style.cursor="col-resize";
|
||
document.body.style.userSelect="none";
|
||
}}
|
||
onMouseEnter={e=>e.currentTarget.style.background=C.accent+"60"}
|
||
onMouseLeave={e=>e.currentTarget.style.background="transparent"}>
|
||
<div style={{position:"absolute",top:"50%",left:"50%",transform:"translate(-50%,-50%)",
|
||
width:3,height:32,borderRadius:2,background:C.border,pointerEvents:"none"}}/>
|
||
</div>
|
||
|
||
{/* ── Reading pane ─────────────────────────────────────────────────────── */}
|
||
<div style={{flex:1,display:"flex",flexDirection:"column",minWidth:0,...bgStyle}}>
|
||
{selEmail?<>
|
||
<EmailToolbar email={selEmail} folder={selFolder} C={C}
|
||
onReply={()=>openReply("reply")} onReplyAll={()=>openReply("replyAll")}
|
||
onForward={()=>openReply("forward")} onArchive={doArchive} onDelete={doDelete}
|
||
onJunk={doJunk} onStar={doStar} onUnread={doUnread} onPrint={()=>printEmail(selEmail)}/>
|
||
<div style={{padding:"16px 22px 14px",borderBottom:`1px solid ${bgDark?"rgba(255,255,255,0.12)":C.border}`,background:bgDark?"rgba(0,0,0,0.25)":"rgba(255,255,255,0.7)",flexShrink:0,backdropFilter:bgIsImage(bg)?"blur(12px)":"none"}}>
|
||
<h2 style={{margin:"0 0 12px",fontSize:17,fontWeight:600,color:readingTextColor,lineHeight:1.3}}>
|
||
{selEmail.subject}
|
||
{selEmail.starred&&<i className="ti ti-star-filled" style={{fontSize:15,color:"#f7c600",marginLeft:8,verticalAlign:"middle"}} aria-hidden="true"/>}
|
||
</h2>
|
||
<div style={{display:"flex",alignItems:"center",gap:10}}>
|
||
<Avatar initials={selEmail.av} color={selEmail.avColor} size={38}/>
|
||
<div style={{flex:1}}>
|
||
<div style={{fontSize:13.5,fontWeight:600,color:readingTextColor}}>{selEmail.from}</div>
|
||
<div style={{fontSize:12,color:readingMutedColor}}><span style={{color:accent}}>{selEmail.from_email}</span>{" → "}{selEmail.to}</div>
|
||
</div>
|
||
<span style={{fontSize:12,color:readingMutedColor,flexShrink:0}}>{selEmail.date}</span>
|
||
</div>
|
||
</div>
|
||
<div style={{flex:1,overflowY:"auto",padding:"18px 22px"}}>
|
||
<div style={{maxWidth:700,background:bgDark||bgIsImage(bg)?"rgba(0,0,0,0.25)":"transparent",borderRadius:bgDark||bgIsImage(bg)?10:0,padding:bgDark||bgIsImage(bg)?"18px 20px":0,backdropFilter:bgIsImage(bg)?"blur(8px)":"none"}}>
|
||
{selEmail.atts?.length>0&&<div style={{marginBottom:16,padding:"10px 14px",background:accent+"15",border:`1px solid ${accent}22`,borderRadius:7}}>
|
||
<div style={{fontSize:11.5,color:readingMutedColor,fontWeight:700,marginBottom:8,letterSpacing:"0.05em"}}><i className="ti ti-paperclip" style={{fontSize:13,marginRight:5}} aria-hidden="true"/>ATTACHMENTS ({selEmail.atts.length})</div>
|
||
<div style={{display:"flex",flexWrap:"wrap",gap:8}}>
|
||
{selEmail.atts.map(a=>(
|
||
<div key={a}
|
||
onClick={()=>downloadAttachment(a,selEmail)}
|
||
onContextMenu={e=>{e.preventDefault();setAttCtxMenu({x:e.clientX,y:e.clientY,name:a,email:selEmail});}}
|
||
title={`Click to download · Right-click for more options`}
|
||
style={{display:"flex",alignItems:"center",gap:6,background:bgDark?"rgba(255,255,255,0.1)":C.surface,border:`1px solid ${C.border}`,borderRadius:5,padding:"5px 10px",fontSize:12.5,color:readingTextColor,cursor:"pointer",transition:"background 0.12s,border-color 0.12s"}}
|
||
onMouseEnter={e=>{e.currentTarget.style.background=bgDark?"rgba(255,255,255,0.18)":C.hover;e.currentTarget.style.borderColor=accent;}}
|
||
onMouseLeave={e=>{e.currentTarget.style.background=bgDark?"rgba(255,255,255,0.1)":C.surface;e.currentTarget.style.borderColor=C.border;}}>
|
||
<i className={`ti ${getAttIcon(a)}`} style={{fontSize:14,color:accent,flexShrink:0}} aria-hidden="true"/>
|
||
<span style={{maxWidth:180,overflow:"hidden",textOverflow:"ellipsis",whiteSpace:"nowrap"}}>{a}</span>
|
||
<i className="ti ti-download" style={{fontSize:13,color:readingMutedColor,flexShrink:0}} aria-hidden="true"/>
|
||
</div>
|
||
))}
|
||
</div>
|
||
</div>}
|
||
{/<[a-z][\s\S]*>/i.test(selEmail.body??'')
|
||
?<div dangerouslySetInnerHTML={{__html:selEmail.body}} style={{fontFamily:FONT,fontSize:14,color:readingTextColor,lineHeight:1.75}}/>
|
||
:<pre style={{fontFamily:FONT,fontSize:14,color:readingTextColor,lineHeight:1.75,whiteSpace:"pre-wrap",margin:0}}>{selEmail.body}</pre>}
|
||
</div>
|
||
</div>
|
||
<div style={{padding:"10px 22px",borderTop:`1px solid ${bgDark?"rgba(255,255,255,0.12)":C.border}`,background:bgDark?"rgba(0,0,0,0.3)":"rgba(255,255,255,0.7)",display:"flex",gap:8,flexShrink:0,backdropFilter:bgIsImage(bg)?"blur(12px)":"none"}}>
|
||
{[["reply","Reply","ti-arrow-back-up"],["replyAll","Reply All","ti-arrows-left"],["forward","Forward","ti-arrow-forward-up"]].map(([mode,lbl,ic])=>(
|
||
<button key={mode} onClick={()=>openReply(mode)}
|
||
style={{background:mode==="reply"?accent:"transparent",color:mode==="reply"?"#fff":bgDark?"rgba(255,255,255,0.7)":C.muted,
|
||
border:mode==="reply"?"none":`1px solid ${bgDark?"rgba(255,255,255,0.2)":C.border}`,borderRadius:5,
|
||
padding:"7px 14px",fontSize:13,cursor:"pointer",fontFamily:FONT,display:"flex",alignItems:"center",gap:6}}
|
||
onMouseEnter={e=>{e.currentTarget.style.background=mode==="reply"?C.accentHov:bgDark?"rgba(255,255,255,0.1)":C.hover;}}
|
||
onMouseLeave={e=>{e.currentTarget.style.background=mode==="reply"?accent:"transparent";}}>
|
||
<i className={`ti ${ic}`} style={{fontSize:14}} aria-hidden="true"/>{lbl}
|
||
</button>
|
||
))}
|
||
</div>
|
||
</>
|
||
:<div style={{flex:1,display:"flex",flexDirection:"column",alignItems:"center",justifyContent:"center",gap:10}}>
|
||
<i className="ti ti-mail-opened" style={{fontSize:52,color:bgDark?"rgba(255,255,255,0.2)":C.subtle}} aria-hidden="true"/>
|
||
<div style={{fontSize:15,fontWeight:500,color:bgDark?"rgba(255,255,255,0.5)":C.muted}}>Select an email to read</div>
|
||
</div>}
|
||
</div>
|
||
</div>
|
||
|
||
{/* Status bar */}
|
||
<div style={{height:28,background:accent,display:"flex",alignItems:"center",justifyContent:"space-between",padding:"0 14px",flexShrink:0}}>
|
||
<div style={{display:"flex",alignItems:"center",gap:14}}>
|
||
<span style={{fontSize:11.5,color:"rgba(255,255,255,0.9)",display:"flex",alignItems:"center",gap:6}}>
|
||
<span style={{width:7,height:7,borderRadius:"50%",flexShrink:0,
|
||
background:syncing?"#f59e0b":"#73d680",
|
||
boxShadow:syncing?"0 0 0 2px rgba(245,158,11,0.35)":"0 0 0 2px rgba(115,214,128,0.35)",
|
||
display:"inline-block"}}/>
|
||
{syncing?"Syncing":"Connected"} — Faxmail Email Server
|
||
</span>
|
||
</div>
|
||
<div style={{display:"flex",alignItems:"center",gap:14}}>
|
||
<span style={{fontSize:11.5,color:"rgba(255,255,255,0.9)"}}>{totalUnread>0?`${totalUnread} unread`:"All read"}</span>
|
||
<span style={{fontSize:11.5,color:"rgba(255,255,255,0.7)"}}>{new Date().toLocaleTimeString([],{hour:"2-digit",minute:"2-digit"})}</span>
|
||
</div>
|
||
</div>
|
||
|
||
{/* Overlays */}
|
||
{ctxMenu&&<ContextMenu x={ctxMenu.x} y={ctxMenu.y} email={ctxMenu.email} C={C}
|
||
onClose={()=>setCtxMenu(null)}
|
||
onOpen={()=>openEmail(ctxMenu.email)}
|
||
onReply={()=>replyEmail("reply",ctxMenu.email)}
|
||
onReplyAll={()=>replyEmail("replyAll",ctxMenu.email)}
|
||
onForward={()=>replyEmail("forward",ctxMenu.email)}
|
||
onForwardAsAtt={()=>replyEmail("forwardAsAtt",ctxMenu.email)}
|
||
onToggleRead={()=>toggleReadEmail(ctxMenu.email)}
|
||
onToggleStar={()=>toggleStarEmail(ctxMenu.email)}
|
||
onArchive={()=>archiveEmail(ctxMenu.email)}
|
||
onDelete={()=>deleteEmail(ctxMenu.email)}
|
||
onPrint={()=>printEmail(ctxMenu.email)}
|
||
moveFolders={moveFoldersFor(ctxMenu.email)}
|
||
onMove={folderKey=>moveEmail(ctxMenu.email,folderKey)}/>}
|
||
{attCtxMenu&&<AttachmentContextMenu x={attCtxMenu.x} y={attCtxMenu.y}
|
||
name={attCtxMenu.name} srcEmail={attCtxMenu.email} C={C}
|
||
onClose={()=>setAttCtxMenu(null)}
|
||
onView={()=>{setAttViewer({name:attCtxMenu.name,email:attCtxMenu.email});setAttCtxMenu(null);}}/>}
|
||
{attViewer&&<AttachmentViewer name={attViewer.name} srcEmail={attViewer.email} C={C}
|
||
onDownload={()=>downloadAttachment(attViewer.name,attViewer.email)}
|
||
onPrint={()=>printAttachment(attViewer.name,attViewer.email)}
|
||
onClose={()=>setAttViewer(null)}/>}
|
||
{sentToast&&<div style={{position:"fixed",bottom:24,right:24,zIndex:300,
|
||
background:isDark?"#1e2d1e":"#f0fdf4",
|
||
border:"1px solid #86efac",borderRadius:10,
|
||
boxShadow:"0 4px 20px rgba(0,0,0,0.15)",
|
||
padding:"12px 18px",display:"flex",alignItems:"center",gap:10,
|
||
fontFamily:FONT,minWidth:260,
|
||
animation:"slideInRight 0.25s ease"}}>
|
||
<div style={{width:30,height:30,borderRadius:"50%",background:"#22c55e",
|
||
display:"flex",alignItems:"center",justifyContent:"center",flexShrink:0}}>
|
||
<i className="ti ti-check" style={{fontSize:16,color:"#fff"}} aria-hidden="true"/>
|
||
</div>
|
||
<div style={{flex:1,minWidth:0}}>
|
||
<div style={{fontSize:13.5,fontWeight:700,color:"#15803d"}}>Message sent</div>
|
||
<div style={{fontSize:12,color:"#16a34a",overflow:"hidden",textOverflow:"ellipsis",whiteSpace:"nowrap"}}>
|
||
To: {sentToast.to}
|
||
</div>
|
||
</div>
|
||
<button onClick={()=>setSentToast(null)}
|
||
style={{background:"transparent",border:"none",cursor:"pointer",color:"#86efac",padding:2,lineHeight:1}}>
|
||
<i className="ti ti-x" style={{fontSize:14}} aria-hidden="true"/>
|
||
</button>
|
||
</div>}
|
||
{contactsOpen&&<ContactsManager contacts={contacts} setContacts={setContacts} C={C} onClose={()=>setContactsOpen(false)}
|
||
onCompose={c=>{setCompose({mode:"new",fromEmail:selAccount?.email??selAccountId,initial:{to:c.name+" <"+c.email+">"}});setContactsOpen(false);}}/>}
|
||
{compose&&<ComposeModal {...compose} C={C} onClose={()=>setCompose(null)} onSend={handleSend} contacts={contacts}/>}
|
||
{rulesOpen&&<RulesPanel C={C} rules={rules} setRules={setRules} allFolders={allFolderNames}
|
||
updateAccount={updateAccount} onClose={()=>setRulesOpen(false)}/>}
|
||
{onboarding&&<OnboardingWizard accountId={onboarding.accountId} C={C} onComplete={()=>setOnboarding(null)}/>}
|
||
{fullSettings&&<FullSettings profile={activeProfile} accounts={ACCOUNTS} C={C}
|
||
onSave={saveProfile} onClose={()=>setFullSettings(false)}
|
||
onLiveChange={handleLiveTheme}
|
||
smbConfig={smbConfig} setSmbConfig={setSmbConfig}
|
||
rules={rules} setRules={setRules} allFolders={allFolderNames}
|
||
updateAccount={updateAccount}
|
||
loginBg={loginBg} setLoginBg={setLoginBg}
|
||
loginDark={loginDark} setLoginDark={setLoginDark}
|
||
onSetupNew={()=>{setFullSettings(false);setWizardOpen(true);}}/>}
|
||
|
||
{/* ── New folder discovery banner ── */}
|
||
{newFolderBanner&&!wizardOpen&&<div style={{position:"fixed",top:0,left:0,right:0,zIndex:500,
|
||
background:"#0d3318",borderBottom:"1px solid rgba(34,197,94,0.28)",
|
||
display:"flex",alignItems:"center",gap:12,padding:"10px 20px",fontFamily:FONT}}>
|
||
<i className="ti ti-folder-plus" style={{fontSize:17,color:"#4ade80",flexShrink:0}} aria-hidden="true"/>
|
||
<span style={{fontSize:13,color:"#d1fae5",flex:1,lineHeight:1.4}}>
|
||
<strong style={{color:"#86efac"}}>{newFolderBanner.count} new email folder</strong> found on your mail server
|
||
{" — "}<span style={{color:"#6ee7b7",fontFamily:"monospace",fontSize:12.5}}>{newFolderBanner.folder}</span>
|
||
</span>
|
||
<button onClick={()=>{setNewFolderBanner(null);setWizardOpen(true);}}
|
||
style={{padding:"5px 14px",border:"none",borderRadius:6,background:"#22c55e",
|
||
color:"#fff",fontSize:12.5,fontWeight:600,cursor:"pointer",fontFamily:FONT,flexShrink:0,
|
||
display:"flex",alignItems:"center",gap:6}}>
|
||
<i className="ti ti-settings-plus" style={{fontSize:13}} aria-hidden="true"/>Set up
|
||
</button>
|
||
<button onClick={()=>setNewFolderBanner(null)}
|
||
style={{background:"transparent",border:"none",color:"rgba(209,250,229,0.45)",
|
||
cursor:"pointer",padding:"2px 4px",fontSize:18,lineHeight:1,flexShrink:0}}
|
||
title="Dismiss">×</button>
|
||
</div>}
|
||
|
||
{/* ── In-app setup wizard overlay (discover mode) ── */}
|
||
{wizardOpen&&<div style={{position:"fixed",inset:0,zIndex:600,background:"#0c0c0c",
|
||
display:"flex",flexDirection:"column"}}>
|
||
<SetupWizard onComplete={()=>setWizardOpen(false)} mode="discover" prefillSmb={smbConfig}/>
|
||
</div>}
|
||
</div>;
|
||
}
|
||
|
||
function FolderItem({icon,label,badge,selected,C,onClick,indent=8,collapsed=false}){
|
||
const[h,setH]=useState(false);
|
||
return <div onClick={onClick} onMouseEnter={()=>setH(true)} onMouseLeave={()=>setH(false)}
|
||
title={collapsed?label:undefined}
|
||
style={{display:"flex",alignItems:"center",gap:8,padding:collapsed?"6px 0":`6px 8px 6px ${indent}px`,borderRadius:6,cursor:"pointer",marginBottom:1,justifyContent:collapsed?"center":"flex-start",
|
||
background:selected?C.selectedBg:h?C.hover:"transparent",borderLeft:collapsed?"3px solid transparent":`3px solid ${selected?C.accent:"transparent"}`}}>
|
||
<i className={`ti ${icon}`} style={{fontSize:collapsed?17:14,color:selected?C.accent:C.muted,width:collapsed?24:18,textAlign:"center"}} aria-hidden="true"/>
|
||
{!collapsed&&<><span style={{flex:1,fontSize:13,color:selected?C.accent:C.text,fontWeight:selected?600:400}}>{label}</span>
|
||
{badge>0&&<span style={{background:C.accent,color:"#fff",fontSize:10.5,fontWeight:700,borderRadius:10,padding:"1px 6px"}}>{badge}</span>}</>}
|
||
</div>;
|
||
}
|
||
|