chore: format
This commit is contained in:
parent
c8338e5516
commit
527139f298
33 changed files with 848 additions and 3132 deletions
|
@ -1,34 +1,40 @@
|
|||
"use server";
|
||||
import { initConnectionPostgres, initConnectionSurreal } from "@/components/db-utils";
|
||||
import {
|
||||
initConnectionPostgres,
|
||||
initConnectionSurreal,
|
||||
} from "@/components/db-utils";
|
||||
|
||||
export async function querydb(slug: string) {
|
||||
let long_url = undefined;
|
||||
try {
|
||||
if (process.env.DB_TYPE === "surrealdb") {
|
||||
let db = await initConnectionSurreal();
|
||||
long_url = await db.query(`
|
||||
let long_url = undefined;
|
||||
try {
|
||||
if (process.env.DB_TYPE === "surrealdb") {
|
||||
const db = await initConnectionSurreal();
|
||||
long_url = await db.query(
|
||||
`
|
||||
update url:[$id]
|
||||
set clicks +=1;
|
||||
`, { id: slug });
|
||||
// @ts-ignore
|
||||
long_url = long_url[0][0].long_url;
|
||||
}
|
||||
`,
|
||||
{ id: slug },
|
||||
);
|
||||
// @ts-ignore
|
||||
long_url = long_url[0][0].long_url;
|
||||
}
|
||||
|
||||
if (process.env.DB_TYPE === "postgres") {
|
||||
let sql = await initConnectionPostgres();
|
||||
long_url = await sql`
|
||||
if (process.env.DB_TYPE === "postgres") {
|
||||
const sql = await initConnectionPostgres();
|
||||
long_url = await sql`
|
||||
update url set
|
||||
clicks = clicks + 1,
|
||||
date_accessed = now()
|
||||
where id = ${slug}
|
||||
returning long_url;
|
||||
`;
|
||||
long_url = long_url[0].long_url;
|
||||
}
|
||||
long_url = long_url[0].long_url;
|
||||
}
|
||||
|
||||
console.log(long_url);
|
||||
return long_url;
|
||||
} catch (e) {
|
||||
return;
|
||||
}
|
||||
console.log(long_url);
|
||||
return long_url;
|
||||
} catch (e) {
|
||||
return;
|
||||
}
|
||||
}
|
||||
|
|
|
@ -3,13 +3,13 @@ import { notFound, redirect } from "next/navigation";
|
|||
import { querydb } from "./db";
|
||||
|
||||
export default async function Page({ params }: { params: { slug: string } }) {
|
||||
if (params.slug == "favicon.ico") {
|
||||
return;
|
||||
}
|
||||
if (params.slug === "favicon.ico") {
|
||||
return;
|
||||
}
|
||||
|
||||
let long_url = await querydb(params.slug);
|
||||
if (long_url == undefined) {
|
||||
return notFound();
|
||||
}
|
||||
redirect(`https://${long_url}`);
|
||||
const long_url = await querydb(params.slug);
|
||||
if (long_url === undefined) {
|
||||
return notFound();
|
||||
}
|
||||
redirect(`https://${long_url}`);
|
||||
}
|
||||
|
|
|
@ -1,117 +1,116 @@
|
|||
"use client";
|
||||
import { Button } from "@/components/ui/button";
|
||||
import {
|
||||
Card,
|
||||
CardContent,
|
||||
CardFooter,
|
||||
CardHeader,
|
||||
CardTitle
|
||||
Card,
|
||||
CardContent,
|
||||
CardFooter,
|
||||
CardHeader,
|
||||
CardTitle,
|
||||
} from "@/components/ui/card";
|
||||
import {
|
||||
Form,
|
||||
FormControl,
|
||||
FormField,
|
||||
FormItem,
|
||||
FormLabel,
|
||||
FormMessage
|
||||
Form,
|
||||
FormControl,
|
||||
FormField,
|
||||
FormItem,
|
||||
FormLabel,
|
||||
FormMessage,
|
||||
} from "@/components/ui/form";
|
||||
import { Input } from "@/components/ui/input";
|
||||
import {
|
||||
Popover,
|
||||
PopoverContent,
|
||||
PopoverTrigger,
|
||||
Popover,
|
||||
PopoverContent,
|
||||
PopoverTrigger,
|
||||
} from "@/components/ui/popover";
|
||||
import { zodResolver } from "@hookform/resolvers/zod";
|
||||
import { CopyIcon } from "@radix-ui/react-icons";
|
||||
import { useFormState } from "react-dom";
|
||||
import { useForm } from "react-hook-form";
|
||||
import { z } from "zod";
|
||||
import type { z } from "zod";
|
||||
import { querydb } from "./db";
|
||||
import { formSchema } from "./schema";
|
||||
|
||||
const initialState = {
|
||||
url: null,
|
||||
}
|
||||
url: null,
|
||||
};
|
||||
|
||||
export default function CreateCard() {
|
||||
// @ts-ignore
|
||||
let [state, formAction] = useFormState(querydb, initialState);
|
||||
// @ts-ignore
|
||||
const [state, formAction] = useFormState(querydb, initialState);
|
||||
|
||||
const form = useForm<z.infer<typeof formSchema>>({
|
||||
resolver: zodResolver(formSchema),
|
||||
defaultValues: {
|
||||
url: "",
|
||||
},
|
||||
});
|
||||
const form = useForm<z.infer<typeof formSchema>>({
|
||||
resolver: zodResolver(formSchema),
|
||||
defaultValues: {
|
||||
url: "",
|
||||
},
|
||||
});
|
||||
|
||||
const handleCopyUrl = () => {
|
||||
if (state && state.url) {
|
||||
let url = undefined;
|
||||
const currentSiteName = window.location.hostname;
|
||||
const handleCopyUrl = () => {
|
||||
if (state && state.url) {
|
||||
let url = undefined;
|
||||
const currentSiteName = window.location.hostname;
|
||||
|
||||
if (state.url.includes("https://")) {
|
||||
url = state.url;
|
||||
} else {
|
||||
if (currentSiteName === "localhost" || currentSiteName === "0.0.0.0") {
|
||||
url = `http://${currentSiteName}:${window.location.port}/${state.url.toString()}`;
|
||||
} else {
|
||||
url = `https://${currentSiteName}/${state.url.toString()}`;
|
||||
}
|
||||
}
|
||||
if (state.url.includes("https://")) {
|
||||
url = state.url;
|
||||
} else {
|
||||
if (currentSiteName === "localhost" || currentSiteName === "0.0.0.0") {
|
||||
url = `http://${currentSiteName}:${window.location.port}/${state.url.toString()}`;
|
||||
} else {
|
||||
url = `https://${currentSiteName}/${state.url.toString()}`;
|
||||
}
|
||||
}
|
||||
|
||||
navigator.clipboard.writeText(url)
|
||||
.catch(err => {
|
||||
console.error('Failed to copy URL to clipboard:', err);
|
||||
});
|
||||
}
|
||||
};
|
||||
navigator.clipboard.writeText(url).catch((err) => {
|
||||
console.error("Failed to copy URL to clipboard:", err);
|
||||
});
|
||||
}
|
||||
};
|
||||
|
||||
return (
|
||||
// <CardGrid>
|
||||
<Card>
|
||||
<CardHeader>
|
||||
<CardTitle>Create a Shortened URL</CardTitle>
|
||||
</CardHeader>
|
||||
return (
|
||||
// <CardGrid>
|
||||
<Card>
|
||||
<CardHeader>
|
||||
<CardTitle>Create a Shortened URL</CardTitle>
|
||||
</CardHeader>
|
||||
|
||||
<CardContent>
|
||||
<Form {...form}>
|
||||
{/* @ts-ignore */}
|
||||
<form id="url_form" action={form.handleSubmit(formAction)} className="space-y-8">
|
||||
<FormField
|
||||
control={form.control}
|
||||
name="url"
|
||||
render={({ field }) => (
|
||||
<FormItem>
|
||||
<FormLabel>Enter a url</FormLabel>
|
||||
<FormControl>
|
||||
<Input placeholder="nexveridian.com" {...field} />
|
||||
</FormControl>
|
||||
<FormMessage />
|
||||
</FormItem>
|
||||
)}
|
||||
/>
|
||||
</form>
|
||||
</Form>
|
||||
</CardContent>
|
||||
<CardContent>
|
||||
<Form {...form}>
|
||||
{/* @ts-ignore */}
|
||||
<form id="url_form" action={form.handleSubmit(formAction)} className="space-y-8">
|
||||
<FormField
|
||||
control={form.control}
|
||||
name="url"
|
||||
render={({ field }) => (
|
||||
<FormItem>
|
||||
<FormLabel>Enter a url</FormLabel>
|
||||
<FormControl>
|
||||
<Input placeholder="nexveridian.com" {...field} />
|
||||
</FormControl>
|
||||
<FormMessage />
|
||||
</FormItem>
|
||||
)}
|
||||
/>
|
||||
</form>
|
||||
</Form>
|
||||
</CardContent>
|
||||
|
||||
<CardFooter className="flex flex-row gap-4">
|
||||
<Button type="submit" form="url_form">Submit</Button>
|
||||
{state && state.url && (
|
||||
<Popover>
|
||||
<PopoverTrigger>
|
||||
<Button onClick={handleCopyUrl}>
|
||||
<CopyIcon className="mr-2 h-4 w-4" /> Copy Url
|
||||
</Button>
|
||||
</PopoverTrigger>
|
||||
<PopoverContent>
|
||||
<p className="text-lg">
|
||||
Url added to clipboard
|
||||
</p>
|
||||
</PopoverContent>
|
||||
</Popover>
|
||||
)}
|
||||
</CardFooter>
|
||||
</Card>
|
||||
// </CardGrid>
|
||||
);
|
||||
<CardFooter className="flex flex-row gap-4">
|
||||
<Button type="submit" form="url_form">
|
||||
Submit
|
||||
</Button>
|
||||
{state && state.url && (
|
||||
<Popover>
|
||||
<PopoverTrigger>
|
||||
<Button onClick={handleCopyUrl}>
|
||||
<CopyIcon className="mr-2 h-4 w-4" /> Copy Url
|
||||
</Button>
|
||||
</PopoverTrigger>
|
||||
<PopoverContent>
|
||||
<p className="text-lg">Url added to clipboard</p>
|
||||
</PopoverContent>
|
||||
</Popover>
|
||||
)}
|
||||
</CardFooter>
|
||||
</Card>
|
||||
// </CardGrid>
|
||||
);
|
||||
}
|
||||
|
|
|
@ -1,47 +1,54 @@
|
|||
"use server";
|
||||
import { initConnectionPostgres, initConnectionSurreal } from "@/components/db-utils";
|
||||
import {
|
||||
initConnectionPostgres,
|
||||
initConnectionSurreal,
|
||||
} from "@/components/db-utils";
|
||||
import { formSchema } from "./schema";
|
||||
|
||||
function generateRandomString(length: number) {
|
||||
const charset = 'ABCDEFGHIJKLMNOPQRSTUVWXYZabcdefghijklmnopqrstuvwxyz0123456789';
|
||||
let randomString = '';
|
||||
for (let i = 0; i < length; i++) {
|
||||
const randomIndex = Math.floor(Math.random() * charset.length);
|
||||
randomString += charset[randomIndex];
|
||||
}
|
||||
return randomString;
|
||||
const charset =
|
||||
"ABCDEFGHIJKLMNOPQRSTUVWXYZabcdefghijklmnopqrstuvwxyz0123456789";
|
||||
let randomString = "";
|
||||
for (let i = 0; i < length; i++) {
|
||||
const randomIndex = Math.floor(Math.random() * charset.length);
|
||||
randomString += charset[randomIndex];
|
||||
}
|
||||
return randomString;
|
||||
}
|
||||
|
||||
export async function querydb(prevState: any, formData: FormData) {
|
||||
const values = formSchema.safeParse(formData)
|
||||
if (!values.success) {
|
||||
return { error: values.error };
|
||||
}
|
||||
let long_url = values.data.url.replace("https://", "").replace("http://", "");
|
||||
long_url = long_url.endsWith('/') ? long_url.slice(0, -1) : long_url;
|
||||
let url = undefined;
|
||||
const values = formSchema.safeParse(formData);
|
||||
if (!values.success) {
|
||||
return { error: values.error };
|
||||
}
|
||||
let long_url = values.data.url.replace("https://", "").replace("http://", "");
|
||||
long_url = long_url.endsWith("/") ? long_url.slice(0, -1) : long_url;
|
||||
let url = undefined;
|
||||
|
||||
try {
|
||||
if (process.env.DB_TYPE === "surrealdb") {
|
||||
let db = await initConnectionSurreal();
|
||||
url = await db.query(`
|
||||
try {
|
||||
if (process.env.DB_TYPE === "surrealdb") {
|
||||
const db = await initConnectionSurreal();
|
||||
url = await db.query(
|
||||
`
|
||||
create url:[rand::string(8)] CONTENT {
|
||||
long_url: $long_url,
|
||||
clicks: 0,
|
||||
date_added: time::now(),
|
||||
date_accessed: <future> { time::now() }
|
||||
} return id[0];
|
||||
`, {
|
||||
long_url: long_url
|
||||
});
|
||||
// @ts-ignore
|
||||
url = url[0][0].id;
|
||||
}
|
||||
`,
|
||||
{
|
||||
long_url: long_url,
|
||||
},
|
||||
);
|
||||
// @ts-ignore
|
||||
url = url[0][0].id;
|
||||
}
|
||||
|
||||
console.log(long_url);
|
||||
if (process.env.DB_TYPE === "postgres") {
|
||||
let sql = await initConnectionPostgres();
|
||||
await sql`
|
||||
console.log(long_url);
|
||||
if (process.env.DB_TYPE === "postgres") {
|
||||
const sql = await initConnectionPostgres();
|
||||
await sql`
|
||||
create table if not exists url (
|
||||
id text primary key,
|
||||
clicks integer not null,
|
||||
|
@ -51,7 +58,7 @@ export async function querydb(prevState: any, formData: FormData) {
|
|||
);
|
||||
`;
|
||||
|
||||
url = await sql`
|
||||
url = await sql`
|
||||
insert into url (id, long_url, clicks, date_added, date_accessed)
|
||||
values (
|
||||
${generateRandomString(8)},
|
||||
|
@ -63,17 +70,17 @@ export async function querydb(prevState: any, formData: FormData) {
|
|||
returning id;
|
||||
`;
|
||||
|
||||
url = url[0].id;
|
||||
}
|
||||
url = url[0].id;
|
||||
}
|
||||
|
||||
console.log(url);
|
||||
console.log(url);
|
||||
|
||||
if (process.env.OVERRIDE_URL !== undefined) {
|
||||
url = `https://${process.env.OVERRIDE_URL}/${url.toString()}`;
|
||||
}
|
||||
if (process.env.OVERRIDE_URL !== undefined) {
|
||||
url = `https://${process.env.OVERRIDE_URL}/${url.toString()}`;
|
||||
}
|
||||
|
||||
return { url: url };
|
||||
} catch (e) {
|
||||
return;
|
||||
}
|
||||
return { url: url };
|
||||
} catch (e) {
|
||||
return;
|
||||
}
|
||||
}
|
||||
|
|
|
@ -1,8 +1,8 @@
|
|||
import { z } from "zod";
|
||||
|
||||
export const formSchema = z.object({
|
||||
url: z.string().min(4,
|
||||
{ message: "The URL must be at least 4 characters long" }
|
||||
).max(100
|
||||
, { message: "The URL must be at most 100 characters long" }),
|
||||
url: z
|
||||
.string()
|
||||
.min(4, { message: "The URL must be at least 4 characters long" })
|
||||
.max(100, { message: "The URL must be at most 100 characters long" }),
|
||||
});
|
||||
|
|
|
@ -1,23 +1,21 @@
|
|||
"use client";
|
||||
import CardGrid from "@/components/card-grid";
|
||||
import {
|
||||
Card,
|
||||
CardHeader,
|
||||
CardTitle
|
||||
} from "@/components/ui/card";
|
||||
import { Card, CardHeader, CardTitle } from "@/components/ui/card";
|
||||
|
||||
export default function GlobalError({
|
||||
error,
|
||||
error,
|
||||
}: {
|
||||
error: Error & { digest?: string }
|
||||
error: Error & { digest?: string };
|
||||
}) {
|
||||
return (
|
||||
<CardGrid maxCols={1}>
|
||||
<Card>
|
||||
<CardHeader>
|
||||
<CardTitle className="text-center text-2xl text-red-400">Error + {String(error)}</CardTitle>
|
||||
</CardHeader>
|
||||
</Card>
|
||||
</CardGrid>
|
||||
);
|
||||
return (
|
||||
<CardGrid maxCols={1}>
|
||||
<Card>
|
||||
<CardHeader>
|
||||
<CardTitle className="text-center text-2xl text-red-400">
|
||||
Error + {String(error)}
|
||||
</CardTitle>
|
||||
</CardHeader>
|
||||
</Card>
|
||||
</CardGrid>
|
||||
);
|
||||
}
|
||||
|
|
|
@ -3,57 +3,57 @@
|
|||
@tailwind utilities;
|
||||
|
||||
@layer base {
|
||||
:root {
|
||||
--background: 0 0% 100%;
|
||||
--foreground: 0 0% 3.9%;
|
||||
--card: 0 0% 100%;
|
||||
--card-foreground: 0 0% 3.9%;
|
||||
--popover: 0 0% 100%;
|
||||
--popover-foreground: 0 0% 3.9%;
|
||||
--primary: 0 0% 9%;
|
||||
--primary-foreground: 0 0% 98%;
|
||||
--secondary: 0 0% 96.1%;
|
||||
--secondary-foreground: 0 0% 9%;
|
||||
--muted: 0 0% 96.1%;
|
||||
--muted-foreground: 0 0% 45.1%;
|
||||
--accent: 0 0% 96.1%;
|
||||
--accent-foreground: 0 0% 9%;
|
||||
--destructive: 0 84.2% 60.2%;
|
||||
--destructive-foreground: 0 0% 98%;
|
||||
--border: 0 0% 89.8%;
|
||||
--input: 0 0% 89.8%;
|
||||
--ring: 0 0% 3.9%;
|
||||
--radius: 0.3rem;
|
||||
}
|
||||
:root {
|
||||
--background: 0 0% 100%;
|
||||
--foreground: 0 0% 3.9%;
|
||||
--card: 0 0% 100%;
|
||||
--card-foreground: 0 0% 3.9%;
|
||||
--popover: 0 0% 100%;
|
||||
--popover-foreground: 0 0% 3.9%;
|
||||
--primary: 0 0% 9%;
|
||||
--primary-foreground: 0 0% 98%;
|
||||
--secondary: 0 0% 96.1%;
|
||||
--secondary-foreground: 0 0% 9%;
|
||||
--muted: 0 0% 96.1%;
|
||||
--muted-foreground: 0 0% 45.1%;
|
||||
--accent: 0 0% 96.1%;
|
||||
--accent-foreground: 0 0% 9%;
|
||||
--destructive: 0 84.2% 60.2%;
|
||||
--destructive-foreground: 0 0% 98%;
|
||||
--border: 0 0% 89.8%;
|
||||
--input: 0 0% 89.8%;
|
||||
--ring: 0 0% 3.9%;
|
||||
--radius: 0.3rem;
|
||||
}
|
||||
|
||||
.dark {
|
||||
--background: 0 0% 3.9%;
|
||||
--foreground: 0 0% 98%;
|
||||
--card: 0 0% 3.9%;
|
||||
--card-foreground: 0 0% 98%;
|
||||
--popover: 0 0% 3.9%;
|
||||
--popover-foreground: 0 0% 98%;
|
||||
--primary: 0 0% 98%;
|
||||
--primary-foreground: 0 0% 9%;
|
||||
--secondary: 0 0% 14.9%;
|
||||
--secondary-foreground: 0 0% 98%;
|
||||
--muted: 0 0% 14.9%;
|
||||
--muted-foreground: 0 0% 63.9%;
|
||||
--accent: 0 0% 14.9%;
|
||||
--accent-foreground: 0 0% 98%;
|
||||
--destructive: 0 62.8% 30.6%;
|
||||
--destructive-foreground: 0 0% 98%;
|
||||
--border: 0 0% 14.9%;
|
||||
--input: 0 0% 14.9%;
|
||||
--ring: 0 0% 83.1%;
|
||||
}
|
||||
.dark {
|
||||
--background: 0 0% 3.9%;
|
||||
--foreground: 0 0% 98%;
|
||||
--card: 0 0% 3.9%;
|
||||
--card-foreground: 0 0% 98%;
|
||||
--popover: 0 0% 3.9%;
|
||||
--popover-foreground: 0 0% 98%;
|
||||
--primary: 0 0% 98%;
|
||||
--primary-foreground: 0 0% 9%;
|
||||
--secondary: 0 0% 14.9%;
|
||||
--secondary-foreground: 0 0% 98%;
|
||||
--muted: 0 0% 14.9%;
|
||||
--muted-foreground: 0 0% 63.9%;
|
||||
--accent: 0 0% 14.9%;
|
||||
--accent-foreground: 0 0% 98%;
|
||||
--destructive: 0 62.8% 30.6%;
|
||||
--destructive-foreground: 0 0% 98%;
|
||||
--border: 0 0% 14.9%;
|
||||
--input: 0 0% 14.9%;
|
||||
--ring: 0 0% 83.1%;
|
||||
}
|
||||
}
|
||||
|
||||
@layer base {
|
||||
* {
|
||||
@apply border-border;
|
||||
}
|
||||
body {
|
||||
@apply bg-background text-foreground;
|
||||
}
|
||||
* {
|
||||
@apply border-border;
|
||||
}
|
||||
body {
|
||||
@apply bg-background text-foreground;
|
||||
}
|
||||
}
|
||||
|
|
|
@ -5,33 +5,33 @@ import { Roboto_Mono } from "next/font/google";
|
|||
import "./globals.css";
|
||||
|
||||
export const roboto_mono = Roboto_Mono({
|
||||
subsets: ["latin"],
|
||||
display: "swap",
|
||||
variable: "--font-roboto-mono",
|
||||
subsets: ["latin"],
|
||||
display: "swap",
|
||||
variable: "--font-roboto-mono",
|
||||
});
|
||||
|
||||
export const metadata: Metadata = {
|
||||
title: "Next Url Shortener",
|
||||
description: "Next Url Shortener",
|
||||
title: "Next Url Shortener",
|
||||
description: "Next Url Shortener",
|
||||
};
|
||||
|
||||
export default function RootLayout({
|
||||
children,
|
||||
children,
|
||||
}: {
|
||||
children: React.ReactNode;
|
||||
children: React.ReactNode;
|
||||
}) {
|
||||
return (
|
||||
<html lang="en">
|
||||
<body className={roboto_mono.className}>
|
||||
<ThemeProvider
|
||||
attribute="class"
|
||||
defaultTheme="dark"
|
||||
disableTransitionOnChange
|
||||
>
|
||||
<Nav />
|
||||
{children}
|
||||
</ThemeProvider>
|
||||
</body>
|
||||
</html>
|
||||
);
|
||||
return (
|
||||
<html lang="en">
|
||||
<body className={roboto_mono.className}>
|
||||
<ThemeProvider
|
||||
attribute="class"
|
||||
defaultTheme="dark"
|
||||
disableTransitionOnChange
|
||||
>
|
||||
<Nav />
|
||||
{children}
|
||||
</ThemeProvider>
|
||||
</body>
|
||||
</html>
|
||||
);
|
||||
}
|
||||
|
|
|
@ -1,19 +1,17 @@
|
|||
"use client";
|
||||
import CardGrid from "@/components/card-grid";
|
||||
import {
|
||||
Card,
|
||||
CardHeader,
|
||||
CardTitle
|
||||
} from "@/components/ui/card";
|
||||
import { Card, CardHeader, CardTitle } from "@/components/ui/card";
|
||||
|
||||
export default function NotFound() {
|
||||
return (
|
||||
<CardGrid maxCols={1}>
|
||||
<Card>
|
||||
<CardHeader>
|
||||
<CardTitle className="text-center text-2xl text-red-400">404 - Not Found</CardTitle>
|
||||
</CardHeader>
|
||||
</Card>
|
||||
</CardGrid>
|
||||
);
|
||||
return (
|
||||
<CardGrid maxCols={1}>
|
||||
<Card>
|
||||
<CardHeader>
|
||||
<CardTitle className="text-center text-2xl text-red-400">
|
||||
404 - Not Found
|
||||
</CardTitle>
|
||||
</CardHeader>
|
||||
</Card>
|
||||
</CardGrid>
|
||||
);
|
||||
}
|
||||
|
|
|
@ -1,32 +1,28 @@
|
|||
"use client";
|
||||
import CardGrid from "@/components/card-grid";
|
||||
import {
|
||||
Card,
|
||||
CardHeader,
|
||||
CardTitle
|
||||
} from "@/components/ui/card";
|
||||
import { Card, CardHeader, CardTitle } from "@/components/ui/card";
|
||||
import Link from "next/link";
|
||||
import CreateCard from "./create/create";
|
||||
|
||||
export default function Home() {
|
||||
return (
|
||||
<CardGrid>
|
||||
{/* <Card>
|
||||
return (
|
||||
<CardGrid>
|
||||
{/* <Card>
|
||||
<Link href="/create">
|
||||
<CardHeader>
|
||||
<CardTitle>Create a Shortened URL</CardTitle>
|
||||
</CardHeader>
|
||||
</Link>
|
||||
</Card> */}
|
||||
<CreateCard />
|
||||
<CreateCard />
|
||||
|
||||
<Card>
|
||||
<Link href="/stats">
|
||||
<CardHeader>
|
||||
<CardTitle>Site Stats</CardTitle>
|
||||
</CardHeader>
|
||||
</Link>
|
||||
</Card>
|
||||
</CardGrid>
|
||||
);
|
||||
<Card>
|
||||
<Link href="/stats">
|
||||
<CardHeader>
|
||||
<CardTitle>Site Stats</CardTitle>
|
||||
</CardHeader>
|
||||
</Link>
|
||||
</Card>
|
||||
</CardGrid>
|
||||
);
|
||||
}
|
||||
|
|
|
@ -1,33 +1,33 @@
|
|||
"use client";
|
||||
import { ColumnDef } from "@tanstack/react-table";
|
||||
import type { ColumnDef } from "@tanstack/react-table";
|
||||
|
||||
export type UrlTable = {
|
||||
clicks: number;
|
||||
date_accessed: Date;
|
||||
date_added: Date;
|
||||
id: string;
|
||||
long_url: string;
|
||||
clicks: number;
|
||||
date_accessed: Date;
|
||||
date_added: Date;
|
||||
id: string;
|
||||
long_url: string;
|
||||
};
|
||||
|
||||
export const columns: ColumnDef<UrlTable>[] = [
|
||||
{
|
||||
accessorKey: "clicks",
|
||||
header: "Clicks",
|
||||
},
|
||||
{
|
||||
accessorKey: "long_url",
|
||||
header: "URL",
|
||||
},
|
||||
{
|
||||
accessorKey: "id",
|
||||
header: "Short URL",
|
||||
},
|
||||
{
|
||||
accessorKey: "date_accessed",
|
||||
header: "Date Accessed",
|
||||
},
|
||||
{
|
||||
accessorKey: "date_added",
|
||||
header: "Date Added",
|
||||
},
|
||||
{
|
||||
accessorKey: "clicks",
|
||||
header: "Clicks",
|
||||
},
|
||||
{
|
||||
accessorKey: "long_url",
|
||||
header: "URL",
|
||||
},
|
||||
{
|
||||
accessorKey: "id",
|
||||
header: "Short URL",
|
||||
},
|
||||
{
|
||||
accessorKey: "date_accessed",
|
||||
header: "Date Accessed",
|
||||
},
|
||||
{
|
||||
accessorKey: "date_added",
|
||||
header: "Date Added",
|
||||
},
|
||||
];
|
||||
|
|
|
@ -1,79 +1,79 @@
|
|||
"use client";
|
||||
import {
|
||||
Table,
|
||||
TableBody,
|
||||
TableCell,
|
||||
TableHead,
|
||||
TableHeader,
|
||||
TableRow,
|
||||
Table,
|
||||
TableBody,
|
||||
TableCell,
|
||||
TableHead,
|
||||
TableHeader,
|
||||
TableRow,
|
||||
} from "@/components/ui/table";
|
||||
import {
|
||||
ColumnDef,
|
||||
flexRender,
|
||||
getCoreRowModel,
|
||||
useReactTable
|
||||
type ColumnDef,
|
||||
flexRender,
|
||||
getCoreRowModel,
|
||||
useReactTable,
|
||||
} from "@tanstack/react-table";
|
||||
|
||||
interface DataTableProps<TData, TValue> {
|
||||
columns: ColumnDef<TData, TValue>[];
|
||||
data: TData[];
|
||||
slug: string[];
|
||||
columns: ColumnDef<TData, TValue>[];
|
||||
data: TData[];
|
||||
slug: string[];
|
||||
}
|
||||
|
||||
export function DataTable<TData, TValue>({
|
||||
columns,
|
||||
data,
|
||||
columns,
|
||||
data,
|
||||
}: DataTableProps<TData, TValue>) {
|
||||
const table = useReactTable({
|
||||
data,
|
||||
columns,
|
||||
getCoreRowModel: getCoreRowModel(),
|
||||
});
|
||||
const table = useReactTable({
|
||||
data,
|
||||
columns,
|
||||
getCoreRowModel: getCoreRowModel(),
|
||||
});
|
||||
|
||||
return (
|
||||
<>
|
||||
<Table>
|
||||
<TableHeader>
|
||||
{table.getHeaderGroups().map((headerGroup) => (
|
||||
<TableRow key={headerGroup.id}>
|
||||
{headerGroup.headers.map((header) => {
|
||||
return (
|
||||
<TableHead key={header.id}>
|
||||
{header.isPlaceholder
|
||||
? null
|
||||
: flexRender(
|
||||
header.column.columnDef.header,
|
||||
header.getContext()
|
||||
)}
|
||||
</TableHead>
|
||||
)
|
||||
})}
|
||||
</TableRow>
|
||||
))}
|
||||
</TableHeader>
|
||||
<TableBody>
|
||||
{table.getRowModel().rows?.length ? (
|
||||
table.getRowModel().rows.map((row) => (
|
||||
<TableRow
|
||||
key={row.id}
|
||||
data-state={row.getIsSelected() && "selected"}
|
||||
>
|
||||
{row.getVisibleCells().map((cell) => (
|
||||
<TableCell key={cell.id}>
|
||||
{flexRender(cell.column.columnDef.cell, cell.getContext())}
|
||||
</TableCell>
|
||||
))}
|
||||
</TableRow>
|
||||
))
|
||||
) : (
|
||||
<TableRow>
|
||||
<TableCell colSpan={columns.length} className="h-24 text-center">
|
||||
No results.
|
||||
</TableCell>
|
||||
</TableRow>
|
||||
)}
|
||||
</TableBody>
|
||||
</Table>
|
||||
</>
|
||||
);
|
||||
return (
|
||||
<>
|
||||
<Table>
|
||||
<TableHeader>
|
||||
{table.getHeaderGroups().map((headerGroup) => (
|
||||
<TableRow key={headerGroup.id}>
|
||||
{headerGroup.headers.map((header) => {
|
||||
return (
|
||||
<TableHead key={header.id}>
|
||||
{header.isPlaceholder
|
||||
? null
|
||||
: flexRender(
|
||||
header.column.columnDef.header,
|
||||
header.getContext(),
|
||||
)}
|
||||
</TableHead>
|
||||
);
|
||||
})}
|
||||
</TableRow>
|
||||
))}
|
||||
</TableHeader>
|
||||
<TableBody>
|
||||
{table.getRowModel().rows?.length ? (
|
||||
table.getRowModel().rows.map((row) => (
|
||||
<TableRow
|
||||
key={row.id}
|
||||
data-state={row.getIsSelected() && "selected"}
|
||||
>
|
||||
{row.getVisibleCells().map((cell) => (
|
||||
<TableCell key={cell.id}>
|
||||
{flexRender(cell.column.columnDef.cell, cell.getContext())}
|
||||
</TableCell>
|
||||
))}
|
||||
</TableRow>
|
||||
))
|
||||
) : (
|
||||
<TableRow>
|
||||
<TableCell colSpan={columns.length} className="h-24 text-center">
|
||||
No results.
|
||||
</TableCell>
|
||||
</TableRow>
|
||||
)}
|
||||
</TableBody>
|
||||
</Table>
|
||||
</>
|
||||
);
|
||||
}
|
||||
|
|
|
@ -1,33 +1,36 @@
|
|||
"use server";
|
||||
import { initConnectionPostgres, initConnectionSurreal } from "@/components/db-utils";
|
||||
import {
|
||||
initConnectionPostgres,
|
||||
initConnectionSurreal,
|
||||
} from "@/components/db-utils";
|
||||
|
||||
export async function querydb() {
|
||||
try {
|
||||
let stats = [];
|
||||
if (process.env.DB_TYPE === "surrealdb") {
|
||||
let db = await initConnectionSurreal();
|
||||
// console.log(db);
|
||||
stats = await db.query(`
|
||||
try {
|
||||
let stats = [];
|
||||
if (process.env.DB_TYPE === "surrealdb") {
|
||||
const db = await initConnectionSurreal();
|
||||
// console.log(db);
|
||||
stats = await db.query(`
|
||||
select * from url
|
||||
order by clicks desc
|
||||
limit 10;
|
||||
`);
|
||||
|
||||
// @ts-ignore
|
||||
stats = stats[0];
|
||||
}
|
||||
// @ts-ignore
|
||||
stats = stats[0];
|
||||
}
|
||||
|
||||
if (process.env.DB_TYPE === "postgres") {
|
||||
let sql = await initConnectionPostgres();
|
||||
stats = await sql`
|
||||
if (process.env.DB_TYPE === "postgres") {
|
||||
const sql = await initConnectionPostgres();
|
||||
stats = await sql`
|
||||
select * from url
|
||||
order by clicks desc
|
||||
limit 10;
|
||||
`;
|
||||
}
|
||||
}
|
||||
|
||||
return stats;
|
||||
} catch (e) {
|
||||
return [];
|
||||
}
|
||||
return stats;
|
||||
} catch (e) {
|
||||
return [];
|
||||
}
|
||||
}
|
||||
|
|
|
@ -1,19 +1,15 @@
|
|||
"use client";
|
||||
import CardGrid from "@/components/card-grid";
|
||||
import {
|
||||
Card,
|
||||
CardHeader,
|
||||
CardTitle
|
||||
} from "@/components/ui/card";
|
||||
import { Card, CardHeader, CardTitle } from "@/components/ui/card";
|
||||
|
||||
export default function Loading() {
|
||||
return (
|
||||
<CardGrid maxCols={1}>
|
||||
<Card>
|
||||
<CardHeader>
|
||||
<CardTitle className="text-1xl text-amber-400">Loading...</CardTitle>
|
||||
</CardHeader>
|
||||
</Card>
|
||||
</CardGrid>
|
||||
);
|
||||
return (
|
||||
<CardGrid maxCols={1}>
|
||||
<Card>
|
||||
<CardHeader>
|
||||
<CardTitle className="text-1xl text-amber-400">Loading...</CardTitle>
|
||||
</CardHeader>
|
||||
</Card>
|
||||
</CardGrid>
|
||||
);
|
||||
}
|
||||
|
|
|
@ -8,47 +8,47 @@ import { querydb } from "./db";
|
|||
import Loading from "./loading";
|
||||
|
||||
export default function StatsPage() {
|
||||
let [data, setData] = useState([]);
|
||||
let [data, setData] = useState([]);
|
||||
|
||||
useEffect(() => {
|
||||
const fetchData = async () => {
|
||||
const result = await querydb();
|
||||
// @ts-ignore
|
||||
setData(result);
|
||||
};
|
||||
fetchData();
|
||||
}, []);
|
||||
useEffect(() => {
|
||||
const fetchData = async () => {
|
||||
const result = await querydb();
|
||||
// @ts-ignore
|
||||
setData(result);
|
||||
};
|
||||
fetchData();
|
||||
}, []);
|
||||
|
||||
if (data.length !== 0 && data !== undefined && data !== null) {
|
||||
const formatDate = (dateString: string | number | Date) => {
|
||||
const date = new Date(dateString);
|
||||
const day = String(date.getDate()).padStart(2, '0');
|
||||
const month = String(date.getMonth() + 1).padStart(2, '0');
|
||||
const year = String(date.getFullYear()).slice(-2);
|
||||
return `${month}/${day}/${year}`;
|
||||
};
|
||||
if (data.length !== 0 && data !== undefined && data !== null) {
|
||||
const formatDate = (dateString: string | number | Date) => {
|
||||
const date = new Date(dateString);
|
||||
const day = String(date.getDate()).padStart(2, "0");
|
||||
const month = String(date.getMonth() + 1).padStart(2, "0");
|
||||
const year = String(date.getFullYear()).slice(-2);
|
||||
return `${month}/${day}/${year}`;
|
||||
};
|
||||
|
||||
// @ts-ignore
|
||||
data = data.map(item => ({
|
||||
// @ts-ignore
|
||||
...item,
|
||||
// @ts-ignore
|
||||
date_accessed: formatDate(item.date_accessed),
|
||||
// @ts-ignore
|
||||
date_added: formatDate(item.date_added),
|
||||
// @ts-ignore
|
||||
id: item.id.replace(/^url:\['(.*)'\]$/, '$1')
|
||||
}));
|
||||
}
|
||||
// @ts-ignore
|
||||
data = data.map((item) => ({
|
||||
// @ts-ignore
|
||||
...item,
|
||||
// @ts-ignore
|
||||
date_accessed: formatDate(item.date_accessed),
|
||||
// @ts-ignore
|
||||
date_added: formatDate(item.date_added),
|
||||
// @ts-ignore
|
||||
id: item.id.replace(/^url:\['(.*)'\]$/, "$1"),
|
||||
}));
|
||||
}
|
||||
|
||||
return data.length === 0 ? (
|
||||
<Loading />
|
||||
) : (
|
||||
<CardGrid maxCols={1}>
|
||||
<Card>
|
||||
{/* @ts-ignore */}
|
||||
<DataTable columns={columns} data={data} />
|
||||
</Card>
|
||||
</CardGrid>
|
||||
);
|
||||
return data.length === 0 ? (
|
||||
<Loading />
|
||||
) : (
|
||||
<CardGrid maxCols={1}>
|
||||
<Card>
|
||||
{/* @ts-ignore */}
|
||||
<DataTable columns={columns} data={data} />
|
||||
</Card>
|
||||
</CardGrid>
|
||||
);
|
||||
}
|
||||
|
|
|
@ -1,38 +1,36 @@
|
|||
"use client";
|
||||
|
||||
export default function CardGrid({
|
||||
maxCols: maxCols = 4,
|
||||
children,
|
||||
className,
|
||||
...props
|
||||
maxCols = 4,
|
||||
children,
|
||||
className,
|
||||
...props
|
||||
}: {
|
||||
maxCols?: number;
|
||||
children?: React.ReactNode;
|
||||
className?: string;
|
||||
maxCols?: number;
|
||||
children?: React.ReactNode;
|
||||
className?: string;
|
||||
}) {
|
||||
let baseClassName = `items-start justify-center gap-6 rounded-lg p-8 grid grid-cols-1`;
|
||||
let baseClassName =
|
||||
"items-start justify-center gap-6 rounded-lg p-8 grid grid-cols-1";
|
||||
|
||||
if (maxCols >= 2) {
|
||||
baseClassName += " lg:grid-cols-2";
|
||||
}
|
||||
if (maxCols >= 3) {
|
||||
baseClassName += " xl:grid-cols-3";
|
||||
}
|
||||
if (maxCols >= 4) {
|
||||
baseClassName += " 2xl:grid-cols-4";
|
||||
}
|
||||
if (maxCols >= 2) {
|
||||
baseClassName += " lg:grid-cols-2";
|
||||
}
|
||||
if (maxCols >= 3) {
|
||||
baseClassName += " xl:grid-cols-3";
|
||||
}
|
||||
if (maxCols >= 4) {
|
||||
baseClassName += " 2xl:grid-cols-4";
|
||||
}
|
||||
|
||||
if (className == undefined) {
|
||||
className = baseClassName;
|
||||
} else {
|
||||
className = baseClassName + " " + className;
|
||||
}
|
||||
return (
|
||||
<div
|
||||
className={`${className}`}
|
||||
{...props}
|
||||
>
|
||||
{children}
|
||||
</div>
|
||||
);
|
||||
if (className === undefined) {
|
||||
className = baseClassName;
|
||||
} else {
|
||||
className = `${baseClassName} ${className}`;
|
||||
}
|
||||
return (
|
||||
<div className={`${className}`} {...props}>
|
||||
{children}
|
||||
</div>
|
||||
);
|
||||
}
|
||||
|
|
|
@ -4,35 +4,35 @@ import { useTheme } from "next-themes";
|
|||
|
||||
import { Button } from "@/components/ui/button";
|
||||
import {
|
||||
DropdownMenu,
|
||||
DropdownMenuContent,
|
||||
DropdownMenuItem,
|
||||
DropdownMenuTrigger,
|
||||
DropdownMenu,
|
||||
DropdownMenuContent,
|
||||
DropdownMenuItem,
|
||||
DropdownMenuTrigger,
|
||||
} from "@/components/ui/dropdown-menu";
|
||||
|
||||
export default function ModeToggle() {
|
||||
const { setTheme } = useTheme();
|
||||
const { setTheme } = useTheme();
|
||||
|
||||
return (
|
||||
<DropdownMenu>
|
||||
<DropdownMenuTrigger asChild>
|
||||
<Button variant="outline" size="icon">
|
||||
<SunIcon className="h-[1.2rem] w-[1.2rem] rotate-0 scale-100 transition-all dark:-rotate-90 dark:scale-0" />
|
||||
<MoonIcon className="absolute h-[1.2rem] w-[1.2rem] rotate-90 scale-0 transition-all dark:rotate-0 dark:scale-100" />
|
||||
<span className="sr-only">Toggle theme</span>
|
||||
</Button>
|
||||
</DropdownMenuTrigger>
|
||||
<DropdownMenuContent align="end">
|
||||
<DropdownMenuItem onClick={() => setTheme("light")}>
|
||||
Light
|
||||
</DropdownMenuItem>
|
||||
<DropdownMenuItem onClick={() => setTheme("dark")}>
|
||||
Dark
|
||||
</DropdownMenuItem>
|
||||
<DropdownMenuItem onClick={() => setTheme("system")}>
|
||||
System
|
||||
</DropdownMenuItem>
|
||||
</DropdownMenuContent>
|
||||
</DropdownMenu>
|
||||
);
|
||||
return (
|
||||
<DropdownMenu>
|
||||
<DropdownMenuTrigger asChild>
|
||||
<Button variant="outline" size="icon">
|
||||
<SunIcon className="h-[1.2rem] w-[1.2rem] rotate-0 scale-100 transition-all dark:-rotate-90 dark:scale-0" />
|
||||
<MoonIcon className="absolute h-[1.2rem] w-[1.2rem] rotate-90 scale-0 transition-all dark:rotate-0 dark:scale-100" />
|
||||
<span className="sr-only">Toggle theme</span>
|
||||
</Button>
|
||||
</DropdownMenuTrigger>
|
||||
<DropdownMenuContent align="end">
|
||||
<DropdownMenuItem onClick={() => setTheme("light")}>
|
||||
Light
|
||||
</DropdownMenuItem>
|
||||
<DropdownMenuItem onClick={() => setTheme("dark")}>
|
||||
Dark
|
||||
</DropdownMenuItem>
|
||||
<DropdownMenuItem onClick={() => setTheme("system")}>
|
||||
System
|
||||
</DropdownMenuItem>
|
||||
</DropdownMenuContent>
|
||||
</DropdownMenu>
|
||||
);
|
||||
}
|
||||
|
|
|
@ -1,35 +1,35 @@
|
|||
"use server";
|
||||
import postgres, { Sql } from 'postgres';
|
||||
import postgres, { type Sql } from "postgres";
|
||||
import { Surreal } from "surrealdb.js";
|
||||
const db = new Surreal();
|
||||
|
||||
export async function initConnectionSurreal(): Promise<Surreal> {
|
||||
try {
|
||||
await db.connect("ws://" + process.env.DB_URL_PORT + "/rpc", {
|
||||
namespace: "url",
|
||||
database: "url",
|
||||
auth: {
|
||||
username: process.env.DB_USER || "root",
|
||||
password: process.env.DB_PASSWORD || "root",
|
||||
},
|
||||
});
|
||||
} catch (e) {
|
||||
console.error("ERROR", e);
|
||||
}
|
||||
try {
|
||||
await db.connect(`ws://${process.env.DB_URL_PORT}/rpc`, {
|
||||
namespace: "url",
|
||||
database: "url",
|
||||
auth: {
|
||||
username: process.env.DB_USER || "root",
|
||||
password: process.env.DB_PASSWORD || "root",
|
||||
},
|
||||
});
|
||||
} catch (e) {
|
||||
console.error("ERROR", e);
|
||||
}
|
||||
|
||||
return db;
|
||||
return db;
|
||||
}
|
||||
|
||||
export async function initConnectionPostgres(): Promise<Sql<{}>> {
|
||||
const DB_URL_PORT = (process.env.DB_URL_PORT || "postgres:5432").split(":");
|
||||
const sql = postgres({
|
||||
host: DB_URL_PORT[0],
|
||||
port: parseInt(DB_URL_PORT[1]),
|
||||
user: process.env.POSTGRES_USER || "root",
|
||||
password: process.env.POSTGRES_PASSWORD || "root",
|
||||
database: process.env.POSTGRES_DB || "url",
|
||||
max: 100,
|
||||
onnotice: () => { },
|
||||
});
|
||||
return sql;
|
||||
const DB_URL_PORT = (process.env.DB_URL_PORT || "postgres:5432").split(":");
|
||||
const sql = postgres({
|
||||
host: DB_URL_PORT[0],
|
||||
port: Number.parseInt(DB_URL_PORT[1]),
|
||||
user: process.env.POSTGRES_USER || "root",
|
||||
password: process.env.POSTGRES_PASSWORD || "root",
|
||||
database: process.env.POSTGRES_DB || "url",
|
||||
max: 100,
|
||||
onnotice: () => {},
|
||||
});
|
||||
return sql;
|
||||
}
|
||||
|
|
|
@ -3,28 +3,29 @@ import DarkModeToggle from "@/components/dark-mode-toggle";
|
|||
import Link from "next/link";
|
||||
|
||||
export default async function Nav() {
|
||||
return (
|
||||
<nav className="relative flex flex-row place-items-center gap-4 p-2 px-4 font-medium border-b">
|
||||
<div className="text-2xl">
|
||||
<Link href="/">Next Url Shortener</Link>
|
||||
</div>
|
||||
return (
|
||||
<nav className="relative flex flex-row place-items-center gap-4 p-2 px-4 font-medium border-b">
|
||||
<div className="text-2xl">
|
||||
<Link href="/">Next Url Shortener</Link>
|
||||
</div>
|
||||
|
||||
{/* <div className="transition-colors text-foreground/50 hover:text-foreground/100">
|
||||
{/* <div className="transition-colors text-foreground/50 hover:text-foreground/100">
|
||||
<Link href="/create">Create</Link>
|
||||
</div> */}
|
||||
|
||||
<div className="transition-colors text-foreground/50 hover:text-foreground/100">
|
||||
<Link href="/stats">Stats</Link>
|
||||
</div>
|
||||
<div className="transition-colors text-foreground/50 hover:text-foreground/100">
|
||||
<Link href="/stats">Stats</Link>
|
||||
</div>
|
||||
|
||||
<div className="flex flex-1 items-center justify-between gap-4 md:justify-end">
|
||||
<div className="transition-colors text-foreground/50 hover:text-foreground/100">
|
||||
<Link href="https://github.com/NexVeridian/next-url-shortener">
|
||||
GitHub
|
||||
</Link>
|
||||
</div>
|
||||
|
||||
<div className="flex flex-1 items-center justify-between gap-4 md:justify-end">
|
||||
<div className="transition-colors text-foreground/50 hover:text-foreground/100">
|
||||
<Link href="https://github.com/NexVeridian/next-url-shortener">GitHub</Link>
|
||||
</div>
|
||||
|
||||
<DarkModeToggle />
|
||||
</div>
|
||||
</nav>
|
||||
);
|
||||
<DarkModeToggle />
|
||||
</div>
|
||||
</nav>
|
||||
);
|
||||
}
|
||||
|
|
|
@ -1,7 +1,7 @@
|
|||
"use client";
|
||||
import { ThemeProvider as NextThemesProvider } from "next-themes";
|
||||
import { type ThemeProviderProps } from "next-themes/dist/types";
|
||||
import type { ThemeProviderProps } from "next-themes/dist/types";
|
||||
|
||||
export function ThemeProvider({ children, ...props }: ThemeProviderProps) {
|
||||
return <NextThemesProvider {...props}>{children}</NextThemesProvider>;
|
||||
return <NextThemesProvider {...props}>{children}</NextThemesProvider>;
|
||||
}
|
||||
|
|
|
@ -1,6 +1,6 @@
|
|||
import { type ClassValue, clsx } from "clsx"
|
||||
import { twMerge } from "tailwind-merge"
|
||||
|
||||
import { type ClassValue, clsx } from "clsx";
|
||||
import { twMerge } from "tailwind-merge";
|
||||
|
||||
export function cn(...inputs: ClassValue[]) {
|
||||
return twMerge(clsx(inputs))
|
||||
return twMerge(clsx(inputs));
|
||||
}
|
||||
|
|
Loading…
Add table
Add a link
Reference in a new issue