feat: Init

This commit is contained in:
beilunyang
2024-12-16 01:35:08 +08:00
commit cc7e5003c5
73 changed files with 15001 additions and 0 deletions

View File

@@ -0,0 +1,150 @@
"use client"
import { useState } from "react"
import { Button } from "@/components/ui/button"
import { Input } from "@/components/ui/input"
import { Dialog, DialogContent, DialogHeader, DialogTitle, DialogTrigger } from "@/components/ui/dialog"
import { Plus, RefreshCw } from "lucide-react"
import { useToast } from "@/components/ui/use-toast"
import { nanoid } from "nanoid"
import { RadioGroup, RadioGroupItem } from "@/components/ui/radio-group"
import { Label } from "@/components/ui/label"
import { EXPIRY_OPTIONS } from "@/types/email"
import { EMAIL_CONFIG } from "@/config"
interface CreateDialogProps {
onEmailCreated: () => void
}
export function CreateDialog({ onEmailCreated }: CreateDialogProps) {
const [open, setOpen] = useState(false)
const [loading, setLoading] = useState(false)
const [emailName, setEmailName] = useState("")
const [expiryTime, setExpiryTime] = useState(EXPIRY_OPTIONS[1].value.toString()) // Default to 24 hours
const { toast } = useToast()
const generateRandomName = () => setEmailName(nanoid(8))
const createEmail = async () => {
if (!emailName.trim()) {
toast({
title: "错误",
description: "请输入邮箱名",
variant: "destructive"
})
return
}
setLoading(true)
try {
const response = await fetch("/api/emails/generate", {
method: "POST",
headers: { "Content-Type": "application/json" },
body: JSON.stringify({
name: emailName,
expiryTime: parseInt(expiryTime) // 确保转换为数字
})
})
if (response.status === 409) {
toast({
title: "错误",
description: "该邮箱名已被使用",
variant: "destructive"
})
return
}
if (response.status === 403) {
toast({
title: "错误",
description: "已达到最大邮箱数量限制",
variant: "destructive"
})
return
}
if (!response.ok) throw new Error("Failed to create email")
toast({
title: "成功",
description: "已创建新的临时邮箱"
})
onEmailCreated()
setOpen(false)
setEmailName("")
} catch {
toast({
title: "错误",
description: "创建邮箱失败",
variant: "destructive"
})
} finally {
setLoading(false)
}
}
return (
<Dialog open={open} onOpenChange={setOpen}>
<DialogTrigger asChild>
<Button className="gap-2">
<Plus className="w-4 h-4" />
</Button>
</DialogTrigger>
<DialogContent>
<DialogHeader>
<DialogTitle></DialogTitle>
</DialogHeader>
<div className="space-y-4 py-4">
<div className="flex gap-2">
<Input
value={emailName}
onChange={(e) => setEmailName(e.target.value)}
placeholder="输入邮箱名"
className="flex-1"
/>
<Button
variant="outline"
size="icon"
onClick={generateRandomName}
type="button"
>
<RefreshCw className="w-4 h-4" />
</Button>
</div>
<div className="flex items-center gap-4">
<Label className="shrink-0 text-muted-foreground"></Label>
<RadioGroup
value={expiryTime}
onValueChange={setExpiryTime}
className="flex gap-6"
>
{EXPIRY_OPTIONS.map((option) => (
<div key={option.value} className="flex items-center gap-2">
<RadioGroupItem value={option.value.toString()} id={option.value.toString()} />
<Label htmlFor={option.value.toString()} className="cursor-pointer text-sm">
{option.label}
</Label>
</div>
))}
</RadioGroup>
</div>
<div className="text-sm text-gray-500">
: {emailName ? `${emailName}@${EMAIL_CONFIG.DOMAIN}` : "..."}
</div>
</div>
<div className="flex justify-end gap-2">
<Button variant="outline" onClick={() => setOpen(false)} disabled={loading}>
</Button>
<Button onClick={createEmail} disabled={loading}>
{loading ? "创建中..." : "创建"}
</Button>
</div>
</DialogContent>
</Dialog>
)
}

View File

