chore: add curd

This commit is contained in:
oiov
2024-07-28 10:03:16 +08:00
parent 93af145894
commit b58be42e22
15 changed files with 644 additions and 91 deletions
+58
View File
@@ -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,
},
});
}
+6 -18
View File
@@ -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 (
<>
<DashboardHeader
heading="Dashboard"
// text={`Current Role : ${user?.role}`}
/>
<AddRecordForm user={{ id: user.id, name: user.name || "" }} />
<EmptyPlaceholder>
<EmptyPlaceholder.Icon name="post" />
<EmptyPlaceholder.Title>No record created</EmptyPlaceholder.Title>
<EmptyPlaceholder.Description>
You don&apos;t have any record yet. Start creating record.
</EmptyPlaceholder.Description>
<Button>Add Record</Button>
</EmptyPlaceholder>
<DashboardHeader heading="Dashboard" />
<UserRecordsList user={{ id: user.id, name: user.name || "" }} />
</>
);
}
+191
View File
@@ -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<User, "id" | "name">;
}
function TableColumnSekleton({ className }: { className?: string }) {
return (
<TableRow className="grid grid-cols-6 items-center">
<TableCell className="col-span-1">
<Skeleton className="h-5 w-24" />
</TableCell>
<TableCell className="col-span-1">
<Skeleton className="h-5 w-24" />
</TableCell>
<TableCell className="col-span-1">
<Skeleton className="h-5 w-24" />
</TableCell>
<TableCell className="col-span-1">
<Skeleton className="h-5 w-16" />
</TableCell>
<TableCell className="col-span-1 flex justify-center">
<Skeleton className="h-5 w-16" />
</TableCell>
<TableCell className="col-span-1 flex justify-center">
<Skeleton className="h-5 w-16" />
</TableCell>
</TableRow>
);
}
export default function UserRecordsList({ user }: RecordListProps) {
const [isShowForm, setShowForm] = useState(false);
const [formType, setFormType] = useState<FormType>("add");
const [currentEditRecord, setCurrentEditRecord] =
useState<UserRecordFormData | null>(null);
const { data, error, isLoading } = useSWR<UserRecordFormData[]>(
"/api/record",
fetcher,
{
revalidateOnFocus: false,
},
);
return (
<>
<Card className="xl:col-span-2">
<CardHeader className="flex flex-row items-center">
<div className="grid gap-2">
<CardTitle>Records</CardTitle>
<CardDescription className="text-balance">
All Dns Records
</CardDescription>
</div>
<Button
className="ml-auto w-[120px] shrink-0 gap-1"
variant="default"
onClick={() => {
setCurrentEditRecord(null);
setShowForm(false);
setFormType("add");
setShowForm(!isShowForm);
}}
>
Add record
</Button>
</CardHeader>
<CardContent>
{isShowForm && (
<RecordForm
user={{ id: user.id, name: user.name || "" }}
isShowForm={isShowForm}
setShowForm={setShowForm}
type={formType}
initData={currentEditRecord}
/>
)}
<Table>
<TableHeader>
<TableRow className="grid grid-cols-6 items-center">
<TableHead className="col-span-1 flex items-center font-bold">
Type
</TableHead>
<TableHead className="col-span-1 flex items-center font-bold">
Name
</TableHead>
<TableHead className="col-span-1 flex items-center font-bold">
Content
</TableHead>
<TableHead className="col-span-1 flex items-center font-bold">
TTL
</TableHead>
<TableHead className="col-span-1 flex items-center justify-center font-bold">
Status
</TableHead>
<TableHead className="col-span-1 flex items-center justify-center font-bold">
Actions
</TableHead>
</TableRow>
</TableHeader>
<TableBody>
{isLoading ? (
<>
<TableColumnSekleton className="col-span-1" />
<TableColumnSekleton className="col-span-1" />
</>
) : data && data.length > 0 ? (
data.map((record) => (
<TableRow className="grid animate-fade-in grid-cols-6 items-center animate-in">
<TableCell className="col-span-1">
<Badge className="text-xs" variant="outline">
{record.type}
</Badge>
</TableCell>
<TableCell className="col-span-1">{record.name}</TableCell>
<TableCell className="col-span-1">
{record.content}
</TableCell>
<TableCell className="col-span-1">
{
TTL_ENUMS.find((ttl) => ttl.value === `${record.ttl}`)
?.label
}
</TableCell>
<TableCell className="col-span-1 flex justify-center">
<StatusDot status={record.active} />
</TableCell>
<TableCell className="col-span-1 flex justify-center">
<Button
variant={"outline"}
onClick={() => {
setCurrentEditRecord(record);
setShowForm(false);
setFormType("edit");
setShowForm(!isShowForm);
}}
>
Edit
</Button>
</TableCell>
</TableRow>
))
) : (
<EmptyPlaceholder>
<EmptyPlaceholder.Icon name="post" />
<EmptyPlaceholder.Title>No records</EmptyPlaceholder.Title>
<EmptyPlaceholder.Description>
You don&apos;t have any record yet. Start creating record.
</EmptyPlaceholder.Description>
<Button>Add Record</Button>
</EmptyPlaceholder>
)}
</TableBody>
</Table>
</CardContent>
</Card>
</>
);
}
+23 -3
View File
@@ -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",
+23 -2
View File
@@ -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",
});
}
}
+27
View File
@@ -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",
});
}
}
+13
View File
@@ -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",
});
}
}
@@ -8,7 +8,9 @@ interface SectionColumnsType {
export function FormSectionColumns({ title, children }: SectionColumnsType) {
return (
<div className="grid grid-cols-1 items-center gap-x-12 gap-y-2 py-2">
<h2 className="col-span-4 text-lg font-semibold leading-none">{title}</h2>
<h2 className="col-span-4 text-base font-semibold leading-none">
{title}
</h2>
<div className="col-span-6">{children}</div>
</div>
);
+12
View File
@@ -0,0 +1,12 @@
import { cn } from "@/lib/utils";
export default function StatusDot({ status }: { status: number }) {
return (
<div
className={cn(
"h-[9px] w-[9px] rounded-full",
status === 1 ? "bg-green-500" : "bg-yellow-500",
)}
/>
);
}
@@ -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<User, "id" | "name">;
isShowForm: boolean;
setShowForm: Dispatch<SetStateAction<boolean>>;
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<FormData>({
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 (
<form
className="rounded-lg border border-dashed p-4 shadow-sm animate-in fade-in-50"
onSubmit={onSubmit}
@@ -80,8 +141,8 @@ export function AddRecordForm({ user }: AddRecordFormProps) {
<Select
onValueChange={(value: RecordType) => {}}
name={"type"}
defaultValue="CNAME"
disabled
defaultValue="CNAME"
>
<SelectTrigger className="w-full">
<SelectValue placeholder="Select a type" />
@@ -122,10 +183,10 @@ export function AddRecordForm({ user }: AddRecordFormProps) {
)}
</div>
</FormSectionColumns>
<FormSectionColumns title="Target">
<FormSectionColumns title="Content">
<div className="flex w-full items-center gap-2">
<Label className="sr-only" htmlFor="target">
Target
<Label className="sr-only" htmlFor="content">
Content
</Label>
<Input
id="content"
@@ -152,7 +213,7 @@ export function AddRecordForm({ user }: AddRecordFormProps) {
<FormSectionColumns title="TTL">
<Select
onValueChange={(value: RecordType) => {}}
name={"ttl"}
name="ttl"
defaultValue="1"
>
<SelectTrigger className="w-full">
@@ -198,11 +259,20 @@ export function AddRecordForm({ user }: AddRecordFormProps) {
</div>
<div className="flex justify-end gap-3">
{type === "edit" && (
<Button
variant={"destructive"}
className="mr-auto w-[80px] px-0"
onClick={() => handleDeleteRecord()}
>
Delete
</Button>
)}
<Button
type="reset"
variant={"destructive"}
variant={"outline"}
className="w-[80px] px-0"
onClick={() => setShow(false)}
onClick={() => setShowForm(false)}
>
Cancle
</Button>
@@ -215,18 +285,10 @@ export function AddRecordForm({ user }: AddRecordFormProps) {
{isPending ? (
<Icons.spinner className="size-4 animate-spin" />
) : (
<p>Save</p>
<p>{type === "edit" ? "Update" : "Save"}</p>
)}
</Button>
</div>
</form>
) : (
<Button
className="w-[120px]"
variant="default"
onClick={() => setShow(true)}
>
Add record
</Button>
);
}
+119 -28
View File
@@ -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<CreateDNSRecordResponse> => {
): Promise<DNSRecordResponse> => {
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<Pick<CreateDNSRecordResponse, "result">> => {
): Promise<Pick<DNSRecordResponse, "result">> => {
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<DNSRecordResult[]> => {
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<DNSRecordResponse> => {
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;
}
};
-1
View File
@@ -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");
}
+45
View File
@@ -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);
+1
View File
@@ -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",
+23
View File
@@ -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: {}