From 64105c718df16bb8e7134dc4ef399d6768cd5969 Mon Sep 17 00:00:00 2001 From: beilunyang <786220806@qq.com> Date: Tue, 24 Dec 2024 23:38:51 +0800 Subject: [PATCH] feat: Add email deletion functionality --- app/api/emails/[id]/route.ts | 42 +++++- app/components/emails/email-list.tsx | 196 +++++++++++++++++++-------- app/components/ui/alert-dialog.tsx | 137 +++++++++++++++++++ app/config/email.ts | 4 +- package.json | 1 + pnpm-lock.yaml | 65 +++++++++ workers/cleanup.ts | 2 +- 7 files changed, 385 insertions(+), 62 deletions(-) create mode 100644 app/components/ui/alert-dialog.tsx diff --git a/app/api/emails/[id]/route.ts b/app/api/emails/[id]/route.ts index e1316fb..a647fc8 100644 --- a/app/api/emails/[id]/route.ts +++ b/app/api/emails/[id]/route.ts @@ -1,11 +1,49 @@ import { NextResponse } from "next/server" +import { auth } from "@/lib/auth" import { createDb } from "@/lib/db" -import { messages } from "@/lib/schema" -import { and, eq, lt, or, sql } from "drizzle-orm" +import { emails, messages } from "@/lib/schema" +import { eq, and, lt, or, sql } from "drizzle-orm" import { encodeCursor, decodeCursor } from "@/lib/cursor" export const runtime = "edge" +export async function DELETE( + _request: Request, + { params }: { params: { id: string } } +) { + const db = createDb() + const session = await auth() + + try { + const email = await db.query.emails.findFirst({ + where: and( + eq(emails.id, params.id), + eq(emails.userId, session!.user!.id!) + ) + }) + + if (!email) { + return NextResponse.json( + { error: "邮箱不存在或无权限删除" }, + { status: 403 } + ) + } + await db.delete(messages) + .where(eq(messages.emailId, params.id)) + + await db.delete(emails) + .where(eq(emails.id, params.id)) + + return NextResponse.json({ success: true }) + } catch (error) { + console.error('Failed to delete email:', error) + return NextResponse.json( + { error: "删除邮箱失败" }, + { status: 500 } + ) + } +} + const PAGE_SIZE = 20 export async function GET( diff --git a/app/components/emails/email-list.tsx b/app/components/emails/email-list.tsx index a384980..f6d9ca8 100644 --- a/app/components/emails/email-list.tsx +++ b/app/components/emails/email-list.tsx @@ -3,11 +3,22 @@ import { useEffect, useState } from "react" import { useSession } from "next-auth/react" import { CreateDialog } from "./create-dialog" -import { Mail, RefreshCw } from "lucide-react" +import { Mail, RefreshCw, Trash2 } 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 { + AlertDialog, + AlertDialogAction, + AlertDialogCancel, + AlertDialogContent, + AlertDialogDescription, + AlertDialogFooter, + AlertDialogHeader, + AlertDialogTitle, +} from "@/components/ui/alert-dialog" interface Email { id: string @@ -17,7 +28,7 @@ interface Email { } interface EmailListProps { - onEmailSelect: (email: Email) => void + onEmailSelect: (email: Email | null) => void selectedEmailId?: string } @@ -35,6 +46,8 @@ export function EmailList({ onEmailSelect, selectedEmailId }: EmailListProps) { const [nextCursor, setNextCursor] = useState(null) const [loadingMore, setLoadingMore] = useState(false) const [total, setTotal] = useState(0) + const [emailToDelete, setEmailToDelete] = useState(null) + const { toast } = useToast() const fetchEmails = async (cursor?: string) => { try { @@ -99,68 +112,135 @@ export function EmailList({ onEmailSelect, selectedEmailId }: EmailListProps) { // eslint-disable-next-line react-hooks/exhaustive-deps }, [session]) + const handleDelete = async (email: Email) => { + try { + const response = await fetch(`/api/emails/${email.id}`, { + method: "DELETE" + }) + + if (!response.ok) { + const data = await response.json() + toast({ + title: "错误", + description: (data as { error: string }).error, + variant: "destructive" + }) + return + } + + setEmails(prev => prev.filter(e => e.id !== email.id)) + setTotal(prev => prev - 1) + + toast({ + title: "成功", + description: "邮箱已删除" + }) + + if (selectedEmailId === email.id) { + onEmailSelect(null) + } + } catch { + toast({ + title: "错误", + description: "删除邮箱失败", + variant: "destructive" + }) + } finally { + setEmailToDelete(null) + } + } + if (!session) return null return ( -
-
-
- - - {total}/{EMAIL_CONFIG.MAX_ACTIVE_EMAILS} 个邮箱 - + <> +
+
+
+ + + {total}/{EMAIL_CONFIG.MAX_ACTIVE_EMAILS} 个邮箱 + +
+
- -
- -
- {loading ? ( -
加载中...
- ) : emails.length > 0 ? ( -
- {emails.map(email => ( -
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" - )} - > - -
-
{email.address}
-
- {new Date(email.expiresAt).getFullYear() === 9999 ? ( - "永久有效" - ) : ( - `过期时间: ${new Date(email.expiresAt).toLocaleString()}` - )} + +
+ {loading ? ( +
加载中...
+ ) : emails.length > 0 ? ( +
+ {emails.map(email => ( +
onEmailSelect(email)} + > + +
+
{email.address}
+
+ {new Date(email.expiresAt).getFullYear() === 9999 ? ( + "永久有效" + ) : ( + `过期时间: ${new Date(email.expiresAt).toLocaleString()}` + )} +
+
-
- ))} - {loadingMore && ( -
- 加载更多... -
- )} -
- ) : ( -
- 还没有邮箱,创建一个吧! -
- )} + ))} + {loadingMore && ( +
+ 加载更多... +
+ )} +
+ ) : ( +
+ 还没有邮箱,创建一个吧! +
+ )} +
-
+ + setEmailToDelete(null)}> + + + 确认删除 + + 确定要删除邮箱 {emailToDelete?.address} 吗?此操作将同时删除该邮箱中的所有邮件,且不可恢复。 + + + + 取消 + emailToDelete && handleDelete(emailToDelete)} + > + 删除 + + + + + ) } \ No newline at end of file diff --git a/app/components/ui/alert-dialog.tsx b/app/components/ui/alert-dialog.tsx new file mode 100644 index 0000000..be9775d --- /dev/null +++ b/app/components/ui/alert-dialog.tsx @@ -0,0 +1,137 @@ +"use client" + +import * as React from "react" +import * as AlertDialogPrimitive from "@radix-ui/react-alert-dialog" +import { cn } from "@/lib/utils" +import { buttonVariants } from "@/components/ui/button" + +const AlertDialog = AlertDialogPrimitive.Root +const AlertDialogTrigger = AlertDialogPrimitive.Trigger + +const AlertDialogPortal = AlertDialogPrimitive.Portal +const AlertDialogOverlay = React.forwardRef< + React.ElementRef, + React.ComponentPropsWithoutRef +>(({ className, ...props }, ref) => ( + +)) +AlertDialogOverlay.displayName = AlertDialogPrimitive.Overlay.displayName + +const AlertDialogContent = React.forwardRef< + React.ElementRef, + React.ComponentPropsWithoutRef +>(({ className, ...props }, ref) => ( + + + + +)) +AlertDialogContent.displayName = AlertDialogPrimitive.Content.displayName + +const AlertDialogHeader = ({ + className, + ...props +}: React.HTMLAttributes) => ( +
+) +AlertDialogHeader.displayName = "AlertDialogHeader" + +const AlertDialogFooter = ({ + className, + ...props +}: React.HTMLAttributes) => ( +
+) +AlertDialogFooter.displayName = "AlertDialogFooter" + +const AlertDialogTitle = React.forwardRef< + React.ElementRef, + React.ComponentPropsWithoutRef +>(({ className, ...props }, ref) => ( + +)) +AlertDialogTitle.displayName = AlertDialogPrimitive.Title.displayName + +const AlertDialogDescription = React.forwardRef< + React.ElementRef, + React.ComponentPropsWithoutRef +>(({ className, ...props }, ref) => ( + +)) +AlertDialogDescription.displayName = AlertDialogPrimitive.Description.displayName + +const AlertDialogAction = React.forwardRef< + React.ElementRef, + React.ComponentPropsWithoutRef +>(({ className, ...props }, ref) => ( + +)) +AlertDialogAction.displayName = AlertDialogPrimitive.Action.displayName + +const AlertDialogCancel = React.forwardRef< + React.ElementRef, + React.ComponentPropsWithoutRef +>(({ className, ...props }, ref) => ( + +)) +AlertDialogCancel.displayName = AlertDialogPrimitive.Cancel.displayName + +export { + AlertDialog, + AlertDialogPortal, + AlertDialogOverlay, + AlertDialogTrigger, + AlertDialogContent, + AlertDialogHeader, + AlertDialogFooter, + AlertDialogTitle, + AlertDialogDescription, + AlertDialogAction, + AlertDialogCancel, +} \ No newline at end of file diff --git a/app/config/email.ts b/app/config/email.ts index 437c259..ce40449 100644 --- a/app/config/email.ts +++ b/app/config/email.ts @@ -1,7 +1,9 @@ +const DOMAINS = typeof process !== 'undefined' && process.env?.NEXT_PUBLIC_EMAIL_DOMAIN ? process.env.NEXT_PUBLIC_EMAIL_DOMAIN : 'moemail.app' + export const EMAIL_CONFIG = { MAX_ACTIVE_EMAILS: 30, // Maximum number of active emails POLL_INTERVAL: 10_000, // Polling interval in milliseconds - DOMAINS: (process.env.NEXT_PUBLIC_EMAIL_DOMAIN || 'moemail.app').split(','), // Email domains array + DOMAINS: DOMAINS.split(','), // Email domains array } as const export type EmailConfig = typeof EMAIL_CONFIG \ No newline at end of file diff --git a/package.json b/package.json index 49a6e07..b72c864 100644 --- a/package.json +++ b/package.json @@ -22,6 +22,7 @@ "dependencies": { "@auth/drizzle-adapter": "^1.7.4", "@cloudflare/next-on-pages": "^1.13.6", + "@radix-ui/react-alert-dialog": "^1.1.4", "@radix-ui/react-dialog": "^1.1.2", "@radix-ui/react-label": "^2.1.0", "@radix-ui/react-radio-group": "^1.2.1", diff --git a/pnpm-lock.yaml b/pnpm-lock.yaml index 7b85111..bc91658 100644 --- a/pnpm-lock.yaml +++ b/pnpm-lock.yaml @@ -14,6 +14,9 @@ importers: '@cloudflare/next-on-pages': specifier: ^1.13.6 version: 1.13.6(@cloudflare/workers-types@4.20241205.0)(vercel@39.1.1)(wrangler@3.93.0(@cloudflare/workers-types@4.20241205.0)) + '@radix-ui/react-alert-dialog': + specifier: ^1.1.4 + version: 1.1.4(@types/react-dom@18.3.2)(@types/react@18.3.14)(react-dom@19.0.0(react@19.0.0))(react@19.0.0) '@radix-ui/react-dialog': specifier: ^1.1.2 version: 1.1.2(@types/react-dom@18.3.2)(@types/react@18.3.14)(react-dom@19.0.0(react@19.0.0))(react@19.0.0) @@ -1587,6 +1590,19 @@ packages: '@radix-ui/primitive@1.1.1': resolution: {integrity: sha512-SJ31y+Q/zAyShtXJc8x83i9TYdbAfHZ++tUZnvjJJqFjzsdUnKsxPL6IEtBlxKkU7yzer//GQtZSV4GbldL3YA==} + '@radix-ui/react-alert-dialog@1.1.4': + resolution: {integrity: sha512-A6Kh23qZDLy3PSU4bh2UJZznOrUdHImIXqF8YtUa6CN73f8EOO9XlXSCd9IHyPvIquTaa/kwaSWzZTtUvgXVGw==} + peerDependencies: + '@types/react': '*' + '@types/react-dom': '*' + react: ^16.8 || ^17.0 || ^18.0 || ^19.0 || ^19.0.0-rc + react-dom: ^16.8 || ^17.0 || ^18.0 || ^19.0 || ^19.0.0-rc + peerDependenciesMeta: + '@types/react': + optional: true + '@types/react-dom': + optional: true + '@radix-ui/react-arrow@1.1.1': resolution: {integrity: sha512-NaVpZfmv8SKeZbn4ijN2V3jlHA9ngBG16VnIIm22nUR0Yk8KUALyBxT3KYEUnNuch9sTE8UTsS3whzBgKOL30w==} peerDependencies: @@ -1675,6 +1691,19 @@ packages: '@types/react-dom': optional: true + '@radix-ui/react-dialog@1.1.4': + resolution: {integrity: sha512-Ur7EV1IwQGCyaAuyDRiOLA5JIUZxELJljF+MbM/2NC0BYwfuRrbpS30BiQBJrVruscgUkieKkqXYDOoByaxIoA==} + peerDependencies: + '@types/react': '*' + '@types/react-dom': '*' + react: ^16.8 || ^17.0 || ^18.0 || ^19.0 || ^19.0.0-rc + react-dom: ^16.8 || ^17.0 || ^18.0 || ^19.0 || ^19.0.0-rc + peerDependenciesMeta: + '@types/react': + optional: true + '@types/react-dom': + optional: true + '@radix-ui/react-direction@1.1.0': resolution: {integrity: sha512-BUuBvgThEiAXh2DWu93XsT+a3aWrGqolGlqqw5VU1kG7p/ZH2cuDlM1sRLNnY3QcBS69UIz2mcKhMxDsdewhjg==} peerDependencies: @@ -6805,6 +6834,20 @@ snapshots: '@radix-ui/primitive@1.1.1': {} + '@radix-ui/react-alert-dialog@1.1.4(@types/react-dom@18.3.2)(@types/react@18.3.14)(react-dom@19.0.0(react@19.0.0))(react@19.0.0)': + dependencies: + '@radix-ui/primitive': 1.1.1 + '@radix-ui/react-compose-refs': 1.1.1(@types/react@18.3.14)(react@19.0.0) + '@radix-ui/react-context': 1.1.1(@types/react@18.3.14)(react@19.0.0) + '@radix-ui/react-dialog': 1.1.4(@types/react-dom@18.3.2)(@types/react@18.3.14)(react-dom@19.0.0(react@19.0.0))(react@19.0.0) + '@radix-ui/react-primitive': 2.0.1(@types/react-dom@18.3.2)(@types/react@18.3.14)(react-dom@19.0.0(react@19.0.0))(react@19.0.0) + '@radix-ui/react-slot': 1.1.1(@types/react@18.3.14)(react@19.0.0) + react: 19.0.0 + react-dom: 19.0.0(react@19.0.0) + optionalDependencies: + '@types/react': 18.3.14 + '@types/react-dom': 18.3.2 + '@radix-ui/react-arrow@1.1.1(@types/react-dom@18.3.2)(@types/react@18.3.14)(react-dom@19.0.0(react@19.0.0))(react@19.0.0)': dependencies: '@radix-ui/react-primitive': 2.0.1(@types/react-dom@18.3.2)(@types/react@18.3.14)(react-dom@19.0.0(react@19.0.0))(react@19.0.0) @@ -6884,6 +6927,28 @@ snapshots: '@types/react': 18.3.14 '@types/react-dom': 18.3.2 + '@radix-ui/react-dialog@1.1.4(@types/react-dom@18.3.2)(@types/react@18.3.14)(react-dom@19.0.0(react@19.0.0))(react@19.0.0)': + dependencies: + '@radix-ui/primitive': 1.1.1 + '@radix-ui/react-compose-refs': 1.1.1(@types/react@18.3.14)(react@19.0.0) + '@radix-ui/react-context': 1.1.1(@types/react@18.3.14)(react@19.0.0) + '@radix-ui/react-dismissable-layer': 1.1.3(@types/react-dom@18.3.2)(@types/react@18.3.14)(react-dom@19.0.0(react@19.0.0))(react@19.0.0) + '@radix-ui/react-focus-guards': 1.1.1(@types/react@18.3.14)(react@19.0.0) + '@radix-ui/react-focus-scope': 1.1.1(@types/react-dom@18.3.2)(@types/react@18.3.14)(react-dom@19.0.0(react@19.0.0))(react@19.0.0) + '@radix-ui/react-id': 1.1.0(@types/react@18.3.14)(react@19.0.0) + '@radix-ui/react-portal': 1.1.3(@types/react-dom@18.3.2)(@types/react@18.3.14)(react-dom@19.0.0(react@19.0.0))(react@19.0.0) + '@radix-ui/react-presence': 1.1.2(@types/react-dom@18.3.2)(@types/react@18.3.14)(react-dom@19.0.0(react@19.0.0))(react@19.0.0) + '@radix-ui/react-primitive': 2.0.1(@types/react-dom@18.3.2)(@types/react@18.3.14)(react-dom@19.0.0(react@19.0.0))(react@19.0.0) + '@radix-ui/react-slot': 1.1.1(@types/react@18.3.14)(react@19.0.0) + '@radix-ui/react-use-controllable-state': 1.1.0(@types/react@18.3.14)(react@19.0.0) + aria-hidden: 1.2.4 + react: 19.0.0 + react-dom: 19.0.0(react@19.0.0) + react-remove-scroll: 2.6.2(@types/react@18.3.14)(react@19.0.0) + optionalDependencies: + '@types/react': 18.3.14 + '@types/react-dom': 18.3.2 + '@radix-ui/react-direction@1.1.0(@types/react@18.3.14)(react@19.0.0)': dependencies: react: 19.0.0 diff --git a/workers/cleanup.ts b/workers/cleanup.ts index 2086d9d..5562ca6 100644 --- a/workers/cleanup.ts +++ b/workers/cleanup.ts @@ -4,7 +4,7 @@ interface Env { const CLEANUP_CONFIG = { // Whether to delete expired emails - DELETE_EXPIRED_EMAILS: false, + DELETE_EXPIRED_EMAILS: true, // Whether to delete messages from expired emails if not deleting the emails themselves DELETE_MESSAGES_FROM_EXPIRED: true,