diff --git a/actions/cloudflare-dns-record.ts b/actions/cloudflare-dns-record.ts index d376c32..7ef3d35 100644 --- a/actions/cloudflare-dns-record.ts +++ b/actions/cloudflare-dns-record.ts @@ -98,6 +98,24 @@ export async function getUserRecordCount(userId: string, active: number = 1) { }); } +export async function getUserRecordByTypeNameContent( + userId: string, + type: string, + name: string, + content: string, + active: number = 1, +) { + return await prisma.userRecord.findMany({ + where: { + userId, + type, + content, + // name, + active, + }, + }); +} + export async function deleteUserRecord( userId: string, record_id: string, @@ -113,3 +131,43 @@ export async function deleteUserRecord( }, }); } + +export async function updateUserRecord( + userId: string, + data: UserRecordFormData, +) { + return await prisma.userRecord.update({ + where: { + userId, + record_id: data.record_id, + zone_id: data.zone_id, + active: data.active, + }, + data: { + type: data.type, + name: data.name, + content: data.content, + ttl: data.ttl, + comment: data.comment, + proxied: data.proxied, + modified_on: new Date().toISOString(), + }, + }); +} +export async function updateUserRecordState( + userId: string, + record_id: string, + zone_id: string, + active: number, +) { + return await prisma.userRecord.update({ + where: { + userId, + record_id, + zone_id, + }, + data: { + active, + }, + }); +} diff --git a/app/(protected)/dashboard/page.tsx b/app/(protected)/dashboard/page.tsx index 1e1fcc3..2d5d6d5 100644 --- a/app/(protected)/dashboard/page.tsx +++ b/app/(protected)/dashboard/page.tsx @@ -2,14 +2,13 @@ import { redirect } from "next/navigation"; import { getCurrentUser } from "@/lib/session"; import { constructMetadata } from "@/lib/utils"; -import { Button } from "@/components/ui/button"; import { DashboardHeader } from "@/components/dashboard/header"; -import { AddRecordForm } from "@/components/forms/add-record-form"; -import { EmptyPlaceholder } from "@/components/shared/empty-placeholder"; + +import UserRecordsList from "./record-list"; export const metadata = constructMetadata({ - title: "Dashboard – Next Template", - description: "Create and manage content.", + title: "Dashboard - WRDO", + description: "List and manage records.", }); export default async function DashboardPage() { @@ -19,19 +18,8 @@ export default async function DashboardPage() { return ( <> - - 、 - - - No record created - - You don't have any record yet. Start creating record. - - Add Record - + + > ); } diff --git a/app/(protected)/dashboard/record-list.tsx b/app/(protected)/dashboard/record-list.tsx new file mode 100644 index 0000000..97d9bbd --- /dev/null +++ b/app/(protected)/dashboard/record-list.tsx @@ -0,0 +1,191 @@ +"use client"; + +import { useState } from "react"; +import Link from "next/link"; +import { UserRecordFormData } from "@/actions/cloudflare-dns-record"; +import { User } from "@prisma/client"; +import { ArrowUpRight } from "lucide-react"; +import useSWR from "swr"; + +import { TTL_ENUMS } from "@/lib/cloudflare"; +import { fetcher } from "@/lib/utils"; +import { Badge } from "@/components/ui/badge"; +import { Button } from "@/components/ui/button"; +import { + Card, + CardContent, + CardDescription, + CardHeader, + CardTitle, +} from "@/components/ui/card"; +import { Skeleton } from "@/components/ui/skeleton"; +import { + Table, + TableBody, + TableCell, + TableHead, + TableHeader, + TableRow, +} from "@/components/ui/table"; +import StatusDot from "@/components/dashboard/status-dot"; +import { FormType, RecordForm } from "@/components/forms/record-form"; +import { EmptyPlaceholder } from "@/components/shared/empty-placeholder"; + +export interface RecordListProps { + user: Pick; +} + +function TableColumnSekleton({ className }: { className?: string }) { + return ( + + + + + + + + + + + + + + + + + + + + + ); +} + +export default function UserRecordsList({ user }: RecordListProps) { + const [isShowForm, setShowForm] = useState(false); + const [formType, setFormType] = useState("add"); + const [currentEditRecord, setCurrentEditRecord] = + useState(null); + + const { data, error, isLoading } = useSWR( + "/api/record", + fetcher, + { + revalidateOnFocus: false, + }, + ); + + return ( + <> + + + + Records + + All Dns Records + + + { + setCurrentEditRecord(null); + setShowForm(false); + setFormType("add"); + setShowForm(!isShowForm); + }} + > + Add record + + + + {isShowForm && ( + + )} + + + + + Type + + + Name + + + Content + + + TTL + + + Status + + + Actions + + + + + {isLoading ? ( + <> + + + > + ) : data && data.length > 0 ? ( + data.map((record) => ( + + + + {record.type} + + + {record.name} + + {record.content} + + + { + TTL_ENUMS.find((ttl) => ttl.value === `${record.ttl}`) + ?.label + } + + + + + + { + setCurrentEditRecord(record); + setShowForm(false); + setFormType("edit"); + setShowForm(!isShowForm); + }} + > + Edit + + + + )) + ) : ( + + + No records + + You don't have any record yet. Start creating record. + + Add Record + + )} + + + + + > + ); +} diff --git a/app/api/record/add/route.ts b/app/api/record/add/route.ts index 523f5a3..90dbdae 100644 --- a/app/api/record/add/route.ts +++ b/app/api/record/add/route.ts @@ -1,5 +1,6 @@ import { createUserRecord, + getUserRecordByTypeNameContent, getUserRecordCount, } from "@/actions/cloudflare-dns-record"; @@ -40,22 +41,40 @@ export async function POST(req: Request) { proxied: false, }; - // return Response.json(record); + // check quota const user_records_count = await getUserRecordCount(user.id); - if (user_records_count >= NEXT_PUBLIC_FREE_RECORD_QUOTA) { + if ( + Number(NEXT_PUBLIC_FREE_RECORD_QUOTA) > 0 && + user_records_count >= Number(NEXT_PUBLIC_FREE_RECORD_QUOTA) + ) { return Response.json("Your records have reached the free limit.", { status: 409, statusText: "Your records have reached the free limit.", }); } + const user_record = await getUserRecordByTypeNameContent( + user.id, + record.type, + record.name, + record.content, + 1, + ); + + if (user_record && user_record.length > 0) { + return Response.json("Record already exists", { + status: 403, + statusText: "Record already exists", + }); + } + const data = await createDNSRecord( CLOUDFLARE_ZONE_ID, CLOUDFLARE_API_KEY, CLOUDFLARE_EMAIL, record, ); - if (!data.success || !data.result) { + if (!data.success || !data.result?.id) { return Response.json(data.errors, { status: 501, statusText: `Error occurred. ${data.errors}`, @@ -86,6 +105,7 @@ export async function POST(req: Request) { return Response.json(res.data); } } catch (error) { + console.error(error); return Response.json(error, { status: 500, statusText: "Server error", diff --git a/app/api/record/delete/route.ts b/app/api/record/delete/route.ts index aa62706..65e700a 100644 --- a/app/api/record/delete/route.ts +++ b/app/api/record/delete/route.ts @@ -1,7 +1,28 @@ +import { deleteUserRecord } from "@/actions/cloudflare-dns-record"; + import { env } from "@/env.mjs"; import { getCurrentUser } from "@/lib/session"; -export async function DELETE(req: Request) { +export async function POST(req: Request) { try { - } catch (error) {} + const user = await getCurrentUser(); + if (!user?.id) { + return Response.json("Unauthorized", { + status: 401, + statusText: "Unauthorized", + }); + } + + const { record_id, zone_id, active } = await req.json(); + const { CLOUDFLARE_ZONE_ID, CLOUDFLARE_API_KEY, CLOUDFLARE_EMAIL } = env; + + await deleteUserRecord(user.id, record_id, zone_id, active); + // await + } catch (error) { + console.error(error); + return Response.json(error, { + status: 500, + statusText: "Server error", + }); + } } diff --git a/app/api/record/route.ts b/app/api/record/route.ts index e69de29..7f94fc0 100644 --- a/app/api/record/route.ts +++ b/app/api/record/route.ts @@ -0,0 +1,27 @@ +import { getUserRecords } from "@/actions/cloudflare-dns-record"; + +import { env } from "@/env.mjs"; +import { getCurrentUser } from "@/lib/session"; + +export async function GET(req: Request) { + try { + const user = await getCurrentUser(); + if (!user?.id) { + return Response.json("Unauthorized", { + status: 401, + statusText: "Unauthorized", + }); + } + + // const { CLOUDFLARE_ZONE_ID, CLOUDFLARE_API_KEY, CLOUDFLARE_EMAIL } = env; + + const user_records = await getUserRecords(user.id, 1); + + return Response.json(user_records); + } catch (error) { + return Response.json(error, { + status: 500, + statusText: "Server error", + }); + } +} diff --git a/app/api/record/update/route.ts b/app/api/record/update/route.ts index e69de29..fb573e3 100644 --- a/app/api/record/update/route.ts +++ b/app/api/record/update/route.ts @@ -0,0 +1,13 @@ +import { env } from "@/env.mjs"; +import { getCurrentUser } from "@/lib/session"; + +export async function POST(req: Request) { + try { + } catch (error) { + console.error(error); + return Response.json(error, { + status: 500, + statusText: "Server error", + }); + } +} diff --git a/components/dashboard/form-section-columns.tsx b/components/dashboard/form-section-columns.tsx index fa2df5b..f50958f 100644 --- a/components/dashboard/form-section-columns.tsx +++ b/components/dashboard/form-section-columns.tsx @@ -8,7 +8,9 @@ interface SectionColumnsType { export function FormSectionColumns({ title, children }: SectionColumnsType) { return ( - {title} + + {title} + {children} ); diff --git a/components/dashboard/status-dot.tsx b/components/dashboard/status-dot.tsx new file mode 100644 index 0000000..0f65828 --- /dev/null +++ b/components/dashboard/status-dot.tsx @@ -0,0 +1,12 @@ +import { cn } from "@/lib/utils"; + +export default function StatusDot({ status }: { status: number }) { + return ( + + ); +} diff --git a/components/forms/add-record-form.tsx b/components/forms/record-form.tsx similarity index 66% rename from components/forms/add-record-form.tsx rename to components/forms/record-form.tsx index cfb0a8d..ab3710d 100644 --- a/components/forms/add-record-form.tsx +++ b/components/forms/record-form.tsx @@ -1,6 +1,7 @@ "use client"; -import { useState, useTransition } from "react"; +import { Dispatch, SetStateAction, useState, useTransition } from "react"; +import { UserRecordFormData } from "@/actions/cloudflare-dns-record"; import { zodResolver } from "@hookform/resolvers/zod"; import { User } from "@prisma/client"; import { useForm } from "react-hook-form"; @@ -29,13 +30,24 @@ import { export type FormData = CreateDNSRecord; -interface AddRecordFormProps { +export type FormType = "add" | "edit"; + +export interface RecordFormProps { user: Pick; + isShowForm: boolean; + setShowForm: Dispatch>; + type: FormType; + initData?: UserRecordFormData | null; } -export function AddRecordForm({ user }: AddRecordFormProps) { +export function RecordForm({ + user, + isShowForm, + setShowForm, + type, + initData, +}: RecordFormProps) { const [isPending, startTransition] = useTransition(); - const [isShow, setShow] = useState(false); const { handleSubmit, @@ -44,33 +56,82 @@ export function AddRecordForm({ user }: AddRecordFormProps) { } = useForm({ resolver: zodResolver(createRecordSchema), defaultValues: { - type: "CNAME", - ttl: 1, - proxied: false, + type: initData?.type || "CNAME", + ttl: initData?.ttl || 1, + proxied: initData?.proxied || false, + comment: initData?.comment || "", + name: initData?.name || "", + content: initData?.content || "", }, }); const onSubmit = handleSubmit((data) => { startTransition(async () => { - const response = await fetch("/api/record/add", { - method: "POST", - body: JSON.stringify({ - records: [data], - }), - }); - if (!response.ok || response.status !== 200) { - toast.error("Add Record Failed", { - description: response.statusText, - }); - } else { - const res = await response.json(); - toast.success(`Created record [${res?.name}] successfully`); - setShow(false); + if (type === "add") { + handleCreateRecord(data); + } else if (type === "edit") { + handleUpdateRecord(data); } }); }); - return isShow ? ( + const handleCreateRecord = async (data: CreateDNSRecord) => { + const response = await fetch("/api/record/add", { + method: "POST", + body: JSON.stringify({ + records: [data], + }), + }); + if (!response.ok || response.status !== 200) { + toast.error("Created Failed!", { + description: response.statusText, + }); + } else { + const res = await response.json(); + toast.success(`Created successfully!`); + setShowForm(false); + } + }; + + const handleUpdateRecord = async (data: CreateDNSRecord) => { + const response = await fetch("/api/record/update", { + method: "POST", + body: JSON.stringify({ + records: [data], + }), + }); + if (!response.ok || response.status !== 200) { + toast.error("Update Failed", { + description: response.statusText, + }); + } else { + const res = await response.json(); + toast.success(`Update successfully!`); + setShowForm(false); + } + }; + + const handleDeleteRecord = async () => { + const response = await fetch("/api/record/delete", { + method: "POST", + body: JSON.stringify({ + record_id: initData?.record_id, + zone_id: initData?.zone_id, + active: initData?.active, + }), + }); + if (!response.ok || response.status !== 200) { + toast.error("Delete Failed", { + description: response.statusText, + }); + } else { + await response.json(); + toast.success(`Delete successfully!`); + setShowForm(false); + } + }; + + return ( {}} name={"type"} - defaultValue="CNAME" disabled + defaultValue="CNAME" > @@ -122,10 +183,10 @@ export function AddRecordForm({ user }: AddRecordFormProps) { )} - + - - Target + + Content {}} - name={"ttl"} + name="ttl" defaultValue="1" > @@ -198,11 +259,20 @@ export function AddRecordForm({ user }: AddRecordFormProps) { + {type === "edit" && ( + handleDeleteRecord()} + > + Delete + + )} setShow(false)} + onClick={() => setShowForm(false)} > Cancle @@ -215,18 +285,10 @@ export function AddRecordForm({ user }: AddRecordFormProps) { {isPending ? ( ) : ( - Save + {type === "edit" ? "Update" : "Save"} )} - ) : ( - setShow(true)} - > - Add record - ); } diff --git a/lib/cloudflare.ts b/lib/cloudflare.ts index 5ea1bed..9d0772c 100644 --- a/lib/cloudflare.ts +++ b/lib/cloudflare.ts @@ -11,6 +11,46 @@ export interface CreateDNSRecord { comment?: string; } +export interface DNSRecordResponse { + success: boolean; + errors: { + code: number; + message: string; + }[]; + messages: { + code: number; + message: string; + }[]; + result?: DNSRecordResult; + result_info?: { + count: number; + page: number; + per_page: number; + total_count: number; + }; +} + +export interface DNSRecordResult { + id: string; + zone_id: string; + zone_name: string; + name: string; + type: string; + content: string; + proxiable: boolean; + proxied: boolean; + ttl: number; + meta: { + auto_added: boolean; + managed_by_apps: boolean; + managed_by_argo_tunnel: boolean; + }; + comment: string; + tags: string[]; + created_on: string; + modified_on: string; +} + export type RecordType = "A" | "CNAME"; export const RECORD_TYPE_ENUMS = [ @@ -45,39 +85,23 @@ export const TTL_ENUMS = [ label: "1d", }, ]; - -export interface CreateDNSRecordResponse { - success: boolean; - errors: any[]; - messages: any[]; - result?: { - id: string; - zone_id: string; - zone_name: string; - name: string; - type: string; - content: string; - proxiable: boolean; - proxied: boolean; - ttl: number; - meta: { - auto_added: boolean; - managed_by_apps: boolean; - managed_by_argo_tunnel: boolean; - }; - comment: string; - tags: string[]; - created_on: string; - modified_on: string; - }; -} +export const STATUS_ENUMS = [ + { + value: 1, + label: "Active", + }, + { + value: 0, + label: "Inactive", + }, +]; export const createDNSRecord = async ( zoneId: string, apiKey: string, email: string, record: CreateDNSRecord, -): Promise => { +): Promise => { try { const url = `${CLOUDFLARE_API_URL}/zones/${zoneId}/dns_records`; @@ -111,7 +135,7 @@ export const deleteDNSRecord = async ( apiKey: string, email: string, recordId: string, -): Promise> => { +): Promise> => { try { const url = `${CLOUDFLARE_API_URL}/zones/${zoneId}/dns_records/${recordId}`; @@ -138,3 +162,70 @@ export const deleteDNSRecord = async ( throw error; } }; + +export const getDNSRecordsList = async ( + zoneId: string, + apiKey: string, + email: string, + type?: RecordType, + name?: string, + page = 1, + perPage = 100, +): Promise => { + try { + const url = `${CLOUDFLARE_API_URL}/zones/${zoneId}/dns_records?page=${page}&per_page=${perPage}&type=${type}&name=${name}`; + + const headers = { + "Content-Type": "application/json", + Authorization: `Bearer ${apiKey}`, + "X-Auth-Email": email, + "X-Auth-Key": apiKey, + }; + + const response = await fetch(url, { + method: "GET", + headers, + }); + + if (!response.ok) { + throw new Error(`HTTP error status: ${response.status}`); + } + + const data = await response.json(); + return data.result; + } catch (error) { + throw error; + } +}; + +export const getDNSRecordDetail = async ( + zoneId: string, + apiKey: string, + email: string, + recordId: string, +): Promise => { + try { + const url = `${CLOUDFLARE_API_URL}/zones/${zoneId}/dns_records/${recordId}`; + + const headers = { + "Content-Type": "application/json", + Authorization: `Bearer ${apiKey}`, + "X-Auth-Email": email, + "X-Auth-Key": apiKey, + }; + + const response = await fetch(url, { + method: "GET", + headers, + }); + + if (!response.ok) { + throw new Error(`HTTP error status: ${response.status}`); + } + + const data = await response.json(); + return data; + } catch (error) { + throw error; + } +}; diff --git a/lib/utils.ts b/lib/utils.ts index 548fea4..dd064c6 100644 --- a/lib/utils.ts +++ b/lib/utils.ts @@ -165,7 +165,6 @@ export const placeholderBlurhash = export function generateSecret(length: number = 16): string { // 使用 crypto.randomBytes 生成随机字节 const buffer = crypto.randomBytes(length); - console.log(buffer); // 将字节转换为十六进制字符串 return buffer.toString("hex"); } diff --git a/next.config.js b/next.config.js index 89f3e20..bf18dae 100644 --- a/next.config.js +++ b/next.config.js @@ -25,6 +25,51 @@ const nextConfig = { experimental: { serverComponentsExternalPackages: ["@prisma/client"], }, + redirects() { + return [ + { + source: "/0", + destination: "https://www.oiov.dev", + permanent: true, + }, + { + source: "/9", + destination: "https://f8dd841.webp.li/IMG20240703084254.jpg", + permanent: true, + }, + { + source: "/ai", + destination: "https://oi.sorapi.dev/?ref=wrdo", + permanent: true, + }, + { + source: "/cps", + destination: + "https://u3b6zhbgfp.feishu.cn/docx/OfHyd7LG5o0UJFx0xIdc0FYjnSf?from=wrdo", + permanent: true, + }, + { + source: "/x", + destination: "https://x.com/yesmoree", + permanent: true, + }, + { + source: "/solo", + destination: "https://solo.oiov.dev", + permanent: true, + }, + { + source: "/rmbg", + destination: "https://remover.wr.do", + permanent: true, + }, + { + source: "/llk", + destination: "https://www.oiov.dev/blog/llk", + permanent: true, + }, + ]; + }, }; module.exports = withContentlayer(nextConfig); diff --git a/package.json b/package.json index 0d29ed6..3d8f5f0 100644 --- a/package.json +++ b/package.json @@ -84,6 +84,7 @@ "sharp": "^0.33.4", "shiki": "^1.11.0", "sonner": "^1.5.0", + "swr": "^2.2.5", "tailwind-merge": "^2.4.0", "tailwindcss-animate": "^1.0.7", "vaul": "^0.9.1", diff --git a/pnpm-lock.yaml b/pnpm-lock.yaml index 5cb3fea..b34696a 100644 --- a/pnpm-lock.yaml +++ b/pnpm-lock.yaml @@ -203,6 +203,9 @@ importers: sonner: specifier: ^1.5.0 version: 1.5.0(react-dom@18.3.1(react@18.3.1))(react@18.3.1) + swr: + specifier: ^2.2.5 + version: 2.2.5(react@18.3.1) tailwind-merge: specifier: ^2.4.0 version: 2.4.0 @@ -5392,6 +5395,11 @@ packages: resolution: {integrity: sha512-ot0WnXS9fgdkgIcePe6RHNk1WA8+muPa6cSjeR3V8K27q9BB1rTE3R1p7Hv0z1ZyAc8s6Vvv8DIyWf681MAt0w==} engines: {node: '>= 0.4'} + swr@2.2.5: + resolution: {integrity: sha512-QtxqyclFeAsxEUeZIYmsaQ0UjimSq1RZ9Un7I68/0ClKK/U3LoyQunwkQfJZr2fc22DfIXLNDc2wFyTEikCUpg==} + peerDependencies: + react: ^16.11.0 || ^17.0.0 || ^18.0.0 + tailwind-merge@2.2.0: resolution: {integrity: sha512-SqqhhaL0T06SW59+JVNfAqKdqLs0497esifRrZ7jOaefP3o64fdFNDMrAQWZFMxTLJPiHVjRLUywT8uFz1xNWQ==} @@ -5646,6 +5654,11 @@ packages: '@types/react': optional: true + use-sync-external-store@1.2.2: + resolution: {integrity: sha512-PElTlVMwpblvbNqQ82d2n6RjStvdSoNe9FG28kNfz3WiXilJm4DdNkEzRhCZuIDwY8U08WVihhGR5iRqAwfDiw==} + peerDependencies: + react: ^16.8.0 || ^17.0.0 || ^18.0.0 + util-deprecate@1.0.2: resolution: {integrity: sha512-EPD5q1uXyFxJpCrLnCc1nHnq3gOa6DZBocAIiI2TaSCA7VCJ1UJDMagCzIkXNsUYfD1daK//LTEQ8xiIbrHtcw==} @@ -11934,6 +11947,12 @@ snapshots: supports-preserve-symlinks-flag@1.0.0: {} + swr@2.2.5(react@18.3.1): + dependencies: + client-only: 0.0.1 + react: 18.3.1 + use-sync-external-store: 1.2.2(react@18.3.1) + tailwind-merge@2.2.0: dependencies: '@babel/runtime': 7.24.4 @@ -12239,6 +12258,10 @@ snapshots: optionalDependencies: '@types/react': 18.3.3 + use-sync-external-store@1.2.2(react@18.3.1): + dependencies: + react: 18.3.1 + util-deprecate@1.0.2: {} uuid@9.0.1: {}
Save
{type === "edit" ? "Update" : "Save"}