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,44 @@
"use client"
import { Button } from "@/components/ui/button"
import Image from "next/image"
import { signIn, signOut, useSession } from "next-auth/react"
import { Github } from "lucide-react"
export function SignButton() {
const { data: session, status } = useSession()
const loading = status === "loading"
if (loading) {
return <div className="h-9" /> // 防止布局跳动
}
if (!session?.user) {
return (
<Button onClick={() => signIn("github", { callbackUrl: "/moe" })} className="gap-2">
<Github className="w-4 h-4" />
使 GitHub
</Button>
)
}
return (
<div className="flex items-center gap-4">
<div className="flex items-center gap-2">
{session.user.image && (
<Image
src={session.user.image}
alt={session.user.name || "用户头像"}
width={24}
height={24}
className="rounded-full"
/>
)}
<span className="text-sm">{session.user.name}</span>
</div>
<Button onClick={() => signOut({ callbackUrl: "/" })} variant="outline">
</Button>
</div>
)
}

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>
)
}

View File

@@ -0,0 +1,38 @@
"use client"
import { Button } from "@/components/ui/button"
import { Mail, Github } from "lucide-react"
import { useRouter } from "next/navigation"
import { signIn } from "next-auth/react"
interface ActionButtonProps {
isLoggedIn?: boolean
}
export function ActionButton({ isLoggedIn }: ActionButtonProps) {
const router = useRouter()
if (isLoggedIn) {
return (
<Button
size="lg"
onClick={() => router.push("/moe")}
className="gap-2 bg-primary hover:bg-primary/90 text-white px-8"
>
<Mail className="w-5 h-5" />
</Button>
)
}
return (
<Button
size="lg"
onClick={() => signIn("github", { callbackUrl: "/moe" })}
className="gap-2 bg-primary hover:bg-primary/90 text-white px-8"
>
<Github className="w-5 h-5" />
使 GitHub
</Button>
)
}

View File

@@ -0,0 +1,21 @@
interface FeatureCardProps {
icon: React.ReactNode
title: string
description: string
}
export function FeatureCard({ icon, title, description }: FeatureCardProps) {
return (
<div className="p-4 rounded border-2 border-primary/20 hover:border-primary/40 transition-colors bg-white/5 backdrop-blur">
<div className="flex items-center gap-3">
<div className="rounded-lg bg-primary/10 text-primary p-2">
{icon}
</div>
<div className="text-left">
<h3 className="font-bold">{title}</h3>
<p className="text-sm text-gray-600 dark:text-gray-400">{description}</p>
</div>
</div>
</div>
)
}

View File

@@ -0,0 +1,19 @@
import { SignButton } from "@/components/auth/sign-button"
import { ThemeToggle } from "@/components/theme/theme-toggle"
import { Logo } from "@/components/ui/logo"
export function Header() {
return (
<header className="fixed top-0 left-0 right-0 z-50 h-16 bg-background/80 backdrop-blur-sm border-b">
<div className="container mx-auto h-full px-4">
<div className="h-full flex items-center justify-between">
<Logo />
<div className="flex items-center gap-4">
<ThemeToggle />
<SignButton />
</div>
</div>
</div>
</header>
)
}

View File

@@ -0,0 +1,8 @@
"use client"
import { ThemeProvider as NextThemesProvider } from "next-themes"
import { type ThemeProviderProps } from "next-themes/dist/types"
export function ThemeProvider({ children, ...props }: ThemeProviderProps) {
return <NextThemesProvider {...props}>{children}</NextThemesProvider>
}

View File

@@ -0,0 +1,22 @@
"use client"
import { Moon, Sun } from "lucide-react"
import { useTheme } from "next-themes"
import { Button } from "@/components/ui/button"
export function ThemeToggle() {
const { theme, setTheme } = useTheme()
return (
<Button
variant="ghost"
size="icon"
onClick={() => setTheme(theme === "light" ? "dark" : "light")}
className="rounded-full"
>
<Sun className="h-5 w-5 rotate-0 scale-100 transition-all dark:-rotate-90 dark:scale-0" />
<Moon className="absolute h-5 w-5 rotate-90 scale-0 transition-all dark:rotate-0 dark:scale-100" />
<span className="sr-only"></span>
</Button>
)
}