@@ -0,0 +1,162 @@
"use client"
import { useEffect, useState } from "react"
import { useSession } from "next-auth/react"
import { CreateDialog } from "./create-dialog"
import { Mail, RefreshCw } from "lucide-react"
import { cn } from "@/lib/utils"
import { Button } from "@/components/ui/button"
import { useThrottle } from "@/hooks/use-throttle"
import { EMAIL_CONFIG } from "@/config"
interface Email {
id: string
address: string
createdAt: number
expiresAt: number
}
interface EmailListProps {
onEmailSelect: (email: Email) => void
selectedEmailId?: string
}
interface EmailResponse {
emails: Email[]
nextCursor: string | null
total: number
}
export function EmailList({ onEmailSelect, selectedEmailId }: EmailListProps) {
const { data: session } = useSession()
const [emails, setEmails] = useState<Email[]>([])
const [loading, setLoading] = useState(true)
const [refreshing, setRefreshing] = useState(false)
const [nextCursor, setNextCursor] = useState<string | null>(null)
const [loadingMore, setLoadingMore] = useState(false)
const [total, setTotal] = useState(0)
const fetchEmails = async (cursor?: string) => {
try {
const url = new URL("/api/emails", window.location.origin)
if (cursor) {
url.searchParams.set('cursor', cursor)
}
const response = await fetch(url)
const data = await response.json() as EmailResponse
if (!cursor) {
const newEmails = data.emails
const oldEmails = emails
const lastDuplicateIndex = newEmails.findIndex(
newEmail => oldEmails.some(oldEmail => oldEmail.id === newEmail.id)
)
if (lastDuplicateIndex === -1) {
setEmails(newEmails)
setNextCursor(data.nextCursor)
setTotal(data.total)
return
}
const uniqueNewEmails = newEmails.slice(0, lastDuplicateIndex)
setEmails([...uniqueNewEmails, ...oldEmails])
setTotal(data.total)
return
}
setEmails(prev => [...prev, ...data.emails])
setNextCursor(data.nextCursor)
setTotal(data.total)
} catch (error) {
console.error("Failed to fetch emails:", error)
} finally {
setLoading(false)
setRefreshing(false)
setLoadingMore(false)
}
}
const handleRefresh = async () => {
setRefreshing(true)
await fetchEmails()
}
const handleScroll = useThrottle((e: React.UIEvent<HTMLDivElement>) => {
if (loadingMore) return
const { scrollHeight, scrollTop, clientHeight } = e.currentTarget
const threshold = clientHeight * 1.5
const remainingScroll = scrollHeight - scrollTop
if (remainingScroll <= threshold && nextCursor) {
setLoadingMore(true)
fetchEmails(nextCursor)
}
}, 200)
useEffect(() => {
if (session) fetchEmails()
// eslint-disable-next-line react-hooks/exhaustive-deps
}, [session])
if (!session) return null
return (
<div className="flex flex-col h-full">
<div className="p-2 flex justify-between items-center border-b border-primary/20">
<div className="flex items-center gap-2">
<Button
variant="ghost"
size="icon"
onClick={handleRefresh}
disabled={refreshing}
className={cn("h-8 w-8", refreshing && "animate-spin")}
>
<RefreshCw className="h-4 w-4" />
</Button>
<span className="text-xs text-gray-500">
{total}/{EMAIL_CONFIG.MAX_ACTIVE_EMAILS}
</span>
</div>
<CreateDialog onEmailCreated={handleRefresh} />
</div>
<div className="flex-1 overflow-auto p-2" onScroll={handleScroll}>
{loading ? (
<div className="text-center text-sm text-gray-500">...</div>
) : emails.length > 0 ? (
<div className="space-y-1">
{emails.map(email => (
<div
key={email.id}
onClick={() => onEmailSelect(email)}
className={cn(
"flex items-center gap-2 p-2 rounded cursor-pointer text-sm",
"hover:bg-primary/5",
selectedEmailId === email.id && "bg-primary/10"
)}
>
<Mail className="w-4 h-4 text-primary/60" />
<div className="truncate flex-1">
<div className="font-medium truncate">{email.address}</div>
<div className="text-xs text-gray-500">
: {new Date(email.expiresAt).toLocaleString()}
</div>
</div>
</div>
))}
{loadingMore && (
<div className="text-center text-sm text-gray-500 py-2">
...
</div>
)}
</div>
) : (
<div className="text-center text-sm text-gray-500">
</div>
)}
</div>
</div>
)
}

View File

