邮件收件箱
三栏式邮件客户端,包含文件夹导航、邮件列表、详情阅读和标签分类。
完整模板源代码
查看 440 行代码
"use client";
export const dynamic = "force-static";
import { useState } from "react";
import {
Archive,
ArrowLeft,
Edit,
Forward,
Inbox,
Mail,
Menu,
MoreHorizontal,
Paperclip,
Reply,
Search,
Send,
Star,
Trash2,
X,
} from "@/components/IconShim";
import { TemplateBackLink } from "@/components/templates/TemplateBackLink";
interface Email {
id: number;
from: string;
fromEmail: string;
avatar: string;
subject: string;
preview: string;
body: string;
time: string;
date: string;
read: boolean;
starred: boolean;
labels: string[];
hasAttachment: boolean;
}
const emails: Email[] = [
{
id: 1,
from: "GitHub",
fromEmail: "noreply@github.com",
avatar: "GH",
subject: "[ui-library] Pull Request #142: Add calendar template",
preview:
"mergify[bot] approved this pull request. Changes look good, merging automatically...",
body: "mergify[bot] approved this pull request.\n\nChanges look good, merging automatically.\n\nAll CI checks have passed:\n- Build: passed\n- Lint: passed\n- Type Check: passed\n- Unit Tests: 48/48 passed\n\nThis PR will be merged in approximately 2 minutes.",
time: "10:24 AM",
date: "Feb 18, 2026",
read: false,
starred: false,
labels: ["dev"],
hasAttachment: false,
},
{
id: 2,
from: "Sarah Chen",
fromEmail: "sarah.chen@company.com",
avatar: "SC",
subject: "Re: Q1 Design System Roadmap",
preview:
"I have updated the timeline based on our discussion. The component library milestone...",
body: "Hi team,\n\nI have updated the timeline based on our discussion. The component library milestone has been moved to March 15.\n\nKey changes:\n- Icon system: Feb 28 (on track)\n- Component library v2: Mar 15 (pushed back 1 week)\n- Documentation site: Mar 30\n- Design tokens migration: Apr 15\n\nPlease review and let me know if there are any concerns.\n\nBest,\nSarah",
time: "9:45 AM",
date: "Feb 18, 2026",
read: false,
starred: true,
labels: ["work"],
hasAttachment: true,
},
{
id: 3,
from: "Vercel",
fromEmail: "notifications@vercel.com",
avatar: "VC",
subject: "Deployment successful: ui-library-main-abc123",
preview:
"Your project ui-library has been deployed successfully to production...",
body: "Your project ui-library has been deployed successfully to production.\n\nDeployment URL: https://luckymaomi.github.io/ui/\nBranch: main\nCommit: abc123f - feat: add calendar template\nBuild Time: 42s\nRegion: iad1",
time: "9:12 AM",
date: "Feb 18, 2026",
read: true,
starred: false,
labels: ["dev"],
hasAttachment: false,
},
{
id: 4,
from: "Alex Rivera",
fromEmail: "alex@rivera.dev",
avatar: "AR",
subject: "Conference talk proposal feedback",
preview:
"Hey! I reviewed your talk proposal for ReactConf. The outline is solid but I think...",
body: "Hey!\n\nI reviewed your talk proposal for ReactConf. The outline is solid but I think you should expand on the performance section.\n\nSpecifically:\n1. Add concrete benchmarks comparing the old vs new approach\n2. Include a live demo if possible\n3. The section about Server Components could use a real-world example\n\nOtherwise, the narrative flow is great. You should definitely submit it.\n\nCheers,\nAlex",
time: "Yesterday",
date: "Feb 17, 2026",
read: true,
starred: false,
labels: [],
hasAttachment: false,
},
{
id: 5,
from: "Linear",
fromEmail: "notifications@linear.app",
avatar: "LN",
subject: "[STYLE-284] Bug: Dark mode toggle not persisting",
preview:
"Issue STYLE-284 has been assigned to you. Priority: High. Reporter: Mika Tanaka...",
body: "Issue STYLE-284 has been assigned to you.\n\nPriority: High\nReporter: Mika Tanaka\nLabel: Bug\n\nDescription:\nThe dark mode toggle on the settings page does not persist across page reloads. The theme resets to light mode every time.\n\nSteps to reproduce:\n1. Go to Settings > Appearance\n2. Toggle dark mode on\n3. Refresh the page\n4. Theme reverts to light mode\n\nExpected: Theme should persist via localStorage.",
time: "Yesterday",
date: "Feb 17, 2026",
read: true,
starred: true,
labels: ["dev"],
hasAttachment: false,
},
{
id: 6,
from: "Newsletter",
fromEmail: "weekly@bytes.dev",
avatar: "NL",
subject: "This Week in Web Dev: CSS @starting-style, Bun 1.5",
preview:
"CSS @starting-style is now supported in all major browsers. Plus: Bun 1.5 drops with...",
body: "CSS @starting-style is now supported in all major browsers.\n\nPlus: Bun 1.5 drops with native S3 support, the state of JavaScript 2025 survey results are out, and React 20 is on the horizon.\n\nRead the full newsletter at bytes.dev",
time: "Feb 16",
date: "Feb 16, 2026",
read: true,
starred: false,
labels: [],
hasAttachment: false,
},
];
const labelColors: Record<string, string> = {
dev: "bg-blue-100 text-blue-700",
work: "bg-green-100 text-green-700",
personal: "bg-purple-100 text-purple-700",
};
const folders = [
{ icon: Inbox, label: "Inbox", count: 2 },
{ icon: Star, label: "Starred", count: 2 },
{ icon: Send, label: "Sent", count: 0 },
{ icon: Edit, label: "Drafts", count: 1 },
{ icon: Archive, label: "Archive", count: 0 },
{ icon: Trash2, label: "Trash", count: 0 },
];
export default function EmailInboxTemplate() {
const [selectedId, setSelectedId] = useState<number | null>(null);
const [emailList, setEmailList] = useState(emails);
const [sidebarOpen, setSidebarOpen] = useState(false);
const [searchQuery, setSearchQuery] = useState("");
const selectedEmail = emailList.find((e) => e.id === selectedId);
const toggleStar = (id: number, e: React.MouseEvent) => {
e.stopPropagation();
setEmailList((prev) =>
prev.map((em) => (em.id === id ? { ...em, starred: !em.starred } : em)),
);
};
const markRead = (id: number) => {
setEmailList((prev) =>
prev.map((em) => (em.id === id ? { ...em, read: true } : em)),
);
};
const filtered = emailList.filter(
(e) =>
e.subject.toLowerCase().includes(searchQuery.toLowerCase()) ||
e.from.toLowerCase().includes(searchQuery.toLowerCase()),
);
return (
<div className="h-screen flex bg-gray-50 overflow-hidden">
{/* Mobile Sidebar */}
{sidebarOpen && (
<div className="fixed inset-0 z-50 md:hidden">
<div
className="absolute inset-0 bg-black/40"
role="presentation"
onClick={() => setSidebarOpen(false)}
/>
<aside className="absolute left-0 top-0 bottom-0 w-64 bg-white shadow-xl flex flex-col">
<div className="flex items-center justify-between p-4 border border-gray-100">
<span className="font-bold">Mail</span>
<button onClick={() => setSidebarOpen(false)} aria-label="Close">
<X className="w-5 h-5 text-gray-400" />
</button>
</div>
<FolderList />
</aside>
</div>
)}
{/* Desktop Sidebar */}
<aside className="hidden md:flex w-56 lg:w-64 shrink-0 bg-white border border-gray-200 flex-col sticky top-0 h-screen">
<div className="p-4 border border-gray-100">
<div className="flex items-center gap-2">
<Mail className="w-5 h-5 text-blue-600" />
<span className="text-lg font-bold text-gray-900">MailKit</span>
</div>
</div>
<button className="mx-4 mt-4 flex items-center justify-center gap-2 px-4 py-2.5 bg-blue-600 text-white text-sm font-medium rounded-lg hover:bg-blue-700 transition-colors">
<Edit className="w-4 h-4" />
Compose
</button>
<FolderList />
</aside>
{/* Email List */}
<div
className={`${
selectedEmail ? "hidden md:flex" : "flex"
} flex-col w-full md:w-80 lg:w-96 bg-white border border-gray-200 shrink-0 h-screen`}
>
<div className="p-3 border border-gray-200 space-y-2">
<div className="flex items-center gap-2">
<button
className="md:hidden p-2 hover:bg-gray-100 rounded-lg"
onClick={() => setSidebarOpen(true)}
aria-label="Open folders"
>
<Menu className="w-5 h-5" />
</button>
<h2 className="font-semibold text-gray-900">Inbox</h2>
<span className="text-xs text-gray-400 ml-auto">
{emailList.filter((e) => !e.read).length} unread
</span>
</div>
<div className="relative">
<Search className="absolute left-3 top-1/2 -translate-y-1/2 w-3.5 h-3.5 text-gray-400" />
<input
type="text"
placeholder="Search mail..."
value={searchQuery}
onChange={(e) => setSearchQuery(e.target.value)}
className="w-full pl-9 pr-3 py-2 text-sm bg-gray-100 rounded-lg outline-none focus:ring-2 focus:ring-blue-200 transition-all"
/>
</div>
</div>
<div className="flex-1 overflow-y-auto">
{filtered.map((email) => (
<button
key={email.id}
onClick={() => {
setSelectedId(email.id);
markRead(email.id);
}}
className={`w-full text-left px-4 py-3 border border-gray-100 hover:bg-gray-50 transition-colors ${
email.id === selectedId ? "bg-blue-50" : ""
} ${!email.read ? "bg-blue-50/50" : ""}`}
>
<div className="flex items-start gap-3">
<div className="w-9 h-9 rounded-full bg-gradient-to-br from-blue-400 to-indigo-500 flex items-center justify-center text-white text-xs font-bold shrink-0 mt-0.5">
{email.avatar}
</div>
<div className="flex-1 min-w-0">
<div className="flex items-center justify-between gap-2">
<span
className={`text-sm truncate ${!email.read ? "font-bold text-gray-900" : "text-gray-700"}`}
>
{email.from}
</span>
<span className="text-[11px] text-gray-400 shrink-0">
{email.time}
</span>
</div>
<p
className={`text-sm truncate mt-0.5 ${!email.read ? "font-semibold text-gray-900" : "text-gray-600"}`}
>
{email.subject}
</p>
<p className="text-xs text-gray-400 truncate mt-0.5">
{email.preview}
</p>
<div className="flex items-center gap-2 mt-1.5">
{email.labels.map((label) => (
<span
key={label}
className={`text-[10px] px-1.5 py-0.5 rounded font-medium ${labelColors[label] || "bg-gray-100 text-gray-600"}`}
>
{label}
</span>
))}
{email.hasAttachment && (
<Paperclip className="w-3 h-3 text-gray-400" />
)}
<button
onClick={(e) => toggleStar(email.id, e)}
className="ml-auto"
>
<Star
className={`w-3.5 h-3.5 ${email.starred ? "text-yellow-500 fill-yellow-500" : "text-gray-300"}`}
/>
</button>
</div>
</div>
</div>
</button>
))}
</div>
</div>
{/* Email Detail */}
<main
className={`${selectedEmail ? "flex" : "hidden md:flex"} flex-col flex-1 min-w-0 bg-white h-screen`}
>
{selectedEmail ? (
<>
<div className="px-4 md:px-6 py-3 border border-gray-200 flex items-center gap-2">
<button
className="md:hidden p-2 hover:bg-gray-100 rounded-lg"
onClick={() => setSelectedId(null)}
aria-label="Back"
>
<ArrowLeft className="w-4 h-4" />
</button>
<div className="flex-1" />
<button className="p-2 hover:bg-gray-100 rounded-lg text-gray-500">
<Archive className="w-4 h-4" />
</button>
<button className="p-2 hover:bg-gray-100 rounded-lg text-gray-500">
<Trash2 className="w-4 h-4" />
</button>
<button className="p-2 hover:bg-gray-100 rounded-lg text-gray-500">
<MoreHorizontal className="w-4 h-4" />
</button>
</div>
<div className="flex-1 overflow-y-auto px-4 md:px-6 py-6">
<h1 className="text-xl font-bold text-gray-900 mb-4">
{selectedEmail.subject}
</h1>
<div className="flex items-start gap-3 mb-6">
<div className="w-10 h-10 rounded-full bg-gradient-to-br from-blue-400 to-indigo-500 flex items-center justify-center text-white font-bold text-sm shrink-0">
{selectedEmail.avatar}
</div>
<div className="flex-1 min-w-0">
<div className="flex items-center gap-2">
<span className="font-semibold text-sm">
{selectedEmail.from}
</span>
<span className="text-xs text-gray-400">
<{selectedEmail.fromEmail}>
</span>
</div>
<div className="text-xs text-gray-400 mt-0.5">
{selectedEmail.date}
</div>
</div>
<button
onClick={(e) => toggleStar(selectedEmail.id, e)}
className="p-1"
>
<Star
className={`w-4 h-4 ${selectedEmail.starred ? "text-yellow-500 fill-yellow-500" : "text-gray-300"}`}
/>
</button>
</div>
<div className="prose prose-sm max-w-none text-gray-700 whitespace-pre-wrap leading-relaxed">
{selectedEmail.body}
</div>
</div>
<div className="px-4 md:px-6 py-3 border border-gray-200 flex gap-2">
<button className="flex items-center gap-2 px-4 py-2 text-sm border border-gray-200 rounded-lg hover:bg-gray-50 transition-colors">
<Reply className="w-4 h-4" />
Reply
</button>
<button className="flex items-center gap-2 px-4 py-2 text-sm border border-gray-200 rounded-lg hover:bg-gray-50 transition-colors">
<Forward className="w-4 h-4" />
Forward
</button>
</div>
</>
) : (
<div className="flex-1 flex items-center justify-center">
<div className="text-center text-gray-400">
<Mail className="w-12 h-12 mx-auto mb-3 text-gray-300" />
<p className="text-sm">Select an email to read</p>
</div>
</div>
)}
</main>
<TemplateBackLink />
</div>
);
}
function FolderList() {
return (
<nav className="flex-1 p-3 space-y-0.5">
{folders.map((folder, i) => (
<button
key={folder.label}
className={`w-full flex items-center gap-3 px-3 py-2 rounded-lg text-sm transition-colors ${
i === 0
? "bg-blue-50 text-blue-700 font-medium"
: "text-gray-600 hover:bg-gray-100"
}`}
>
<folder.icon className="w-4 h-4" />
<span className="flex-1 text-left">{folder.label}</span>
{folder.count > 0 && (
<span
className={`text-xs ${i === 0 ? "text-blue-600 font-semibold" : "text-gray-400"}`}
>
{folder.count}
</span>
)}
</button>
))}
<div className="pt-3 mt-3 border border-gray-100">
<p className="text-xs font-medium text-gray-400 uppercase tracking-wider px-3 mb-2">
Labels
</p>
{[
{ name: "dev", color: "bg-blue-500" },
{ name: "work", color: "bg-green-500" },
{ name: "personal", color: "bg-purple-500" },
].map((label) => (
<button
key={label.name}
className="w-full flex items-center gap-3 px-3 py-2 text-sm text-gray-600 hover:bg-gray-100 rounded-lg transition-colors"
>
<span className={`w-2.5 h-2.5 rounded-full ${label.color}`} />
{label.name}
</button>
))}
</div>
</nav>
);
}