View File

@@ -0,0 +1,52 @@
import * as React from "react"
import { Slot } from "@radix-ui/react-slot"
import { cva, type VariantProps } from "class-variance-authority"
import { cn } from "@/lib/utils"
const buttonVariants = cva(
"inline-flex items-center justify-center rounded-md text-sm font-medium transition-colors focus-visible:outline-none focus-visible:ring-1 focus-visible:ring-ring disabled:pointer-events-none disabled:opacity-50",
{
variants: {
variant: {
default: "bg-primary text-primary-foreground shadow hover:bg-primary/90",
destructive: "bg-destructive text-destructive-foreground shadow-sm hover:bg-destructive/90",
outline: "border border-input bg-transparent shadow-sm hover:bg-accent hover:text-accent-foreground",
secondary: "bg-secondary text-secondary-foreground shadow-sm hover:bg-secondary/80",
ghost: "hover:bg-accent hover:text-accent-foreground",
link: "text-primary underline-offset-4 hover:underline",
},
size: {
default: "h-9 px-4 py-2",
sm: "h-8 rounded-md px-3 text-xs",
lg: "h-10 rounded-md px-8",
icon: "h-9 w-9",
},
},
defaultVariants: {
variant: "default",
size: "default",
},
}
)
export interface ButtonProps
extends React.ButtonHTMLAttributes<HTMLButtonElement>,
VariantProps<typeof buttonVariants> {
asChild?: boolean
}
const Button = React.forwardRef<HTMLButtonElement, ButtonProps>(
({ className, variant, size, asChild = false, ...props }, ref) => {
const Comp = asChild ? Slot : "button"
return (
<Comp
className={cn(buttonVariants({ variant, size, className }))}
ref={ref}
{...props}
/>
)
}
)
Button.displayName = "Button"
export { Button, buttonVariants }

View File

@@ -0,0 +1,83 @@
"use client"
import * as React from "react"
import * as DialogPrimitive from "@radix-ui/react-dialog"
import { cn } from "@/lib/utils"
const Dialog = DialogPrimitive.Root
const DialogTrigger = DialogPrimitive.Trigger
const DialogPortal = DialogPrimitive.Portal
const DialogClose = DialogPrimitive.Close
const DialogOverlay = React.forwardRef<
React.ElementRef<typeof DialogPrimitive.Overlay>,
React.ComponentPropsWithoutRef<typeof DialogPrimitive.Overlay>
>(({ className, ...props }, ref) => (
<DialogPrimitive.Overlay
ref={ref}
className={cn(
"fixed inset-0 z-50 bg-black/80 data-[state=open]:animate-in data-[state=closed]:animate-out data-[state=closed]:fade-out-0 data-[state=open]:fade-in-0",
className
)}
{...props}
/>
))
DialogOverlay.displayName = DialogPrimitive.Overlay.displayName
const DialogContent = React.forwardRef<
React.ElementRef<typeof DialogPrimitive.Content>,
React.ComponentPropsWithoutRef<typeof DialogPrimitive.Content>
>(({ className, children, ...props }, ref) => (
<DialogPortal>
<DialogOverlay />
<DialogPrimitive.Content
ref={ref}
className={cn(
"fixed left-[50%] top-[50%] z-50 grid w-full max-w-lg translate-x-[-50%] translate-y-[-50%] gap-4 border bg-background p-6 shadow-lg duration-200 data-[state=open]:animate-in data-[state=closed]:animate-out data-[state=closed]:fade-out-0 data-[state=open]:fade-in-0 data-[state=closed]:zoom-out-95 data-[state=open]:zoom-in-95 data-[state=closed]:slide-out-to-left-1/2 data-[state=closed]:slide-out-to-top-[48%] data-[state=open]:slide-in-from-left-1/2 data-[state=open]:slide-in-from-top-[48%] sm:rounded-lg",
className
)}
{...props}
>
{children}
</DialogPrimitive.Content>
</DialogPortal>
))
DialogContent.displayName = DialogPrimitive.Content.displayName
const DialogHeader = ({
className,
...props
}: React.HTMLAttributes<HTMLDivElement>) => (
<div
className={cn(
"flex flex-col space-y-1.5 text-center sm:text-left",
className
)}
{...props}
/>
)
DialogHeader.displayName = "DialogHeader"
const DialogTitle = React.forwardRef<
React.ElementRef<typeof DialogPrimitive.Title>,
React.ComponentPropsWithoutRef<typeof DialogPrimitive.Title>
>(({ className, ...props }, ref) => (
<DialogPrimitive.Title
ref={ref}
className={cn(
"text-lg font-semibold leading-none tracking-tight",
className
)}
{...props}
/>
))
DialogTitle.displayName = DialogPrimitive.Title.displayName
export {
Dialog,
DialogTrigger,
DialogContent,
DialogHeader,
DialogTitle,
DialogClose,
}