@@ -0,0 +1,196 @@
"use client"
import { useState, useEffect, useRef } from "react"
import { Mail, Calendar, RefreshCw } from "lucide-react"
import { cn } from "@/lib/utils"
import { Button } from "@/components/ui/button"
import { useThrottle } from "@/hooks/use-throttle"
import { EMAIL_CONFIG } from "@/config"
interface Message {
id: string
from_address: string
subject: string
received_at: number
}
interface MessageListProps {
email: {
id: string
address: string
}
onMessageSelect: (messageId: string) => void
selectedMessageId?: string | null
}
interface MessageResponse {
messages: Message[]
nextCursor: string | null
total: number
}
export function MessageList({ email, onMessageSelect, selectedMessageId }: MessageListProps) {
const [messages, setMessages] = useState<Message[]>([])
const [loading, setLoading] = useState(true)
const [refreshing, setRefreshing] = useState(false)
const [nextCursor, setNextCursor] = useState<string | null>(null)
const [loadingMore, setLoadingMore] = useState(false)
const pollTimeoutRef = useRef<NodeJS.Timeout>()
const messagesRef = useRef<Message[]>([]) // 添加 ref 来追踪最新的消息列表
const [total, setTotal] = useState(0)
// 当 messages 改变时更新 ref
useEffect(() => {
messagesRef.current = messages
}, [messages])
const fetchMessages = async (cursor?: string) => {
try {
const url = new URL(`/api/emails/${email.id}`, window.location.origin)
if (cursor) {
url.searchParams.set('cursor', cursor)
}
const response = await fetch(url)
const data = await response.json() as MessageResponse
if (!cursor) {
const newMessages = data.messages
const oldMessages = messagesRef.current
const lastDuplicateIndex = newMessages.findIndex(
newMsg => oldMessages.some(oldMsg => oldMsg.id === newMsg.id)
)
if (lastDuplicateIndex === -1) {
setMessages(newMessages)
setNextCursor(data.nextCursor)
setTotal(data.total)
return
}
const uniqueNewMessages = newMessages.slice(0, lastDuplicateIndex)
setMessages([...uniqueNewMessages, ...oldMessages])
setTotal(data.total)
return
}
setMessages(prev => [...prev, ...data.messages])
setNextCursor(data.nextCursor)
setTotal(data.total)
} catch (error) {
console.error("Failed to fetch messages:", error)
} finally {
setLoading(false)
setRefreshing(false)
setLoadingMore(false)
}
}
const startPolling = () => {
stopPolling() // 先清除之前的轮询
pollTimeoutRef.current = setInterval(() => {
if (!refreshing && !loadingMore) {
fetchMessages()
}
}, EMAIL_CONFIG.POLL_INTERVAL)
}
const stopPolling = () => {
if (pollTimeoutRef.current) {
clearInterval(pollTimeoutRef.current)
pollTimeoutRef.current = undefined
}
}
const handleRefresh = async () => {
setRefreshing(true)
await fetchMessages()
}
const handleScroll = useThrottle((e: React.UIEvent<HTMLDivElement>) => {
if (loadingMore) return
const { scrollHeight, scrollTop, clientHeight } = e.currentTarget
const threshold = clientHeight * 1.5
const remainingScroll = scrollHeight - scrollTop
if (remainingScroll <= threshold && nextCursor) {
setLoadingMore(true)
fetchMessages(nextCursor)
}
}, 200)
useEffect(() => {
if (!email.id) {
return
}
setLoading(true)
setNextCursor(null)
fetchMessages()
startPolling()
return () => {
stopPolling()
}
// eslint-disable-next-line react-hooks/exhaustive-deps
}, [email.id])
return (
<div className="h-full flex flex-col">
<div className="p-2 flex justify-between items-center border-b border-primary/20">
<Button
variant="ghost"
size="icon"
onClick={handleRefresh}
disabled={refreshing}
className={cn("h-8 w-8", refreshing && "animate-spin")}
>
<RefreshCw className="h-4 w-4" />
</Button>
<span className="text-xs text-gray-500">
{total > 0 ? `${total} 封邮件` : "暂无邮件"}
</span>
</div>
<div className="flex-1 overflow-auto" onScroll={handleScroll}>
{loading ? (
<div className="p-4 text-center text-sm text-gray-500">...</div>
) : messages.length > 0 ? (
<div className="divide-y divide-primary/10">
{messages.map(message => (
<div
key={message.id}
onClick={() => onMessageSelect(message.id)}
className={cn(
"p-3 hover:bg-primary/5 cursor-pointer",
selectedMessageId === message.id && "bg-primary/10"
)}
>
<div className="flex items-start gap-3">
<Mail className="w-4 h-4 text-primary/60 mt-1" />
<div className="min-w-0 flex-1">
<p className="font-medium text-sm truncate">{message.subject}</p>
<div className="mt-1 flex items-center gap-2 text-xs text-gray-500">
<span className="truncate">{message.from_address}</span>
<span className="flex items-center gap-1">
<Calendar className="w-3 h-3" />
{new Date(message.received_at).toLocaleString()}
</span>
</div>
</div>
</div>
</div>
))}
{loadingMore && (
<div className="text-center text-sm text-gray-500 py-2">
...
</div>
)}
</div>
) : (
<div className="p-4 text-center text-sm text-gray-500">
</div>
)}
</div>
</div>
)
}

