feat(sharing): add email and message sharing functionality

This commit is contained in:
beilunyang
2025-10-18 20:08:42 +08:00
parent 47d555eaf5
commit dbe8c42b11
45 changed files with 5669 additions and 38 deletions

View File

@@ -4,6 +4,7 @@ import { useEffect, useState } from "react"
import { useSession } from "next-auth/react"
import { useTranslations } from "next-intl"
import { CreateDialog } from "./create-dialog"
import { ShareDialog } from "./share-dialog"
import { Mail, RefreshCw, Trash2 } from "lucide-react"
import { cn } from "@/lib/utils"
import { Button } from "@/components/ui/button"
@@ -209,17 +210,20 @@ export function EmailList({ onEmailSelect, selectedEmailId }: EmailListProps) {
)}
</div>
</div>
<Button
variant="ghost"
size="icon"
className="opacity-0 group-hover:opacity-100 h-8 w-8"
onClick={(e) => {
e.stopPropagation()
setEmailToDelete(email)
}}
>
<Trash2 className="h-4 w-4 text-destructive" />
</Button>
<div className="opacity-0 group-hover:opacity-100 flex gap-1" onClick={(e) => e.stopPropagation()}>
<ShareDialog emailId={email.id} emailAddress={email.address} />
<Button
variant="ghost"
size="icon"
className="h-8 w-8"
onClick={(e) => {
e.stopPropagation()
setEmailToDelete(email)
}}
>
<Trash2 className="h-4 w-4 text-destructive" />
</Button>
</div>
</div>
))}
{loadingMore && (

View File

@@ -2,12 +2,13 @@
import { useState, useEffect, useRef } from "react"
import { useTranslations } from "next-intl"
import {Mail, Calendar, RefreshCw, Trash2} from "lucide-react"
import {Mail, Calendar, RefreshCw, Trash2, Share2} 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"
import { useToast } from "@/components/ui/use-toast"
import { ShareMessageDialog } from "./share-message-dialog"
import {
AlertDialog,
AlertDialogAction,
@@ -219,7 +220,7 @@ export function MessageList({ email, messageType, onMessageSelect, selectedMessa
<RefreshCw className="h-4 w-4" />
</Button>
<span className="text-xs text-gray-500">
{total > 0 ? `${total} ${t("noMessages")}` : t("noMessages")}
{total > 0 ? `${total} ${t("messageCount")}` : t("noMessages")}
</span>
</div>
@@ -251,17 +252,33 @@ export function MessageList({ email, messageType, onMessageSelect, selectedMessa
</span>
</div>
</div>
<Button
<div className="opacity-0 group-hover:opacity-100 flex gap-1" onClick={(e) => e.stopPropagation()}>
<ShareMessageDialog
emailId={email.id}
messageId={message.id}
messageSubject={message.subject}
trigger={
<Button
variant="ghost"
size="icon"
className="h-8 w-8"
>
<Share2 className="h-4 w-4" />
</Button>
}
/>
<Button
variant="ghost"
size="icon"
className="opacity-0 group-hover:opacity-100 h-8 w-8"
className="h-8 w-8"
onClick={(e) => {
e.stopPropagation()
setMessageToDelete(message)
}}
>
<Trash2 className="h-4 w-4 text-destructive" />
</Button>
>
<Trash2 className="h-4 w-4 text-destructive" />
</Button>
</div>
</div>
</div>
))}

View File

@@ -2,11 +2,12 @@
import { useState, useEffect, useRef } from "react"
import { useTranslations } from "next-intl"
import { Loader2 } from "lucide-react"
import { Loader2, Share2 } from "lucide-react"
import { RadioGroup, RadioGroupItem } from "@/components/ui/radio-group"
import { Label } from "@/components/ui/label"
import { useTheme } from "next-themes"
import { useToast } from "@/components/ui/use-toast"
import { ShareMessageDialog } from "./share-message-dialog"
interface Message {
id: string
@@ -209,7 +210,19 @@ export function MessageView({ emailId, messageId, messageType = 'received' }: Me
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="flex items-start justify-between gap-2">
<h3 className="text-base font-bold flex-1">{message.subject}</h3>
<ShareMessageDialog
emailId={emailId}
messageId={message.id}
messageSubject={message.subject}
trigger={
<button className="p-1.5 hover:bg-primary/10 rounded-md transition-colors">
<Share2 className="h-4 w-4 text-gray-500" />
</button>
}
/>
</div>
<div className="text-xs text-gray-500 space-y-1">
{message.from_address && (
<p>{t("from")}: {message.from_address}</p>

View File

@@ -0,0 +1,347 @@
"use client"
import { useState, useEffect } from "react"
import { useTranslations } from "next-intl"
import { Share2, Copy, Trash2, Link2 } from "lucide-react"
import { cn } from "@/lib/utils"
import {
Dialog,
DialogContent,
DialogDescription,
DialogHeader,
DialogTitle,
DialogTrigger,
} from "@/components/ui/dialog"
import { Button } from "@/components/ui/button"
import { Label } from "@/components/ui/label"
import {
Select,
SelectContent,
SelectItem,
SelectTrigger,
SelectValue,
} from "@/components/ui/select"
import { useToast } from "@/components/ui/use-toast"
import { useCopy } from "@/hooks/use-copy"
import {
AlertDialog,
AlertDialogAction,
AlertDialogCancel,
AlertDialogContent,
AlertDialogDescription,
AlertDialogFooter,
AlertDialogHeader,
AlertDialogTitle,
} from "@/components/ui/alert-dialog"
import { EXPIRY_OPTIONS } from "@/types/email"
interface ShareDialogProps {
emailId: string
emailAddress: string
}
interface ShareLink {
id: string
token: string
createdAt: number | string | Date
expiresAt: number | string | Date | null
enabled: boolean
}
export function ShareDialog({ emailId }: ShareDialogProps) {
const t = useTranslations("emails.share")
const { toast } = useToast()
const { copyToClipboard } = useCopy()
const [open, setOpen] = useState(false)
const [shares, setShares] = useState<ShareLink[]>([])
const [loading, setLoading] = useState(false)
const [creating, setCreating] = useState(false)
const [expiryTime, setExpiryTime] = useState(EXPIRY_OPTIONS[1].value.toString())
const [deleteTarget, setDeleteTarget] = useState<ShareLink | null>(null)
const fetchShares = async () => {
try {
setLoading(true)
const response = await fetch(`/api/emails/${emailId}/share`)
if (!response.ok) throw new Error("Failed to fetch shares")
const data = await response.json() as { shares: ShareLink[] }
setShares(data.shares || [])
} catch (error) {
console.error("Failed to fetch shares:", error)
toast({
title: t("createFailed"),
description: String(error),
variant: "destructive"
})
} finally {
setLoading(false)
}
}
const createShare = async () => {
try {
setCreating(true)
const response = await fetch(`/api/emails/${emailId}/share`, {
method: "POST",
headers: { "Content-Type": "application/json" },
body: JSON.stringify({ expiresIn: Number(expiryTime) })
})
if (!response.ok) throw new Error("Failed to create share")
const share = await response.json() as ShareLink
setShares(prev => [share, ...prev])
toast({
title: t("createSuccess"),
})
} catch (error) {
console.error("Failed to create share:", error)
toast({
title: t("createFailed"),
description: String(error),
variant: "destructive"
})
} finally {
setCreating(false)
}
}
const deleteShare = async (share: ShareLink) => {
try {
const response = await fetch(`/api/emails/${emailId}/share/${share.id}`, {
method: "DELETE"
})
if (!response.ok) throw new Error("Failed to delete share")
setShares(prev => prev.filter(s => s.id !== share.id))
toast({
title: t("deleteSuccess"),
})
} catch (error) {
console.error("Failed to delete share:", error)
toast({
title: t("deleteFailed"),
description: String(error),
variant: "destructive"
})
} finally {
setDeleteTarget(null)
}
}
const getShareUrl = (token: string) => {
return `${window.location.origin}/shared/${token}`
}
const handleCopy = async (token: string) => {
const url = getShareUrl(token)
const success = await copyToClipboard(url)
if (success) {
toast({
title: t("copied"),
})
} else {
toast({
title: t("copyFailed"),
variant: "destructive"
})
}
}
useEffect(() => {
if (open) {
fetchShares()
}
// eslint-disable-next-line react-hooks/exhaustive-deps
}, [open])
return (
<>
<Dialog open={open} onOpenChange={setOpen}>
<DialogTrigger asChild>
<Button variant="ghost" size="icon" className="h-8 w-8">
<Share2 className="h-4 w-4" />
</Button>
</DialogTrigger>
<DialogContent
className="sm:max-w-[600px]"
onInteractOutside={(e) => e.preventDefault()}
onEscapeKeyDown={(e) => {
if (deleteTarget) {
e.preventDefault()
}
}}
>
<DialogHeader>
<DialogTitle>{t("title")}</DialogTitle>
<DialogDescription>
{t("description")}
</DialogDescription>
</DialogHeader>
<div className="space-y-4">
{/* Create new share link */}
<div className="space-y-2">
<Label>{t("expiryTime")}</Label>
<div className="flex gap-2">
<Select value={expiryTime} onValueChange={setExpiryTime}>
<SelectTrigger className="flex-1">
<SelectValue />
</SelectTrigger>
<SelectContent>
{EXPIRY_OPTIONS.map(option => (
<SelectItem key={option.value} value={option.value.toString()}>
{option.label}
</SelectItem>
))}
</SelectContent>
</Select>
<Button onClick={createShare} disabled={creating} className="min-w-[100px]">
{creating ? t("creating") : t("createLink")}
</Button>
</div>
</div>
{/* Active share links */}
<div className="space-y-2">
<Label>{t("activeLinks")}</Label>
<div className="h-[270px] overflow-y-auto">
{loading ? (
<div className="text-sm text-gray-500 text-center py-8 flex flex-col items-center gap-2">
<div className="w-5 h-5 border-2 border-primary/30 border-t-primary rounded-full animate-spin" />
<span>{t("loading")}</span>
</div>
) : shares.length === 0 ? (
<div className="text-sm text-gray-500 text-center py-4">
{t("noLinks")}
</div>
) : (
<div className="space-y-2">
{shares.map(share => {
// 将expiresAt转换为时间戳进行比较
const expiresAtTime = share.expiresAt
? (typeof share.expiresAt === 'number'
? share.expiresAt
: new Date(share.expiresAt).getTime())
: null
const isExpired = expiresAtTime !== null && expiresAtTime < Date.now()
return (
<div
key={share.id}
className={cn(
"p-3 border rounded-lg space-y-2 transition-all",
isExpired
? "border-destructive/30 bg-destructive/5 opacity-75"
: "border-border"
)}
>
<div className="flex items-center gap-2">
<Link2 className={cn(
"h-4 w-4 flex-shrink-0",
isExpired ? "text-destructive/60" : "text-primary/60"
)} />
<a
href={isExpired ? undefined : getShareUrl(share.token)}
target={isExpired ? undefined : "_blank"}
rel={isExpired ? undefined : "noopener noreferrer"}
onClick={(e) => {
if (isExpired) {
e.preventDefault()
}
}}
className={cn(
"flex-1 text-xs p-1 rounded truncate font-mono transition-colors",
isExpired
? "bg-destructive/10 text-destructive/70 cursor-not-allowed pointer-events-none"
: "bg-gray-100 dark:bg-gray-800 text-gray-700 dark:text-gray-300 hover:text-primary dark:hover:text-primary cursor-pointer"
)}
>
{getShareUrl(share.token)}
</a>
<Button
variant="ghost"
size="icon"
className="h-8 w-8 flex-shrink-0"
onClick={() => handleCopy(share.token)}
>
<Copy className="h-4 w-4" />
</Button>
<Button
variant="ghost"
size="icon"
className="h-8 w-8 flex-shrink-0"
onClick={() => setDeleteTarget(share)}
>
<Trash2 className="h-4 w-4 text-destructive" />
</Button>
</div>
<div className="flex gap-4 text-xs">
<span className={cn(
isExpired ? "text-destructive/70" : "text-gray-500"
)}>
{t("createdAt")}: {new Date(
typeof share.createdAt === 'number'
? share.createdAt
: share.createdAt
).toLocaleString()}
</span>
<span className={cn(
isExpired ? "text-destructive/70" : "text-gray-500"
)}>
{t("expiresAt")}: {
share.expiresAt
? new Date(
typeof share.expiresAt === 'number'
? share.expiresAt
: share.expiresAt
).toLocaleString()
: t("permanent")
}
</span>
{isExpired && (
<span className="text-destructive font-medium flex items-center gap-1">
<span className="w-2 h-2 bg-destructive rounded-full"></span>
{t("expired")}
</span>
)}
</div>
</div>
)
})}
</div>
)}
</div>
</div>
</div>
</DialogContent>
</Dialog>
<AlertDialog open={!!deleteTarget} onOpenChange={() => setDeleteTarget(null)}>
<AlertDialogContent>
<AlertDialogHeader>
<AlertDialogTitle>{t("deleteConfirm")}</AlertDialogTitle>
<AlertDialogDescription>
{t("deleteDescription")}
</AlertDialogDescription>
</AlertDialogHeader>
<AlertDialogFooter>
<AlertDialogCancel>{t("cancel")}</AlertDialogCancel>
<AlertDialogAction
className="bg-destructive hover:bg-destructive/90"
onClick={() => deleteTarget && deleteShare(deleteTarget)}
>
{t("delete")}
</AlertDialogAction>
</AlertDialogFooter>
</AlertDialogContent>
</AlertDialog>
</>
)
}

View File

@@ -0,0 +1,356 @@
"use client"
import { useState, useEffect } from "react"
import { useTranslations } from "next-intl"
import { Share2, Copy, Trash2, Link2 } from "lucide-react"
import { cn } from "@/lib/utils"
import {
Dialog,
DialogContent,
DialogDescription,
DialogHeader,
DialogTitle,
DialogTrigger,
} from "@/components/ui/dialog"
import { Button } from "@/components/ui/button"
import { Label } from "@/components/ui/label"
import {
Select,
SelectContent,
SelectItem,
SelectTrigger,
SelectValue,
} from "@/components/ui/select"
import { useToast } from "@/components/ui/use-toast"
import { useCopy } from "@/hooks/use-copy"
import {
AlertDialog,
AlertDialogAction,
AlertDialogCancel,
AlertDialogContent,
AlertDialogDescription,
AlertDialogFooter,
AlertDialogHeader,
AlertDialogTitle,
} from "@/components/ui/alert-dialog"
import { EXPIRY_OPTIONS } from "@/types/email"
interface ShareMessageDialogProps {
emailId: string
messageId: string
messageSubject: string
trigger?: React.ReactNode
}
interface ShareLink {
id: string
token: string
createdAt: number | string | Date
expiresAt: number | string | Date | null
enabled: boolean
}
export function ShareMessageDialog({ emailId, messageId, messageSubject, trigger }: ShareMessageDialogProps) {
const t = useTranslations("emails.shareMessage")
const { toast } = useToast()
const { copyToClipboard } = useCopy()
const [open, setOpen] = useState(false)
const [shares, setShares] = useState<ShareLink[]>([])
const [loading, setLoading] = useState(false)
const [creating, setCreating] = useState(false)
const [expiryTime, setExpiryTime] = useState(EXPIRY_OPTIONS[1].value.toString())
const [deleteTarget, setDeleteTarget] = useState<ShareLink | null>(null)
const fetchShares = async () => {
try {
setLoading(true)
const response = await fetch(`/api/emails/${emailId}/messages/${messageId}/share`)
if (!response.ok) throw new Error("Failed to fetch shares")
const data = await response.json() as { shares: ShareLink[] }
setShares(data.shares || [])
} catch (error) {
console.error("Failed to fetch shares:", error)
toast({
title: t("createFailed"),
description: String(error),
variant: "destructive"
})
} finally {
setLoading(false)
}
}
const createShare = async () => {
try {
setCreating(true)
const response = await fetch(`/api/emails/${emailId}/messages/${messageId}/share`, {
method: "POST",
headers: { "Content-Type": "application/json" },
body: JSON.stringify({ expiresIn: Number(expiryTime) })
})
if (!response.ok) throw new Error("Failed to create share")
const share = await response.json() as ShareLink
setShares(prev => [share, ...prev])
toast({
title: t("createSuccess"),
})
} catch (error) {
console.error("Failed to create share:", error)
toast({
title: t("createFailed"),
description: String(error),
variant: "destructive"
})
} finally {
setCreating(false)
}
}
const deleteShare = async (share: ShareLink) => {
try {
const response = await fetch(`/api/emails/${emailId}/messages/${messageId}/share/${share.id}`, {
method: "DELETE"
})
if (!response.ok) throw new Error("Failed to delete share")
setShares(prev => prev.filter(s => s.id !== share.id))
toast({
title: t("deleteSuccess"),
})
} catch (error) {
console.error("Failed to delete share:", error)
toast({
title: t("deleteFailed"),
description: String(error),
variant: "destructive"
})
} finally {
setDeleteTarget(null)
}
}
const getShareUrl = (token: string) => {
return `${window.location.origin}/shared/message/${token}`
}
const handleCopy = async (token: string) => {
const url = getShareUrl(token)
const success = await copyToClipboard(url)
if (success) {
toast({
title: t("copied"),
})
} else {
toast({
title: t("copyFailed"),
variant: "destructive"
})
}
}
useEffect(() => {
if (open) {
fetchShares()
}
// eslint-disable-next-line react-hooks/exhaustive-deps
}, [open])
return (
<>
<Dialog open={open} onOpenChange={setOpen}>
<DialogTrigger asChild>
{trigger || (
<Button variant="ghost" size="icon" className="h-8 w-8">
<Share2 className="h-4 w-4" />
</Button>
)}
</DialogTrigger>
<DialogContent
className="sm:max-w-[600px]"
onInteractOutside={(e) => e.preventDefault()}
onEscapeKeyDown={(e) => {
if (deleteTarget) {
e.preventDefault()
}
}}
>
<DialogHeader>
<DialogTitle>{t("title")}</DialogTitle>
<DialogDescription>
{t("description")}
</DialogDescription>
</DialogHeader>
<div className="space-y-4">
{/* Message info */}
<div className="p-3 bg-gray-50 dark:bg-gray-800 rounded-lg">
<p className="text-sm font-medium truncate">{messageSubject}</p>
</div>
{/* Create new share link */}
<div className="space-y-2">
<Label>{t("expiryTime")}</Label>
<div className="flex gap-2">
<Select value={expiryTime} onValueChange={setExpiryTime}>
<SelectTrigger className="flex-1">
<SelectValue />
</SelectTrigger>
<SelectContent>
{EXPIRY_OPTIONS.map(option => (
<SelectItem key={option.value} value={option.value.toString()}>
{option.label}
</SelectItem>
))}
</SelectContent>
</Select>
<Button onClick={createShare} disabled={creating} className="min-w-[100px]">
{creating ? t("creating") : t("createLink")}
</Button>
</div>
</div>
{/* Active share links */}
<div className="space-y-2">
<Label>{t("activeLinks")}</Label>
<div className="h-[270px] overflow-y-auto">
{loading ? (
<div className="text-sm text-gray-500 text-center py-8 flex flex-col items-center gap-2">
<div className="w-5 h-5 border-2 border-primary/30 border-t-primary rounded-full animate-spin" />
<span>{t("loading")}</span>
</div>
) : shares.length === 0 ? (
<div className="text-sm text-gray-500 text-center py-4">
{t("noLinks")}
</div>
) : (
<div className="space-y-2">
{shares.map(share => {
// 将expiresAt转换为时间戳进行比较
const expiresAtTime = share.expiresAt
? (typeof share.expiresAt === 'number'
? share.expiresAt
: new Date(share.expiresAt).getTime())
: null
const isExpired = expiresAtTime !== null && expiresAtTime < Date.now()
return (
<div
key={share.id}
className={cn(
"p-3 border rounded-lg space-y-2 transition-all",
isExpired
? "border-destructive/30 bg-destructive/5 opacity-75"
: "border-border"
)}
>
<div className="flex items-center gap-2">
<Link2 className={cn(
"h-4 w-4 flex-shrink-0",
isExpired ? "text-destructive/60" : "text-primary/60"
)} />
<a
href={isExpired ? undefined : getShareUrl(share.token)}
target={isExpired ? undefined : "_blank"}
rel={isExpired ? undefined : "noopener noreferrer"}
onClick={(e) => {
if (isExpired) {
e.preventDefault()
}
}}
className={cn(
"flex-1 text-xs p-1 rounded truncate font-mono transition-colors",
isExpired
? "bg-destructive/10 text-destructive/70 cursor-not-allowed pointer-events-none"
: "bg-gray-100 dark:bg-gray-800 text-gray-700 dark:text-gray-300 hover:text-primary dark:hover:text-primary cursor-pointer"
)}
>
{getShareUrl(share.token)}
</a>
<Button
variant="ghost"
size="icon"
className="h-8 w-8 flex-shrink-0"
onClick={() => handleCopy(share.token)}
>
<Copy className="h-4 w-4" />
</Button>
<Button
variant="ghost"
size="icon"
className="h-8 w-8 flex-shrink-0"
onClick={() => setDeleteTarget(share)}
>
<Trash2 className="h-4 w-4 text-destructive" />
</Button>
</div>
<div className="flex gap-4 text-xs">
<span className={cn(
isExpired ? "text-destructive/70" : "text-gray-500"
)}>
{t("createdAt")}: {new Date(
typeof share.createdAt === 'number'
? share.createdAt
: share.createdAt
).toLocaleString()}
</span>
<span className={cn(
isExpired ? "text-destructive/70" : "text-gray-500"
)}>
{t("expiresAt")}: {
share.expiresAt
? new Date(
typeof share.expiresAt === 'number'
? share.expiresAt
: share.expiresAt
).toLocaleString()
: t("permanent")
}
</span>
{isExpired && (
<span className="text-destructive font-medium flex items-center gap-1">
<span className="w-2 h-2 bg-destructive rounded-full"></span>
{t("expired")}
</span>
)}
</div>
</div>
)
})}
</div>
)}
</div>
</div>
</div>
</DialogContent>
</Dialog>
<AlertDialog open={!!deleteTarget} onOpenChange={() => setDeleteTarget(null)}>
<AlertDialogContent>
<AlertDialogHeader>
<AlertDialogTitle>{t("deleteConfirm")}</AlertDialogTitle>
<AlertDialogDescription>
{t("deleteDescription")}
</AlertDialogDescription>
</AlertDialogHeader>
<AlertDialogFooter>
<AlertDialogCancel>{t("cancel")}</AlertDialogCancel>
<AlertDialogAction
className="bg-destructive hover:bg-destructive/90"
onClick={() => deleteTarget && deleteShare(deleteTarget)}
>
{t("delete")}
</AlertDialogAction>
</AlertDialogFooter>
</AlertDialogContent>
</AlertDialog>
</>
)
}

View File

@@ -0,0 +1,41 @@
"use client"
import { AlertCircle } from "lucide-react"
import { Card } from "@/components/ui/card"
import { BrandHeader } from "@/components/ui/brand-header"
import { LanguageSwitcher } from "@/components/layout/language-switcher"
interface SharedErrorPageProps {
title: string
subtitle: string
error: string
description: string
ctaText: string
}
export function SharedErrorPage({ title, subtitle, error, description, ctaText }: SharedErrorPageProps) {
return (
<div className="min-h-screen bg-gray-50 dark:bg-gray-900">
<div className="container mx-auto p-4 max-w-4xl">
<div className="flex justify-end mb-4">
<LanguageSwitcher />
</div>
<BrandHeader
title={title}
subtitle={subtitle}
showCta={true}
ctaText={ctaText}
/>
<div className="text-center">
<Card className="max-w-md mx-auto p-8 text-center space-y-4">
<AlertCircle className="h-12 w-12 mx-auto text-destructive" />
<h2 className="text-2xl font-bold">{error}</h2>
<p className="text-gray-500">
{description}
</p>
</Card>
</div>
</div>
</div>
)
}

View File

@@ -0,0 +1,241 @@
"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"
import { useTheme } from "next-themes"
interface MessageDetail {
id: string
from_address?: string
to_address?: string
subject: string
content?: string
html?: string
received_at?: number
sent_at?: number
}
interface SharedMessageDetailProps {
message: MessageDetail | null
loading?: boolean
t: {
messageContent: string
selectMessage: string
loading: string
from: string
to: string
subject: string
time: string
htmlFormat: string
textFormat: string
}
}
type ViewMode = "html" | "text"
export function SharedMessageDetail({
message,
loading = false,
t,
}: SharedMessageDetailProps) {
const [viewMode, setViewMode] = useState<ViewMode>("html")
const iframeRef = useRef<HTMLIFrameElement>(null)
const { theme } = useTheme()
// 如果没有HTML内容默认显示文本
useEffect(() => {
if (message) {
if (!message.html && message.content) {
setViewMode("text")
} else if (message.html) {
setViewMode("html")
}
}
}, [message])
const updateIframeContent = () => {
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: ${theme === "dark" ? "#fff" : "#000"};
background: ${theme === "dark" ? "#1a1a1a" : "#fff"};
}
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: ${
theme === "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: ${
theme === "dark"
? "rgba(130, 109, 217, 0.5)"
: "rgba(130, 109, 217, 0.4)"
};
}
* {
scrollbar-width: thin;
scrollbar-color: ${
theme === "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()
}
}
}
}
useEffect(() => {
updateIframeContent()
}, [message?.html, viewMode, theme])
if (loading) {
return (
<div className="flex items-center justify-center h-32">
<Loader2 className="w-5 h-5 animate-spin text-primary/60" />
<span className="ml-2 text-sm text-gray-500">{t.loading}</span>
</div>
)
}
if (!message) {
return (
<div className="flex items-center justify-center h-32 text-gray-500">
{t.selectMessage}
</div>
)
}
return (
<div className="h-full flex flex-col">
<div className="p-4 space-y-3 border-b border-primary/20">
<div className="flex items-start justify-between gap-2">
<h3 className="text-base font-bold flex-1">{message.subject}</h3>
</div>
<div className="text-xs text-gray-500 space-y-1">
{message.from_address && (
<p>
{t.from}: {message.from_address}
</p>
)}
{message.to_address && (
<p>
{t.to}: {message.to_address}
</p>
)}
<p>
{t.time}:{" "}
{new Date(
message.sent_at || message.received_at || 0
).toLocaleString()}
</p>
</div>
</div>
{message.html && message.content && (
<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">
{t.htmlFormat}
</Label>
</div>
<div className="flex items-center space-x-2">
<RadioGroupItem value="text" id="text" />
<Label htmlFor="text" className="text-xs cursor-pointer">
{t.textFormat}
</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"
/>
) : message.content ? (
<div className="p-4 text-sm whitespace-pre-wrap">
{message.content}
</div>
) : (
<div className="flex items-center justify-center h-32 text-gray-500 text-sm">
{t.selectMessage}
</div>
)}
</div>
</div>
)
}

View File

@@ -0,0 +1,131 @@
"use client"
import { Mail, Calendar, RefreshCw } from "lucide-react"
import { cn } from "@/lib/utils"
import { useThrottle } from "@/hooks/use-throttle"
import { Button } from "@/components/ui/button"
interface Message {
id: string
from_address?: string
to_address?: string
subject: string
received_at?: number
sent_at?: number
}
interface SharedMessageListProps {
messages: Message[]
selectedMessageId?: string | null
onMessageSelect: (messageId: string) => void
onLoadMore?: () => void
onRefresh?: () => void
loading?: boolean
loadingMore?: boolean
refreshing?: boolean
hasMore?: boolean
total?: number
t: {
received: string
noMessages: string
messageCount: string
loading: string
loadingMore: string
}
}
export function SharedMessageList({
messages,
selectedMessageId,
onMessageSelect,
onLoadMore,
onRefresh,
loading = false,
loadingMore = false,
refreshing = false,
hasMore = false,
total = 0,
t,
}: SharedMessageListProps) {
const handleScroll = useThrottle((e: React.UIEvent<HTMLDivElement>) => {
if (loadingMore || !hasMore || !onLoadMore) return
const { scrollHeight, scrollTop, clientHeight } = e.currentTarget
const threshold = clientHeight * 1.5
const remainingScroll = scrollHeight - scrollTop
if (remainingScroll <= threshold) {
onLoadMore()
}
}, 200)
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={onRefresh}
disabled={refreshing || loading}
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} ${t.messageCount}` : t.noMessages}
</span>
</div>
<div className="flex-1 overflow-auto" onScroll={handleScroll}>
{loading ? (
<div className="p-4 text-center text-sm text-gray-500">
<RefreshCw className="h-6 w-6 animate-spin mx-auto text-primary mb-2" />
{t.loading}
</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 || message.to_address || ""}
</span>
<span className="flex items-center gap-1">
<Calendar className="w-3 h-3" />
{new Date(
message.received_at || message.sent_at || 0
).toLocaleString()}
</span>
</div>
</div>
</div>
</div>
))}
{loadingMore && (
<div className="text-center text-sm text-gray-500 py-2">
{t.loadingMore}
</div>
)}
</div>
) : (
<div className="p-4 text-center text-sm text-gray-500">
{t.noMessages}
</div>
)}
</div>
</div>
)
}