View File

@@ -0,0 +1,26 @@
import * as React from "react"
import { cn } from "@/lib/utils"
export interface InputProps
extends React.InputHTMLAttributes<HTMLInputElement> {
className?: string
}
const Input = React.forwardRef<HTMLInputElement, InputProps>(
({ className, type, ...props }, ref) => {
return (
<input
type={type}
className={cn(
"flex h-9 w-full rounded-md border border-input bg-transparent px-3 py-1 text-sm shadow-sm transition-colors file:border-0 file:bg-transparent file:text-sm file:font-medium placeholder:text-muted-foreground focus-visible:outline-none focus-visible:ring-1 focus-visible:ring-ring disabled:cursor-not-allowed disabled:opacity-50",
className
)}
ref={ref}
{...props}
/>
)
}
)
Input.displayName = "Input"
export { Input }

View File

@@ -0,0 +1,22 @@
"use client"
import * as React from "react"
import * as LabelPrimitive from "@radix-ui/react-label"
import { cn } from "@/lib/utils"
const Label = React.forwardRef<
React.ElementRef<typeof LabelPrimitive.Root>,
React.ComponentPropsWithoutRef<typeof LabelPrimitive.Root>
>(({ className, ...props }, ref) => (
<LabelPrimitive.Root
ref={ref}
className={cn(
"text-sm font-medium leading-none peer-disabled:cursor-not-allowed peer-disabled:opacity-70",
className
)}
{...props}
/>
))
Label.displayName = LabelPrimitive.Root.displayName
export { Label }

View File

@@ -0,0 +1,65 @@
"use client"
import Link from "next/link"
export function Logo() {
return (
<Link
href="/"
className="flex items-center gap-2 hover:opacity-80 transition-opacity"
>
<div className="relative w-8 h-8">
<div className="absolute inset-0 grid grid-cols-8 grid-rows-8 gap-px">
<svg
width="32"
height="32"
viewBox="0 0 32 32"
fill="none"
xmlns="http://www.w3.org/2000/svg"
className="text-primary"
>
{/* 信封主体 */}
<path
d="M4 8h24v16H4V8z"
className="fill-primary/20"
/>
{/* 信封边框 */}
<path
d="M4 8h24v2H4V8zM4 22h24v2H4v-2z"
className="fill-primary"
/>
{/* @ 符号 */}
<path
d="M14 12h4v4h-4v-4zM12 14h2v4h-2v-4zM18 14h2v4h-2v-4zM14 18h4v2h-4v-2z"
className="fill-primary"
/>
{/* 折线装饰 */}
<path
d="M4 8l12 8 12-8"
className="stroke-primary stroke-2"
fill="none"
/>
{/* 装饰点 */}
<path
d="M8 18h2v2H8v-2zM22 18h2v2h-2v-2z"
className="fill-primary/60"
/>
{/* 底部装饰线 */}
<path
d="M8 14h2v2H8v-2zM22 14h2v2h-2v-2z"
className="fill-primary/40"
/>
</svg>
</div>
</div>
<span className="font-bold tracking-wider bg-clip-text text-transparent bg-gradient-to-r from-primary to-purple-600">
MoeMail
</span>
</Link>
)
}