View File

@@ -0,0 +1,208 @@
"use client"
import { useState, useEffect, useRef } from "react"
import { Loader2 } from "lucide-react"
import { RadioGroup, RadioGroupItem } from "@/components/ui/radio-group"
import { Label } from "@/components/ui/label"
interface Message {
id: string
from_address: string
subject: string
content: string
html: string | null
received_at: number
}
interface MessageViewProps {
emailId: string
messageId: string
onClose: () => void
}
type ViewMode = "html" | "text"
export function MessageView({ emailId, messageId }: MessageViewProps) {
const [message, setMessage] = useState<Message | null>(null)
const [loading, setLoading] = useState(true)
const [viewMode, setViewMode] = useState<ViewMode>("html")
const iframeRef = useRef<HTMLIFrameElement>(null)
useEffect(() => {
const fetchMessage = async () => {
try {
const response = await fetch(`/api/emails/${emailId}/${messageId}`)
const data = await response.json() as { message: Message }
setMessage(data.message)
if (!data.message.html) {
setViewMode("text")
}
} catch (error) {
console.error("Failed to fetch message:", error)
} finally {
setLoading(false)
}
}
fetchMessage()
}, [emailId, messageId])
// 处理 iframe 内容
useEffect(() => {
if (viewMode === "html" && message?.html && iframeRef.current) {
const iframe = iframeRef.current
const doc = iframe.contentDocument || iframe.contentWindow?.document
if (doc) {
doc.open()
doc.write(`
<!DOCTYPE html>
<html>
<head>
<base target="_blank">
<style>
html, body {
margin: 0;
padding: 0;
min-height: 100%;
font-family: system-ui, -apple-system, sans-serif;
color: ${document.documentElement.classList.contains('dark') ? '#fff' : '#000'};
background: transparent;
}
body {
padding: 20px;
}
img {
max-width: 100%;
height: auto;
}
a {
color: #2563eb;
}
/* 滚动条样式 */
::-webkit-scrollbar {
width: 6px;
height: 6px;
}
::-webkit-scrollbar-track {
background: transparent;
}
::-webkit-scrollbar-thumb {
background: ${document.documentElement.classList.contains('dark')
? 'rgba(130, 109, 217, 0.3)'
: 'rgba(130, 109, 217, 0.2)'};
border-radius: 9999px;
transition: background-color 0.2s;
}
::-webkit-scrollbar-thumb:hover {
background: ${document.documentElement.classList.contains('dark')
? 'rgba(130, 109, 217, 0.5)'
: 'rgba(130, 109, 217, 0.4)'};
}
/* Firefox 滚动条 */
* {
scrollbar-width: thin;
scrollbar-color: ${document.documentElement.classList.contains('dark')
? 'rgba(130, 109, 217, 0.3) transparent'
: 'rgba(130, 109, 217, 0.2) transparent'};
}
</style>
</head>
<body>${message.html}</body>
</html>
`)
doc.close()
// 更新高度以填充容器
const updateHeight = () => {
const container = iframe.parentElement
if (container) {
iframe.style.height = `${container.clientHeight}px`
}
}
updateHeight()
window.addEventListener('resize', updateHeight)
// 监听内容变化
const resizeObserver = new ResizeObserver(updateHeight)
resizeObserver.observe(doc.body)
// 监听图片加载
doc.querySelectorAll('img').forEach((img: HTMLImageElement) => {
img.onload = updateHeight
})
return () => {
window.removeEventListener('resize', updateHeight)
resizeObserver.disconnect()
}
}
}
}, [message?.html, viewMode])
if (loading) {
return (
<div className="flex items-center justify-center h-32">
<Loader2 className="w-5 h-5 animate-spin text-primary/60" />
</div>
)
}
if (!message) return null
return (
<div className="h-full flex flex-col">
<div className="p-4 space-y-3 border-b border-primary/20">
<h3 className="text-base font-bold">{message.subject}</h3>
<div className="text-xs text-gray-500 space-y-1">
<p>{message.from_address}</p>
<p>{new Date(message.received_at).toLocaleString()}</p>
</div>
</div>
{message.html && (
<div className="border-b border-primary/20 p-2">
<RadioGroup
value={viewMode}
onValueChange={(value) => setViewMode(value as ViewMode)}
className="flex items-center gap-4"
>
<div className="flex items-center space-x-2">
<RadioGroupItem value="html" id="html" />
<Label
htmlFor="html"
className="text-xs cursor-pointer"
>
HTML
</Label>
</div>
<div className="flex items-center space-x-2">
<RadioGroupItem value="text" id="text" />
<Label
htmlFor="text"
className="text-xs cursor-pointer"
>
</Label>
</div>
</RadioGroup>
</div>
)}
<div className="flex-1 overflow-auto relative">
{viewMode === "html" && message.html ? (
<iframe
ref={iframeRef}
className="absolute inset-0 w-full h-full border-0 bg-transparent"
sandbox="allow-same-origin allow-popups"
/>
) : (
<div className="p-4 text-sm whitespace-pre-wrap">
{message.content}
</div>
)}
</div>
</div>
)
}

