mirror of
https://github.com/beilunyang/moemail.git
synced 2026-05-11 10:00:25 +08:00
feat(sharing): add email and message sharing functionality
This commit is contained in:
@@ -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 && (
|
||||
|
||||
@@ -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>
|
||||
))}
|
||||
|
||||
@@ -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>
|
||||
|
||||
347
app/components/emails/share-dialog.tsx
Normal file
347
app/components/emails/share-dialog.tsx
Normal 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>
|
||||
</>
|
||||
)
|
||||
}
|
||||
|
||||
356
app/components/emails/share-message-dialog.tsx
Normal file
356
app/components/emails/share-message-dialog.tsx
Normal 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>
|
||||
</>
|
||||
)
|
||||
}
|
||||
|
||||
41
app/components/emails/shared-error-page.tsx
Normal file
41
app/components/emails/shared-error-page.tsx
Normal 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>
|
||||
)
|
||||
}
|
||||
241
app/components/emails/shared-message-detail.tsx
Normal file
241
app/components/emails/shared-message-detail.tsx
Normal 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>
|
||||
)
|
||||
}
|
||||
131
app/components/emails/shared-message-list.tsx
Normal file
131
app/components/emails/shared-message-list.tsx
Normal 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>
|
||||
)
|
||||
}
|
||||
@@ -1,6 +1,7 @@
|
||||
"use client"
|
||||
|
||||
import { useTranslations } from "next-intl"
|
||||
import { usePathname } from "next/navigation"
|
||||
import { Github } from "lucide-react"
|
||||
import { Button } from "@/components/ui/button"
|
||||
import {
|
||||
@@ -12,9 +13,15 @@ import {
|
||||
|
||||
export function FloatMenu() {
|
||||
const t = useTranslations("common")
|
||||
const pathname = usePathname()
|
||||
|
||||
// 在分享页面隐藏GitHub悬浮框
|
||||
if (pathname.includes("/shared/")) {
|
||||
return null
|
||||
}
|
||||
|
||||
return (
|
||||
<div className="fixed bottom-6 right-6">
|
||||
<div className="fixed bottom-6 right-6 z-50">
|
||||
<TooltipProvider>
|
||||
<Tooltip>
|
||||
<TooltipTrigger asChild>
|
||||
|
||||
76
app/components/layout/floating-language-switcher.tsx
Normal file
76
app/components/layout/floating-language-switcher.tsx
Normal file
@@ -0,0 +1,76 @@
|
||||
"use client"
|
||||
|
||||
import { useLocale, useTranslations } from "next-intl"
|
||||
import { usePathname, useRouter } from "next/navigation"
|
||||
import { i18n } from "@/i18n/config"
|
||||
import { Button } from "@/components/ui/button"
|
||||
import { Languages } from "lucide-react"
|
||||
import {
|
||||
DropdownMenu,
|
||||
DropdownMenuContent,
|
||||
DropdownMenuItem,
|
||||
DropdownMenuTrigger,
|
||||
} from "@/components/ui/dropdown-menu"
|
||||
|
||||
export function FloatingLanguageSwitcher() {
|
||||
const t = useTranslations("common")
|
||||
const locale = useLocale()
|
||||
const router = useRouter()
|
||||
const pathname = usePathname()
|
||||
|
||||
const switchLocale = (newLocale: string) => {
|
||||
if (newLocale === locale) return
|
||||
|
||||
document.cookie = `NEXT_LOCALE=${newLocale}; path=/; max-age=31536000`
|
||||
|
||||
const segments = pathname.split("/")
|
||||
if (i18n.locales.includes(segments[1] as any)) {
|
||||
segments[1] = newLocale
|
||||
} else {
|
||||
segments.splice(1, 0, newLocale)
|
||||
}
|
||||
const newPath = segments.join("/")
|
||||
|
||||
router.push(newPath)
|
||||
router.refresh()
|
||||
}
|
||||
|
||||
const getLanguageName = (loc: string) => {
|
||||
switch (loc) {
|
||||
case "en":
|
||||
return "English"
|
||||
case "zh-CN":
|
||||
return "简体中文"
|
||||
default:
|
||||
return loc
|
||||
}
|
||||
}
|
||||
|
||||
return (
|
||||
<div className="fixed bottom-6 right-6 z-50">
|
||||
<DropdownMenu>
|
||||
<DropdownMenuTrigger asChild>
|
||||
<Button
|
||||
variant="outline"
|
||||
size="icon"
|
||||
className="bg-white dark:bg-background rounded-full shadow-lg group relative border-primary/20 hover:border-primary/40 transition-all"
|
||||
>
|
||||
<Languages className="h-5 w-5 text-primary group-hover:scale-110 transition-transform" />
|
||||
<span className="sr-only">{t("lang.switch")}</span>
|
||||
</Button>
|
||||
</DropdownMenuTrigger>
|
||||
<DropdownMenuContent align="end" side="top" className="mb-2">
|
||||
{i18n.locales.map((loc) => (
|
||||
<DropdownMenuItem
|
||||
key={loc}
|
||||
onClick={() => switchLocale(loc)}
|
||||
className={locale === loc ? "bg-accent" : ""}
|
||||
>
|
||||
{getLanguageName(loc)}
|
||||
</DropdownMenuItem>
|
||||
))}
|
||||
</DropdownMenuContent>
|
||||
</DropdownMenu>
|
||||
</div>
|
||||
)
|
||||
}
|
||||
@@ -434,6 +434,134 @@ export function ApiKeyPanel() {
|
||||
</pre>
|
||||
</div>
|
||||
|
||||
<div className="space-y-2">
|
||||
<div className="flex items-center justify-between">
|
||||
<div className="text-sm font-medium">{t("docs.createEmailShare")}</div>
|
||||
<Button
|
||||
variant="ghost"
|
||||
size="icon"
|
||||
onClick={() => copyToClipboard(
|
||||
`curl -X POST ${window.location.protocol}//${window.location.host}/api/emails/{emailId}/share \\
|
||||
-H "X-API-Key: YOUR_API_KEY" \\
|
||||
-H "Content-Type: application/json" \\
|
||||
-d '{"expiresIn": 86400000}'`
|
||||
)}
|
||||
>
|
||||
<Copy className="w-4 h-4" />
|
||||
</Button>
|
||||
</div>
|
||||
<pre className="text-xs bg-muted/50 rounded-lg p-4 overflow-x-auto">
|
||||
{`curl -X POST ${window.location.protocol}//${window.location.host}/api/emails/{emailId}/share \\
|
||||
-H "X-API-Key: YOUR_API_KEY" \\
|
||||
-H "Content-Type: application/json" \\
|
||||
-d '{"expiresIn": 86400000}'`}
|
||||
</pre>
|
||||
</div>
|
||||
|
||||
<div className="space-y-2">
|
||||
<div className="flex items-center justify-between">
|
||||
<div className="text-sm font-medium">{t("docs.getEmailShares")}</div>
|
||||
<Button
|
||||
variant="ghost"
|
||||
size="icon"
|
||||
onClick={() => copyToClipboard(
|
||||
`curl ${window.location.protocol}//${window.location.host}/api/emails/{emailId}/share \\
|
||||
-H "X-API-Key: YOUR_API_KEY"`
|
||||
)}
|
||||
>
|
||||
<Copy className="w-4 h-4" />
|
||||
</Button>
|
||||
</div>
|
||||
<pre className="text-xs bg-muted/50 rounded-lg p-4 overflow-x-auto">
|
||||
{`curl ${window.location.protocol}//${window.location.host}/api/emails/{emailId}/share \\
|
||||
-H "X-API-Key: YOUR_API_KEY"`}
|
||||
</pre>
|
||||
</div>
|
||||
|
||||
<div className="space-y-2">
|
||||
<div className="flex items-center justify-between">
|
||||
<div className="text-sm font-medium">{t("docs.deleteEmailShare")}</div>
|
||||
<Button
|
||||
variant="ghost"
|
||||
size="icon"
|
||||
onClick={() => copyToClipboard(
|
||||
`curl -X DELETE ${window.location.protocol}//${window.location.host}/api/emails/{emailId}/share/{shareId} \\
|
||||
-H "X-API-Key: YOUR_API_KEY"`
|
||||
)}
|
||||
>
|
||||
<Copy className="w-4 h-4" />
|
||||
</Button>
|
||||
</div>
|
||||
<pre className="text-xs bg-muted/50 rounded-lg p-4 overflow-x-auto">
|
||||
{`curl -X DELETE ${window.location.protocol}//${window.location.host}/api/emails/{emailId}/share/{shareId} \\
|
||||
-H "X-API-Key: YOUR_API_KEY"`}
|
||||
</pre>
|
||||
</div>
|
||||
|
||||
<div className="space-y-2">
|
||||
<div className="flex items-center justify-between">
|
||||
<div className="text-sm font-medium">{t("docs.createMessageShare")}</div>
|
||||
<Button
|
||||
variant="ghost"
|
||||
size="icon"
|
||||
onClick={() => copyToClipboard(
|
||||
`curl -X POST ${window.location.protocol}//${window.location.host}/api/emails/{emailId}/messages/{messageId}/share \\
|
||||
-H "X-API-Key: YOUR_API_KEY" \\
|
||||
-H "Content-Type: application/json" \\
|
||||
-d '{"expiresIn": 0}'`
|
||||
)}
|
||||
>
|
||||
<Copy className="w-4 h-4" />
|
||||
</Button>
|
||||
</div>
|
||||
<pre className="text-xs bg-muted/50 rounded-lg p-4 overflow-x-auto">
|
||||
{`curl -X POST ${window.location.protocol}//${window.location.host}/api/emails/{emailId}/messages/{messageId}/share \\
|
||||
-H "X-API-Key: YOUR_API_KEY" \\
|
||||
-H "Content-Type: application/json" \\
|
||||
-d '{"expiresIn": 0}'`}
|
||||
</pre>
|
||||
</div>
|
||||
|
||||
<div className="space-y-2">
|
||||
<div className="flex items-center justify-between">
|
||||
<div className="text-sm font-medium">{t("docs.getMessageShares")}</div>
|
||||
<Button
|
||||
variant="ghost"
|
||||
size="icon"
|
||||
onClick={() => copyToClipboard(
|
||||
`curl ${window.location.protocol}//${window.location.host}/api/emails/{emailId}/messages/{messageId}/share \\
|
||||
-H "X-API-Key: YOUR_API_KEY"`
|
||||
)}
|
||||
>
|
||||
<Copy className="w-4 h-4" />
|
||||
</Button>
|
||||
</div>
|
||||
<pre className="text-xs bg-muted/50 rounded-lg p-4 overflow-x-auto">
|
||||
{`curl ${window.location.protocol}//${window.location.host}/api/emails/{emailId}/messages/{messageId}/share \\
|
||||
-H "X-API-Key: YOUR_API_KEY"`}
|
||||
</pre>
|
||||
</div>
|
||||
|
||||
<div className="space-y-2">
|
||||
<div className="flex items-center justify-between">
|
||||
<div className="text-sm font-medium">{t("docs.deleteMessageShare")}</div>
|
||||
<Button
|
||||
variant="ghost"
|
||||
size="icon"
|
||||
onClick={() => copyToClipboard(
|
||||
`curl -X DELETE ${window.location.protocol}//${window.location.host}/api/emails/{emailId}/messages/{messageId}/share/{shareId} \\
|
||||
-H "X-API-Key: YOUR_API_KEY"`
|
||||
)}
|
||||
>
|
||||
<Copy className="w-4 h-4" />
|
||||
</Button>
|
||||
</div>
|
||||
<pre className="text-xs bg-muted/50 rounded-lg p-4 overflow-x-auto">
|
||||
{`curl -X DELETE ${window.location.protocol}//${window.location.host}/api/emails/{emailId}/messages/{messageId}/share/{shareId} \\
|
||||
-H "X-API-Key: YOUR_API_KEY"`}
|
||||
</pre>
|
||||
</div>
|
||||
|
||||
<div className="text-xs text-muted-foreground mt-4">
|
||||
<p>{t("docs.notes")}</p>
|
||||
<ul className="list-disc list-inside space-y-1 mt-2">
|
||||
@@ -445,6 +573,8 @@ export function ApiKeyPanel() {
|
||||
<li>{t("docs.note6")}</li>
|
||||
<li>{t("docs.note7")}</li>
|
||||
<li>{t("docs.note8")}</li>
|
||||
<li>{t("docs.note9")}</li>
|
||||
<li>{t("docs.note10")}</li>
|
||||
</ul>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
116
app/components/ui/brand-header.tsx
Normal file
116
app/components/ui/brand-header.tsx
Normal file
@@ -0,0 +1,116 @@
|
||||
"use client"
|
||||
|
||||
import Link from "next/link"
|
||||
import { useTranslations } from "next-intl"
|
||||
import { Button } from "@/components/ui/button"
|
||||
import { ExternalLink, Mail } from "lucide-react"
|
||||
|
||||
interface BrandHeaderProps {
|
||||
title?: string
|
||||
subtitle?: string
|
||||
showCta?: boolean
|
||||
ctaText?: string
|
||||
ctaHref?: string
|
||||
}
|
||||
|
||||
export function BrandHeader({
|
||||
title,
|
||||
subtitle,
|
||||
showCta = true,
|
||||
ctaText,
|
||||
ctaHref = "https://moemail.app"
|
||||
}: BrandHeaderProps) {
|
||||
const t = useTranslations("emails.shared.brand")
|
||||
|
||||
const displayTitle = title || t("title")
|
||||
const displaySubtitle = subtitle || t("subtitle")
|
||||
const displayCtaText = ctaText || t("cta")
|
||||
return (
|
||||
<div className="text-center space-y-4 lg:pb-4">
|
||||
<div className="flex justify-center pt-2">
|
||||
<Link
|
||||
href={ctaHref}
|
||||
className="flex items-center gap-3 hover:opacity-80 transition-opacity group"
|
||||
>
|
||||
<div className="relative w-12 h-12">
|
||||
<div className="absolute inset-0 grid grid-cols-8 grid-rows-8 gap-px">
|
||||
<svg
|
||||
width="48"
|
||||
height="48"
|
||||
viewBox="0 0 32 32"
|
||||
fill="none"
|
||||
xmlns="http://www.w3.org/2000/svg"
|
||||
className="text-primary group-hover:scale-105 transition-transform duration-200"
|
||||
>
|
||||
{/* 信封主体 */}
|
||||
<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="text-3xl font-bold tracking-wider bg-clip-text text-transparent bg-gradient-to-r from-primary to-purple-600">
|
||||
MoeMail
|
||||
</span>
|
||||
</Link>
|
||||
</div>
|
||||
|
||||
<div className="space-y-3">
|
||||
<h1 className="text-2xl md:text-3xl font-bold text-gray-900 dark:text-white">
|
||||
{displayTitle}
|
||||
</h1>
|
||||
<p className="text-gray-600 dark:text-gray-300 max-w-md mx-auto">
|
||||
{displaySubtitle}
|
||||
</p>
|
||||
</div>
|
||||
|
||||
{showCta && (
|
||||
<div className="flex justify-center">
|
||||
<Button
|
||||
asChild
|
||||
size="lg"
|
||||
className="gap-2 bg-primary hover:bg-primary/90 text-white px-8 min-h-10 h-auto py-1"
|
||||
>
|
||||
<Link href={ctaHref} target="_blank" rel="noopener noreferrer">
|
||||
<Mail className="w-5 h-5" />
|
||||
{displayCtaText}
|
||||
<ExternalLink className="w-4 h-4" />
|
||||
</Link>
|
||||
</Button>
|
||||
</div>
|
||||
)}
|
||||
</div>
|
||||
)
|
||||
}
|
||||
Reference in New Issue
Block a user