View File

@@ -0,0 +1,44 @@
"use client"
import * as React from "react"
import * as RadioGroupPrimitive from "@radix-ui/react-radio-group"
import { Circle } from "lucide-react"
import { cn } from "@/lib/utils"
const RadioGroup = React.forwardRef<
React.ElementRef<typeof RadioGroupPrimitive.Root>,
React.ComponentPropsWithoutRef<typeof RadioGroupPrimitive.Root>
>(({ className, ...props }, ref) => {
return (
<RadioGroupPrimitive.Root
className={cn("grid gap-2", className)}
{...props}
ref={ref}
/>
)
})
RadioGroup.displayName = RadioGroupPrimitive.Root.displayName
const RadioGroupItem = React.forwardRef<
React.ElementRef<typeof RadioGroupPrimitive.Item>,
React.ComponentPropsWithoutRef<typeof RadioGroupPrimitive.Item>
>(({ className, ...props }, ref) => {
return (
<RadioGroupPrimitive.Item
ref={ref}
className={cn(
"aspect-square h-4 w-4 rounded-full border border-primary text-primary ring-offset-background focus:outline-none focus-visible:ring-2 focus-visible:ring-ring focus-visible:ring-offset-2 disabled:cursor-not-allowed disabled:opacity-50",
className
)}
{...props}
>
<RadioGroupPrimitive.Indicator className="flex items-center justify-center">
<Circle className="h-2.5 w-2.5 fill-current text-current" />
</RadioGroupPrimitive.Indicator>
</RadioGroupPrimitive.Item>
)
})
RadioGroupItem.displayName = RadioGroupPrimitive.Item.displayName
export { RadioGroup, RadioGroupItem }

View File

@@ -0,0 +1,23 @@
import * as React from "react"
import { cn } from "@/lib/utils"
export interface ToastActionProps
extends React.ButtonHTMLAttributes<HTMLButtonElement> {
className?: string
}
const ToastAction = React.forwardRef<HTMLButtonElement, ToastActionProps>(
({ className, ...props }, ref) => (
<button
ref={ref}
className={cn(
"inline-flex h-8 shrink-0 items-center justify-center rounded-md border bg-transparent px-3 text-sm font-medium transition-colors hover:bg-secondary focus:outline-none focus:ring-1 focus:ring-ring disabled:pointer-events-none disabled:opacity-50 group-[.destructive]:border-muted/40 group-[.destructive]:hover:border-destructive/30 group-[.destructive]:hover:bg-destructive group-[.destructive]:hover:text-destructive-foreground group-[.destructive]:focus:ring-destructive",
className
)}
{...props}
/>
)
)
ToastAction.displayName = "ToastAction"
export { ToastAction }

112
app/components/ui/toast.tsx Normal file
View File