View File

@@ -0,0 +1,154 @@
"use client"
import { useState } from "react"
import { EmailList } from "./email-list"
import { MessageList } from "./message-list"
import { MessageView } from "./message-view"
import { cn } from "@/lib/utils"
interface Email {
id: string
address: string
}
export function ThreeColumnLayout() {
const [selectedEmail, setSelectedEmail] = useState<Email | null>(null)
const [selectedMessageId, setSelectedMessageId] = useState<string | null>(null)
const columnClass = "border-2 border-primary/20 bg-background rounded-lg overflow-hidden flex flex-col"
const headerClass = "p-2 border-b-2 border-primary/20 flex items-center justify-between shrink-0"
const titleClass = "text-sm font-bold px-2"
// 移动端视图逻辑
const getMobileView = () => {
if (selectedMessageId) return "message"
if (selectedEmail) return "emails"
return "list"
}
const mobileView = getMobileView()
return (
<div className="pb-5 pt-20 h-full flex flex-col">
{/* 桌面端三栏布局 */}
<div className="hidden lg:grid grid-cols-12 gap-4 h-full min-h-0">
<div className={cn("col-span-3", columnClass)}>
<div className={headerClass}>
<h2 className={titleClass}></h2>
</div>
<div className="flex-1 overflow-auto">
<EmailList
onEmailSelect={setSelectedEmail}
selectedEmailId={selectedEmail?.id}
/>
</div>
</div>
<div className={cn("col-span-4", columnClass)}>
<div className={headerClass}>
<h2 className={titleClass}>
{selectedEmail ? (
<span className="truncate block">{selectedEmail.address}</span>
) : (
"选择邮箱查看消息"
)}
</h2>
</div>
{selectedEmail && (
<div className="flex-1 overflow-auto">
<MessageList
email={selectedEmail}
onMessageSelect={setSelectedMessageId}
selectedMessageId={selectedMessageId}
/>
</div>
)}
</div>
<div className={cn("col-span-5", columnClass)}>
<div className={headerClass}>
<h2 className={titleClass}>
{selectedMessageId ? "邮件内容" : "选择邮件查看详情"}
</h2>
</div>
{selectedEmail && selectedMessageId && (
<div className="flex-1 overflow-auto">
<MessageView
emailId={selectedEmail.id}
messageId={selectedMessageId}
onClose={() => setSelectedMessageId(null)}
/>
</div>
)}
</div>
</div>
{/* 移动端单栏布局 */}
<div className="lg:hidden h-full min-h-0">
<div className={cn("h-full", columnClass)}>
{mobileView === "list" && (
<>
<div className={headerClass}>
<h2 className={titleClass}></h2>
</div>
<div className="flex-1 overflow-auto">
<EmailList
onEmailSelect={(email) => {
setSelectedEmail(email)
}}
selectedEmailId={selectedEmail?.id}
/>
</div>
</>
)}
{mobileView === "emails" && selectedEmail && (
<div className="h-full flex flex-col">
<div className={headerClass}>
<button
onClick={() => {
setSelectedEmail(null)
}}
className="text-sm text-primary"
>
</button>
<span className="text-sm font-medium truncate">
{selectedEmail.address}
</span>
</div>
<div className="flex-1 overflow-auto">
<MessageList
email={selectedEmail}
onMessageSelect={setSelectedMessageId}
selectedMessageId={selectedMessageId}
/>
</div>
</div>
)}
{mobileView === "message" && selectedEmail && selectedMessageId && (
<div className="h-full flex flex-col">
<div className={headerClass}>
<button
onClick={() => setSelectedMessageId(null)}
className="text-sm text-primary"
>
</button>
<span className="text-sm font-medium"></span>
</div>
<div className="flex-1 overflow-auto">
<MessageView
emailId={selectedEmail.id}
messageId={selectedMessageId}
onClose={() => setSelectedMessageId(null)}
/>
</div>
</div>
)}
</div>
</div>
</div>
)
}