@@ -0,0 +1,112 @@
import * as React from "react"
import * as ToastPrimitives from "@radix-ui/react-toast"
import { cva, type VariantProps } from "class-variance-authority"
import { X } from "lucide-react"
import { ToastAction } from "./toast-action"
import { cn } from "@/lib/utils"
const ToastProvider = ToastPrimitives.Provider
const ToastViewport = React.forwardRef<
React.ElementRef<typeof ToastPrimitives.Viewport>,
React.ComponentPropsWithoutRef<typeof ToastPrimitives.Viewport>
>(({ className, ...props }, ref) => (
<ToastPrimitives.Viewport
ref={ref}
className={cn(
"fixed top-0 z-[100] flex max-h-screen w-full flex-col-reverse p-4 sm:bottom-0 sm:right-0 sm:top-auto sm:flex-col md:max-w-[420px]",
className
)}
{...props}
/>
))
ToastViewport.displayName = ToastPrimitives.Viewport.displayName
const toastVariants = cva(
"group pointer-events-auto relative flex w-full items-center justify-between space-x-2 overflow-hidden rounded-md border p-4 pr-6 shadow-lg transition-all data-[swipe=cancel]:translate-x-0 data-[swipe=end]:translate-x-[var(--radix-toast-swipe-end-x)] data-[swipe=move]:translate-x-[var(--radix-toast-swipe-move-x)] data-[swipe=move]:transition-none data-[state=open]:animate-in data-[state=closed]:animate-out data-[swipe=end]:animate-out data-[state=closed]:fade-out-80 data-[state=closed]:slide-out-to-right-full data-[state=open]:slide-in-from-top-full data-[state=open]:sm:slide-in-from-bottom-full",
{
variants: {
variant: {
default: "border bg-background text-foreground",
destructive:
"destructive group border-destructive bg-destructive text-destructive-foreground",
},
},
defaultVariants: {
variant: "default",
},
}
)
const Toast = React.forwardRef<
React.ElementRef<typeof ToastPrimitives.Root>,
React.ComponentPropsWithoutRef<typeof ToastPrimitives.Root> &
VariantProps<typeof toastVariants>
>(({ className, variant, ...props }, ref) => {
return (
<ToastPrimitives.Root
ref={ref}
className={cn(toastVariants({ variant }), className)}
{...props}
/>
)
})
Toast.displayName = ToastPrimitives.Root.displayName
const ToastClose = React.forwardRef<
React.ElementRef<typeof ToastPrimitives.Close>,
React.ComponentPropsWithoutRef<typeof ToastPrimitives.Close>
>(({ className, ...props }, ref) => (
<ToastPrimitives.Close
ref={ref}
className={cn(
"absolute right-1 top-1 rounded-md p-1 text-foreground/50 opacity-0 transition-opacity hover:text-foreground focus:opacity-100 focus:outline-none focus:ring-1 group-hover:opacity-100 group-[.destructive]:text-red-300 group-[.destructive]:hover:text-red-50 group-[.destructive]:focus:ring-red-400 group-[.destructive]:focus:ring-offset-red-600",
className
)}
toast-close=""
{...props}
>
<X className="h-4 w-4" />
</ToastPrimitives.Close>
))
ToastClose.displayName = ToastPrimitives.Close.displayName
const ToastTitle = React.forwardRef<
React.ElementRef<typeof ToastPrimitives.Title>,
React.ComponentPropsWithoutRef<typeof ToastPrimitives.Title>
>(({ className, ...props }, ref) => (
<ToastPrimitives.Title
ref={ref}
className={cn("text-sm font-semibold [&+div]:text-xs", className)}
{...props}
/>
))
ToastTitle.displayName = ToastPrimitives.Title.displayName
const ToastDescription = React.forwardRef<
React.ElementRef<typeof ToastPrimitives.Description>,
React.ComponentPropsWithoutRef<typeof ToastPrimitives.Description>
>(({ className, ...props }, ref) => (
<ToastPrimitives.Description
ref={ref}
className={cn("text-sm opacity-90", className)}
{...props}
/>
))
ToastDescription.displayName = ToastPrimitives.Description.displayName
type ToastProps = React.ComponentPropsWithoutRef<typeof Toast>
type ToastActionElement = React.ReactElement<typeof ToastAction>
export {
type ToastProps,
type ToastActionElement,
ToastProvider,
ToastViewport,
Toast,
ToastTitle,
ToastDescription,
ToastClose,
}

View File

@@ -0,0 +1,55 @@
"use client"
import {
Toast,
ToastClose,
ToastDescription,
ToastProvider,
ToastTitle,
ToastViewport,
} from "./toast"
import { useToast } from "./use-toast"
export interface ToastProps {
id: string
title?: string
description?: string
action?: React.ReactNode
variant?: "default" | "destructive"
}
export function Toaster() {
const { toasts } = useToast()
return (
<ToastProvider>
{toasts.map(function ({
id,
title,
description,
action,
...props
}: {
id: string;
title?: React.ReactNode;
description?: React.ReactNode;
action?: React.ReactNode;
[key: string]: any;
}) {
return (
<Toast key={id} {...props}>
<div className="grid gap-1">
{title && <ToastTitle>{title}</ToastTitle>}
{description && (
<ToastDescription>{description}</ToastDescription>
)}
</div>
{action}
<ToastClose />
</Toast>
)
})}
<ToastViewport />
</ToastProvider>
)
}

View File

@@ -0,0 +1,192 @@
import * as React from "react"
import type {
ToastActionElement,
ToastProps,
} from "@/components/ui/toast"
const TOAST_LIMIT = 1
const TOAST_REMOVE_DELAY = 1000000
type ToasterToast = ToastProps & {
id: string
title?: React.ReactNode
description?: React.ReactNode
action?: ToastActionElement
}
export const actionTypes = {
ADD_TOAST: "ADD_TOAST",
UPDATE_TOAST: "UPDATE_TOAST",
DISMISS_TOAST: "DISMISS_TOAST",
REMOVE_TOAST: "REMOVE_TOAST",
} as const
export type ActionType = typeof actionTypes
let count = 0
function genId() {
count = (count + 1) % Number.MAX_VALUE
return count.toString()
}
type Action =
| {
type: ActionType["ADD_TOAST"]
toast: ToasterToast
}
| {
type: ActionType["UPDATE_TOAST"]
toast: Partial<ToasterToast>
}
| {
type: ActionType["DISMISS_TOAST"]
toastId?: ToasterToast["id"]
}
| {
type: ActionType["REMOVE_TOAST"]
toastId?: ToasterToast["id"]
}
interface State {
toasts: ToasterToast[]
}
const toastTimeouts = new Map<string, ReturnType<typeof setTimeout>>()
const addToRemoveQueue = (toastId: string) => {
if (toastTimeouts.has(toastId)) {
return
}
const timeout = setTimeout(() => {
toastTimeouts.delete(toastId)
dispatch({
type: "REMOVE_TOAST",
toastId: toastId,
})
}, TOAST_REMOVE_DELAY)
toastTimeouts.set(toastId, timeout)
}
export const reducer = (state: State, action: Action): State => {
switch (action.type) {
case "ADD_TOAST":
return {
...state,
toasts: [action.toast, ...state.toasts].slice(0, TOAST_LIMIT),
}
case "UPDATE_TOAST":
return {
...state,
toasts: state.toasts.map((t) =>
t.id === action.toast.id ? { ...t, ...action.toast } : t
),
}
case "DISMISS_TOAST": {
const { toastId } = action
// ! Side effects ! - This could be extracted into a dismissToast() action,
// but I'll keep it here for simplicity
if (toastId) {
addToRemoveQueue(toastId)
} else {
state.toasts.forEach((toast) => {
addToRemoveQueue(toast.id)
})
}
return {
...state,
toasts: state.toasts.map((t) =>
t.id === toastId || toastId === undefined
? {
...t,
open: false,
}
: t
),
}
}
case "REMOVE_TOAST":
if (action.toastId === undefined) {
return {
...state,
toasts: [],
}
}
return {
...state,
toasts: state.toasts.filter((t) => t.id !== action.toastId),
}
}
}
const listeners: Array<(state: State) => void> = []
let memoryState: State = { toasts: [] }
function dispatch(action: Action) {
memoryState = reducer(memoryState, action)
listeners.forEach((listener) => {
listener(memoryState)
})
}
type Toast = Omit<ToasterToast, "id">
function toast({ ...props }: Toast) {
const id = genId()
const update = (props: ToasterToast) =>
dispatch({
type: "UPDATE_TOAST",
toast: { ...props, id },
})
const dismiss = () => dispatch({ type: "DISMISS_TOAST", toastId: id })
dispatch({
type: "ADD_TOAST",
toast: {
...props,
id,
open: true,
onOpenChange: (open) => {
if (!open) dismiss()
},
},
})
return {
id: id,
dismiss,
update,
}
}
function useToast() {
const [state, setState] = React.useState<State>(memoryState)
React.useEffect(() => {
listeners.push(setState)
return () => {
const index = listeners.indexOf(setState)
if (index > -1) {
listeners.splice(index, 1)
}
}
}, [state])
return {
...state,
toast,
dismiss: (toastId?: string) => dispatch({ type: "DISMISS_TOAST", toastId }),
}
}
export { useToast, toast }