feat: add short url crud
This commit is contained in:
@@ -0,0 +1,3 @@
|
||||
export default function IndexPage() {
|
||||
return <></>;
|
||||
}
|
||||
@@ -1,6 +1,8 @@
|
||||
import { getUserRecordCount } from "@/actions/cloudflare-dns-record";
|
||||
import { GlobeLock, Link } from "lucide-react";
|
||||
import Link from "next/link";
|
||||
import { GlobeLock, Link as LinkIcon } from "lucide-react";
|
||||
|
||||
import { getUserRecordCount } from "@/lib/dto/cloudflare-dns-record";
|
||||
import { getUserShortUrlCount } from "@/lib/dto/short-urls";
|
||||
import { Card, CardContent, CardHeader, CardTitle } from "@/components/ui/card";
|
||||
import { Skeleton } from "@/components/ui/skeleton";
|
||||
|
||||
@@ -10,11 +12,15 @@ export async function DNSInfoCard({ userId }: { userId: string }) {
|
||||
return (
|
||||
<Card>
|
||||
<CardHeader className="flex flex-row items-center justify-between space-y-0 pb-2">
|
||||
<CardTitle className="text-sm font-medium">DNS Records</CardTitle>
|
||||
<CardTitle className="text-sm font-medium">
|
||||
<Link className="hover:underline" href="/dashboard/records">
|
||||
DNS Records
|
||||
</Link>
|
||||
</CardTitle>
|
||||
<GlobeLock className="size-4 text-muted-foreground" />
|
||||
</CardHeader>
|
||||
<CardContent>
|
||||
{[0, -1, undefined].includes(count) ? (
|
||||
{[-1, undefined].includes(count) ? (
|
||||
<Skeleton className="h-5 w-20" />
|
||||
) : (
|
||||
<div className="text-2xl font-bold">{count}</div>
|
||||
@@ -25,15 +31,24 @@ export async function DNSInfoCard({ userId }: { userId: string }) {
|
||||
);
|
||||
}
|
||||
|
||||
export function UrlsInfoCard({ userId }: { userId: string }) {
|
||||
export async function UrlsInfoCard({ userId }: { userId: string }) {
|
||||
const count = await getUserShortUrlCount(userId);
|
||||
return (
|
||||
<Card>
|
||||
<CardHeader className="flex flex-row items-center justify-between space-y-0 pb-2">
|
||||
<CardTitle className="text-sm font-medium">Short URLs</CardTitle>
|
||||
<Link className="size-4 text-muted-foreground" />
|
||||
<CardTitle className="text-sm font-medium">
|
||||
<Link className="hover:underline" href="/dashboard/urls">
|
||||
Short URLs
|
||||
</Link>
|
||||
</CardTitle>
|
||||
<LinkIcon className="size-4 text-muted-foreground" />
|
||||
</CardHeader>
|
||||
<CardContent>
|
||||
<div className="text-2xl font-bold">20</div>
|
||||
{[-1, undefined].includes(count) ? (
|
||||
<Skeleton className="h-5 w-20" />
|
||||
) : (
|
||||
<div className="text-2xl font-bold">{count}</div>
|
||||
)}
|
||||
<p className="text-xs text-muted-foreground">total</p>
|
||||
</CardContent>
|
||||
</Card>
|
||||
|
||||
@@ -18,7 +18,12 @@ export default async function DashboardPage() {
|
||||
|
||||
return (
|
||||
<>
|
||||
<DashboardHeader heading="DNS Records" text="List and manage records." />
|
||||
<DashboardHeader
|
||||
heading="Manage DNS Records"
|
||||
text="List and manage records."
|
||||
link="/docs/dns-records"
|
||||
linkText="DNS Records."
|
||||
/>
|
||||
<UserRecordsList user={{ id: user.id, name: user.name || "" }} />
|
||||
</>
|
||||
);
|
||||
|
||||
@@ -2,7 +2,6 @@
|
||||
|
||||
import { useState } from "react";
|
||||
import Link from "next/link";
|
||||
import { UserRecordFormData } from "@/actions/cloudflare-dns-record";
|
||||
import { User } from "@prisma/client";
|
||||
import {
|
||||
ArrowUpRight,
|
||||
@@ -13,6 +12,7 @@ import {
|
||||
import useSWR, { useSWRConfig } from "swr";
|
||||
|
||||
import { TTL_ENUMS } from "@/lib/cloudflare";
|
||||
import { UserRecordFormData } from "@/lib/dto/cloudflare-dns-record";
|
||||
import { fetcher } from "@/lib/utils";
|
||||
import { Badge } from "@/components/ui/badge";
|
||||
import { Button } from "@/components/ui/button";
|
||||
@@ -157,8 +157,9 @@ export default function UserRecordsList({ user }: RecordListProps) {
|
||||
<TableBody>
|
||||
{isLoading ? (
|
||||
<>
|
||||
<TableColumnSekleton className="col-span-1" />
|
||||
<TableColumnSekleton className="col-span-1" />
|
||||
<TableColumnSekleton />
|
||||
<TableColumnSekleton />
|
||||
<TableColumnSekleton />
|
||||
</>
|
||||
) : data && data.length > 0 ? (
|
||||
data.map((record) => (
|
||||
@@ -201,7 +202,7 @@ export default function UserRecordsList({ user }: RecordListProps) {
|
||||
))
|
||||
) : (
|
||||
<EmptyPlaceholder>
|
||||
<EmptyPlaceholder.Icon name="globeLock" />
|
||||
{/* <EmptyPlaceholder.Icon name="globeLock" /> */}
|
||||
<EmptyPlaceholder.Title>No records</EmptyPlaceholder.Title>
|
||||
<EmptyPlaceholder.Description>
|
||||
You don't have any record yet. Start creating record.
|
||||
|
||||
@@ -4,7 +4,7 @@ import { getCurrentUser } from "@/lib/session";
|
||||
import { constructMetadata } from "@/lib/utils";
|
||||
import { DashboardHeader } from "@/components/dashboard/header";
|
||||
|
||||
import UserRecordsList from "./url-list";
|
||||
import UserUrlsList from "./url-list";
|
||||
|
||||
export const metadata = constructMetadata({
|
||||
title: "Short URLs - WRDO",
|
||||
@@ -19,9 +19,12 @@ export default async function DashboardPage() {
|
||||
return (
|
||||
<>
|
||||
<DashboardHeader
|
||||
heading="Short URLs"
|
||||
heading="Manage Short URLs"
|
||||
text="List and manage short urls."
|
||||
link="/docs/short-urls"
|
||||
linkText="Short urls."
|
||||
/>
|
||||
<UserUrlsList user={{ id: user.id, name: user.name || "" }} />
|
||||
</>
|
||||
);
|
||||
}
|
||||
|
||||
@@ -2,17 +2,12 @@
|
||||
|
||||
import { useState } from "react";
|
||||
import Link from "next/link";
|
||||
import { UserRecordFormData } from "@/actions/cloudflare-dns-record";
|
||||
import { User } from "@prisma/client";
|
||||
import {
|
||||
ArrowUpRight,
|
||||
DotSquareIcon,
|
||||
PenLine,
|
||||
RefreshCwIcon,
|
||||
} from "lucide-react";
|
||||
import { PenLine, RefreshCwIcon } from "lucide-react";
|
||||
import useSWR, { useSWRConfig } from "swr";
|
||||
|
||||
import { TTL_ENUMS } from "@/lib/cloudflare";
|
||||
import { ShortUrlFormData } from "@/lib/dto/short-urls";
|
||||
import { fetcher } from "@/lib/utils";
|
||||
import { Badge } from "@/components/ui/badge";
|
||||
import { Button } from "@/components/ui/button";
|
||||
@@ -33,23 +28,24 @@ import {
|
||||
TableRow,
|
||||
} from "@/components/ui/table";
|
||||
import StatusDot from "@/components/dashboard/status-dot";
|
||||
import { FormType, RecordForm } from "@/components/forms/record-form";
|
||||
import { FormType } from "@/components/forms/record-form";
|
||||
import { UrlForm } from "@/components/forms/url-form";
|
||||
import { EmptyPlaceholder } from "@/components/shared/empty-placeholder";
|
||||
|
||||
export interface RecordListProps {
|
||||
export interface UrlListProps {
|
||||
user: Pick<User, "id" | "name">;
|
||||
}
|
||||
|
||||
function TableColumnSekleton({ className }: { className?: string }) {
|
||||
return (
|
||||
<TableRow className="grid grid-cols-3 items-center sm:grid-cols-5">
|
||||
<TableCell className="col-span-1">
|
||||
<TableRow className="grid grid-cols-5 items-center sm:grid-cols-7">
|
||||
<TableCell className="col-span-2">
|
||||
<Skeleton className="h-5 w-24" />
|
||||
</TableCell>
|
||||
<TableCell className="col-span-1 hidden sm:inline-block">
|
||||
<TableCell className="col-span-2">
|
||||
<Skeleton className="h-5 w-24" />
|
||||
</TableCell>
|
||||
<TableCell className="col-span-1 hidden sm:inline-block">
|
||||
<TableCell className="col-span-1 hidden justify-center sm:flex">
|
||||
<Skeleton className="h-5 w-16" />
|
||||
</TableCell>
|
||||
<TableCell className="col-span-1 hidden justify-center sm:flex">
|
||||
@@ -62,15 +58,16 @@ function TableColumnSekleton({ className }: { className?: string }) {
|
||||
);
|
||||
}
|
||||
|
||||
export default function UserUrlsList({ user }: RecordListProps) {
|
||||
export default function UserUrlsList({ user }: UrlListProps) {
|
||||
const [isShowForm, setShowForm] = useState(false);
|
||||
const [formType, setFormType] = useState<FormType>("add");
|
||||
const [currentEditRecord, setCurrentEditRecord] =
|
||||
useState<UserRecordFormData | null>(null);
|
||||
const [currentEditUrl, setCurrentEditUrl] = useState<ShortUrlFormData | null>(
|
||||
null,
|
||||
);
|
||||
|
||||
const { mutate } = useSWRConfig();
|
||||
const { data, error, isLoading } = useSWR<UserRecordFormData[]>(
|
||||
"/api/record",
|
||||
const { data, error, isLoading } = useSWR<ShortUrlFormData[]>(
|
||||
"/api/url",
|
||||
fetcher,
|
||||
{
|
||||
revalidateOnFocus: false,
|
||||
@@ -78,7 +75,7 @@ export default function UserUrlsList({ user }: RecordListProps) {
|
||||
);
|
||||
|
||||
const handleRefresh = () => {
|
||||
// mutate("/api/record", undefined);
|
||||
mutate("/api/url", undefined);
|
||||
};
|
||||
|
||||
return (
|
||||
@@ -107,10 +104,10 @@ export default function UserUrlsList({ user }: RecordListProps) {
|
||||
className="w-[120px] shrink-0 gap-1"
|
||||
variant="default"
|
||||
onClick={() => {
|
||||
// setCurrentEditRecord(null);
|
||||
// setShowForm(false);
|
||||
// setFormType("add");
|
||||
// setShowForm(!isShowForm);
|
||||
setCurrentEditUrl(null);
|
||||
setShowForm(false);
|
||||
setFormType("add");
|
||||
setShowForm(!isShowForm);
|
||||
}}
|
||||
>
|
||||
Add url
|
||||
@@ -118,30 +115,30 @@ export default function UserUrlsList({ user }: RecordListProps) {
|
||||
</div>
|
||||
</CardHeader>
|
||||
<CardContent>
|
||||
{/* {isShowForm && (
|
||||
<RecordForm
|
||||
{isShowForm && (
|
||||
<UrlForm
|
||||
user={{ id: user.id, name: user.name || "" }}
|
||||
isShowForm={isShowForm}
|
||||
setShowForm={setShowForm}
|
||||
type={formType}
|
||||
initData={currentEditRecord}
|
||||
initData={currentEditUrl}
|
||||
onRefresh={handleRefresh}
|
||||
/>
|
||||
)} */}
|
||||
)}
|
||||
<Table>
|
||||
<TableHeader>
|
||||
<TableRow className="grid grid-cols-3 items-center sm:grid-cols-5">
|
||||
<TableHead className="col-span-1 flex items-center font-bold">
|
||||
<TableRow className="grid grid-cols-5 items-center sm:grid-cols-7">
|
||||
<TableHead className="col-span-2 flex items-center font-bold">
|
||||
Target
|
||||
</TableHead>
|
||||
<TableHead className="col-span-1 hidden items-center font-bold sm:flex">
|
||||
<TableHead className="col-span-2 flex items-center font-bold">
|
||||
Url
|
||||
</TableHead>
|
||||
<TableHead className="col-span-1 hidden items-center font-bold sm:flex">
|
||||
Clicks
|
||||
<TableHead className="col-span-1 hidden items-center justify-center font-bold sm:flex">
|
||||
Visible
|
||||
</TableHead>
|
||||
<TableHead className="col-span-1 hidden items-center justify-center font-bold sm:flex">
|
||||
Public
|
||||
Active
|
||||
</TableHead>
|
||||
<TableHead className="col-span-1 flex items-center justify-center font-bold">
|
||||
Actions
|
||||
@@ -149,17 +146,78 @@ export default function UserUrlsList({ user }: RecordListProps) {
|
||||
</TableRow>
|
||||
</TableHeader>
|
||||
<TableBody>
|
||||
<TableColumnSekleton />
|
||||
{/* <EmptyPlaceholder>
|
||||
<EmptyPlaceholder.Icon name="link" />
|
||||
<EmptyPlaceholder.Title>No urls</EmptyPlaceholder.Title>
|
||||
<EmptyPlaceholder.Description>
|
||||
You don't have any url yet. Start creating url.
|
||||
</EmptyPlaceholder.Description>
|
||||
<Button className="w-[120px] shrink-0 gap-1" variant="default">
|
||||
Add url
|
||||
</Button>
|
||||
</EmptyPlaceholder> */}
|
||||
{isLoading ? (
|
||||
<>
|
||||
<TableColumnSekleton />
|
||||
<TableColumnSekleton />
|
||||
<TableColumnSekleton />
|
||||
</>
|
||||
) : data && data.length > 0 ? (
|
||||
data.map((short) => (
|
||||
<TableRow className="grid animate-fade-in grid-cols-5 items-center animate-in sm:grid-cols-7">
|
||||
<TableCell className="col-span-2">
|
||||
<Link
|
||||
className="font-semibold text-slate-600 hover:text-blue-400 hover:underline"
|
||||
href={short.target}
|
||||
target="_blank"
|
||||
>
|
||||
{short.target}
|
||||
</Link>
|
||||
</TableCell>
|
||||
<TableCell className="col-span-2">
|
||||
<Link
|
||||
className="font-semibold text-slate-600 after:content-['_↗'] hover:text-blue-400 hover:underline"
|
||||
href={short.url}
|
||||
target="_blank"
|
||||
>
|
||||
{short.url.slice(16)}
|
||||
</Link>
|
||||
</TableCell>
|
||||
<TableCell className="col-span-1 hidden justify-center font-semibold sm:flex">
|
||||
{short.visible === 1 ? "Public" : "Private"}
|
||||
</TableCell>
|
||||
<TableCell className="col-span-1 hidden justify-center sm:flex">
|
||||
<StatusDot status={short.active} />
|
||||
</TableCell>
|
||||
<TableCell className="col-span-1 flex justify-center">
|
||||
<Button
|
||||
className="text-sm hover:bg-slate-100"
|
||||
size="sm"
|
||||
variant={"outline"}
|
||||
onClick={() => {
|
||||
setCurrentEditUrl(short);
|
||||
setShowForm(false);
|
||||
setFormType("edit");
|
||||
setShowForm(!isShowForm);
|
||||
}}
|
||||
>
|
||||
<p>Edit</p>
|
||||
<PenLine className="ml-1 h-4 w-4" />
|
||||
</Button>
|
||||
</TableCell>
|
||||
</TableRow>
|
||||
))
|
||||
) : (
|
||||
<EmptyPlaceholder>
|
||||
{/* <EmptyPlaceholder.Icon name="link" /> */}
|
||||
<EmptyPlaceholder.Title>No urls</EmptyPlaceholder.Title>
|
||||
<EmptyPlaceholder.Description>
|
||||
You don't have any url yet. Start creating url.
|
||||
</EmptyPlaceholder.Description>
|
||||
<Button
|
||||
className="w-[120px] shrink-0 gap-1"
|
||||
variant="default"
|
||||
onClick={() => {
|
||||
setCurrentEditUrl(null);
|
||||
setShowForm(false);
|
||||
setFormType("add");
|
||||
setShowForm(!isShowForm);
|
||||
}}
|
||||
>
|
||||
Add url
|
||||
</Button>
|
||||
</EmptyPlaceholder>
|
||||
)}
|
||||
</TableBody>
|
||||
</Table>
|
||||
</CardContent>
|
||||
|
||||
+12
-12
@@ -1,13 +1,12 @@
|
||||
import { env } from "@/env.mjs";
|
||||
import { createDNSRecord } from "@/lib/cloudflare";
|
||||
import {
|
||||
createUserRecord,
|
||||
getUserRecordByTypeNameContent,
|
||||
getUserRecordCount,
|
||||
} from "@/actions/cloudflare-dns-record";
|
||||
|
||||
import { env } from "@/env.mjs";
|
||||
import { createDNSRecord } from "@/lib/cloudflare";
|
||||
} from "@/lib/dto/cloudflare-dns-record";
|
||||
import { checkUserStatus } from "@/lib/dto/user";
|
||||
import { getCurrentUser } from "@/lib/session";
|
||||
import { checkUserStatus } from "@/lib/user";
|
||||
import { generateSecret } from "@/lib/utils";
|
||||
|
||||
export async function POST(req: Request) {
|
||||
@@ -28,13 +27,6 @@ export async function POST(req: Request) {
|
||||
statusText: "API key、zone iD and email are required",
|
||||
});
|
||||
}
|
||||
const { records } = await req.json();
|
||||
const record = {
|
||||
...records[0],
|
||||
id: generateSecret(16),
|
||||
type: "CNAME",
|
||||
proxied: false,
|
||||
};
|
||||
|
||||
// check quota
|
||||
const user_records_count = await getUserRecordCount(user.id);
|
||||
@@ -48,6 +40,14 @@ export async function POST(req: Request) {
|
||||
});
|
||||
}
|
||||
|
||||
const { records } = await req.json();
|
||||
const record = {
|
||||
...records[0],
|
||||
id: generateSecret(16),
|
||||
type: "CNAME",
|
||||
proxied: false,
|
||||
};
|
||||
|
||||
const user_record = await getUserRecordByTypeNameContent(
|
||||
user.id,
|
||||
record.type,
|
||||
|
||||
@@ -1,9 +1,8 @@
|
||||
import { deleteUserRecord } from "@/actions/cloudflare-dns-record";
|
||||
|
||||
import { env } from "@/env.mjs";
|
||||
import { deleteDNSRecord } from "@/lib/cloudflare";
|
||||
import { deleteUserRecord } from "@/lib/dto/cloudflare-dns-record";
|
||||
import { checkUserStatus } from "@/lib/dto/user";
|
||||
import { getCurrentUser } from "@/lib/session";
|
||||
import { checkUserStatus } from "@/lib/user";
|
||||
|
||||
export async function POST(req: Request) {
|
||||
try {
|
||||
@@ -30,7 +29,7 @@ export async function POST(req: Request) {
|
||||
if (res && res.result?.id) {
|
||||
// Then delete user record.
|
||||
await deleteUserRecord(user.id, record_id, zone_id, active);
|
||||
return Response.json({
|
||||
return Response.json("success", {
|
||||
status: 200,
|
||||
statusText: "success",
|
||||
});
|
||||
|
||||
@@ -1,8 +1,7 @@
|
||||
import { getUserRecords } from "@/actions/cloudflare-dns-record";
|
||||
|
||||
import { env } from "@/env.mjs";
|
||||
import { getUserRecords } from "@/lib/dto/cloudflare-dns-record";
|
||||
import { checkUserStatus } from "@/lib/dto/user";
|
||||
import { getCurrentUser } from "@/lib/session";
|
||||
import { checkUserStatus } from "@/lib/user";
|
||||
|
||||
export async function GET(req: Request) {
|
||||
try {
|
||||
|
||||
@@ -1,9 +1,8 @@
|
||||
import { updateUserRecord } from "@/actions/cloudflare-dns-record";
|
||||
|
||||
import { env } from "@/env.mjs";
|
||||
import { updateDNSRecord } from "@/lib/cloudflare";
|
||||
import { updateUserRecord } from "@/lib/dto/cloudflare-dns-record";
|
||||
import { checkUserStatus } from "@/lib/dto/user";
|
||||
import { getCurrentUser } from "@/lib/session";
|
||||
import { checkUserStatus } from "@/lib/user";
|
||||
|
||||
export async function POST(req: Request) {
|
||||
try {
|
||||
|
||||
@@ -0,0 +1,36 @@
|
||||
import { env } from "@/env.mjs";
|
||||
import { createUserShortUrl } from "@/lib/dto/short-urls";
|
||||
import { checkUserStatus } from "@/lib/dto/user";
|
||||
import { getCurrentUser } from "@/lib/session";
|
||||
import { createUrlSchema } from "@/lib/validations/url";
|
||||
|
||||
export async function POST(req: Request) {
|
||||
try {
|
||||
const user = checkUserStatus(await getCurrentUser());
|
||||
if (user instanceof Response) return user;
|
||||
|
||||
const { data } = await req.json();
|
||||
|
||||
const { target, url, visible, active } = createUrlSchema.parse(data);
|
||||
const res = await createUserShortUrl({
|
||||
userId: user.id,
|
||||
userName: user.name || "Anonymous",
|
||||
target,
|
||||
url,
|
||||
visible,
|
||||
active,
|
||||
});
|
||||
if (res.status !== "success") {
|
||||
return Response.json(res.status, {
|
||||
status: 502,
|
||||
statusText: `An error occurred. ${res.status}`,
|
||||
});
|
||||
}
|
||||
return Response.json(res.data);
|
||||
} catch (error) {
|
||||
return Response.json(error?.statusText || error, {
|
||||
status: error.status || 500,
|
||||
statusText: error.statusText || "Server error",
|
||||
});
|
||||
}
|
||||
}
|
||||
@@ -0,0 +1,28 @@
|
||||
import { env } from "@/env.mjs";
|
||||
import { getUserRecords } from "@/lib/dto/cloudflare-dns-record";
|
||||
import { deleteUserShortUrl } from "@/lib/dto/short-urls";
|
||||
import { checkUserStatus } from "@/lib/dto/user";
|
||||
import { getCurrentUser } from "@/lib/session";
|
||||
|
||||
export async function POST(req: Request) {
|
||||
try {
|
||||
const user = checkUserStatus(await getCurrentUser());
|
||||
if (user instanceof Response) return user;
|
||||
|
||||
const { url_id } = await req.json();
|
||||
if (!url_id) {
|
||||
return Response.json("url id is required", {
|
||||
status: 400,
|
||||
statusText: "url id is required",
|
||||
});
|
||||
}
|
||||
|
||||
await deleteUserShortUrl(user.id, url_id);
|
||||
return Response.json("success");
|
||||
} catch (error) {
|
||||
return Response.json(error?.statusText || error, {
|
||||
status: error.status || 500,
|
||||
statusText: error.statusText || "Server error",
|
||||
});
|
||||
}
|
||||
}
|
||||
@@ -0,0 +1,21 @@
|
||||
import { env } from "@/env.mjs";
|
||||
import { getUserShortUrls } from "@/lib/dto/short-urls";
|
||||
import { checkUserStatus } from "@/lib/dto/user";
|
||||
import { getCurrentUser } from "@/lib/session";
|
||||
|
||||
export async function GET(req: Request) {
|
||||
try {
|
||||
const user = checkUserStatus(await getCurrentUser());
|
||||
if (user instanceof Response) return user;
|
||||
|
||||
const user_urls = await getUserShortUrls(user.id, 1);
|
||||
// TODO: get meta info
|
||||
|
||||
return Response.json(user_urls);
|
||||
} catch (error) {
|
||||
return Response.json(error?.statusText || error, {
|
||||
status: error.status || 500,
|
||||
statusText: error.statusText || "Server error",
|
||||
});
|
||||
}
|
||||
}
|
||||
@@ -0,0 +1,44 @@
|
||||
import { env } from "@/env.mjs";
|
||||
import { getUserRecords } from "@/lib/dto/cloudflare-dns-record";
|
||||
import { updateUserShortUrl } from "@/lib/dto/short-urls";
|
||||
import { checkUserStatus } from "@/lib/dto/user";
|
||||
import { getCurrentUser } from "@/lib/session";
|
||||
import { createUrlSchema } from "@/lib/validations/url";
|
||||
|
||||
export async function POST(req: Request) {
|
||||
try {
|
||||
const user = checkUserStatus(await getCurrentUser());
|
||||
if (user instanceof Response) return user;
|
||||
|
||||
const { data } = await req.json();
|
||||
if (!data?.id) {
|
||||
return Response.json(`Url id is required`, {
|
||||
status: 400,
|
||||
statusText: `Url id is required`,
|
||||
});
|
||||
}
|
||||
|
||||
const { target, url, visible, active, id } = createUrlSchema.parse(data);
|
||||
const res = await updateUserShortUrl({
|
||||
id,
|
||||
userId: user.id,
|
||||
userName: user.name || "Anonymous",
|
||||
target,
|
||||
url,
|
||||
visible,
|
||||
active,
|
||||
});
|
||||
if (res.status !== "success") {
|
||||
return Response.json(res.status, {
|
||||
status: 400,
|
||||
statusText: `An error occurred. ${res.status}`,
|
||||
});
|
||||
}
|
||||
return Response.json(res.data);
|
||||
} catch (error) {
|
||||
return Response.json(error?.statusText || error, {
|
||||
status: error.status || 500,
|
||||
statusText: error.statusText || "Server error",
|
||||
});
|
||||
}
|
||||
}
|
||||
@@ -4,7 +4,7 @@ import { UserRole } from "@prisma/client";
|
||||
import NextAuth, { type DefaultSession } from "next-auth";
|
||||
|
||||
import { prisma } from "@/lib/db";
|
||||
import { getUserById } from "@/lib/user";
|
||||
import { getUserById } from "@/lib/dto/user";
|
||||
|
||||
// More info: https://authjs.dev/getting-started/typescript#module-augmentation
|
||||
declare module "next-auth" {
|
||||
|
||||
@@ -1,19 +1,41 @@
|
||||
import Link from "next/link";
|
||||
|
||||
interface DashboardHeaderProps {
|
||||
heading: string;
|
||||
text?: string;
|
||||
link?: string;
|
||||
linkText?: string;
|
||||
children?: React.ReactNode;
|
||||
}
|
||||
|
||||
export function DashboardHeader({
|
||||
heading,
|
||||
text,
|
||||
link,
|
||||
linkText,
|
||||
children,
|
||||
}: DashboardHeaderProps) {
|
||||
return (
|
||||
<div className="flex items-center justify-between">
|
||||
<div className="grid gap-1">
|
||||
<h1 className="font-heading text-2xl font-semibold">{heading}</h1>
|
||||
{text && <p className="text-base text-muted-foreground">{text}</p>}
|
||||
|
||||
<p className="text-sm text-muted-foreground">
|
||||
{text && <span>{text}</span>}
|
||||
{link && (
|
||||
<span>
|
||||
{" "}
|
||||
See documentation about{" "}
|
||||
<Link
|
||||
href={link}
|
||||
target="_blank"
|
||||
className="font-semibold after:content-['_↗'] hover:text-blue-500 hover:underline"
|
||||
>
|
||||
{linkText}
|
||||
</Link>
|
||||
</span>
|
||||
)}
|
||||
</p>
|
||||
</div>
|
||||
{children}
|
||||
</div>
|
||||
|
||||
@@ -1,13 +1,6 @@
|
||||
"use client";
|
||||
|
||||
import {
|
||||
Dispatch,
|
||||
SetStateAction,
|
||||
useEffect,
|
||||
useState,
|
||||
useTransition,
|
||||
} from "react";
|
||||
import { UserRecordFormData } from "@/actions/cloudflare-dns-record";
|
||||
import { Dispatch, SetStateAction, useState, useTransition } from "react";
|
||||
import { zodResolver } from "@hookform/resolvers/zod";
|
||||
import { User } from "@prisma/client";
|
||||
import { useForm } from "react-hook-form";
|
||||
@@ -19,6 +12,7 @@ import {
|
||||
RecordType,
|
||||
TTL_ENUMS,
|
||||
} from "@/lib/cloudflare";
|
||||
import { UserRecordFormData } from "@/lib/dto/cloudflare-dns-record";
|
||||
import { createRecordSchema } from "@/lib/validations/record";
|
||||
import { Button } from "@/components/ui/button";
|
||||
import { Input } from "@/components/ui/input";
|
||||
|
||||
@@ -0,0 +1,266 @@
|
||||
"use client";
|
||||
|
||||
import { Dispatch, SetStateAction, useTransition } from "react";
|
||||
import { zodResolver } from "@hookform/resolvers/zod";
|
||||
import { User } from "@prisma/client";
|
||||
import { useForm } from "react-hook-form";
|
||||
import { toast } from "sonner";
|
||||
|
||||
import { ShortUrlFormData } from "@/lib/dto/short-urls";
|
||||
import { createUrlSchema } from "@/lib/validations/url";
|
||||
import { Button } from "@/components/ui/button";
|
||||
import { Input } from "@/components/ui/input";
|
||||
import { Label } from "@/components/ui/label";
|
||||
import { Icons } from "@/components/shared/icons";
|
||||
|
||||
import { FormSectionColumns } from "../dashboard/form-section-columns";
|
||||
import { Switch } from "../ui/switch";
|
||||
|
||||
export type FormData = ShortUrlFormData;
|
||||
|
||||
export type FormType = "add" | "edit";
|
||||
|
||||
export interface RecordFormProps {
|
||||
user: Pick<User, "id" | "name">;
|
||||
isShowForm: boolean;
|
||||
setShowForm: Dispatch<SetStateAction<boolean>>;
|
||||
type: FormType;
|
||||
initData?: ShortUrlFormData | null;
|
||||
onRefresh: () => void;
|
||||
}
|
||||
|
||||
export function UrlForm({
|
||||
setShowForm,
|
||||
type,
|
||||
initData,
|
||||
onRefresh,
|
||||
}: RecordFormProps) {
|
||||
const [isPending, startTransition] = useTransition();
|
||||
const [isDeleting, startDeleteTransition] = useTransition();
|
||||
|
||||
const {
|
||||
handleSubmit,
|
||||
register,
|
||||
formState: { errors },
|
||||
getValues,
|
||||
setValue,
|
||||
} = useForm<FormData>({
|
||||
resolver: zodResolver(createUrlSchema),
|
||||
defaultValues: {
|
||||
id: initData?.id || "",
|
||||
target: initData?.target || "",
|
||||
url: initData?.url || "",
|
||||
active: initData?.active || 1,
|
||||
visible: initData?.visible || 0,
|
||||
},
|
||||
});
|
||||
|
||||
const onSubmit = handleSubmit((data) => {
|
||||
if (type === "add") {
|
||||
handleCreateUrl(data);
|
||||
} else if (type === "edit") {
|
||||
handleUpdateUrl(data);
|
||||
}
|
||||
});
|
||||
|
||||
const handleCreateUrl = async (data: ShortUrlFormData) => {
|
||||
startTransition(async () => {
|
||||
const response = await fetch("/api/url/add", {
|
||||
method: "POST",
|
||||
body: JSON.stringify({
|
||||
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);
|
||||
onRefresh();
|
||||
}
|
||||
});
|
||||
};
|
||||
|
||||
const handleUpdateUrl = async (data: ShortUrlFormData) => {
|
||||
startTransition(async () => {
|
||||
if (type === "edit") {
|
||||
const response = await fetch("/api/url/update", {
|
||||
method: "POST",
|
||||
body: JSON.stringify({ 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);
|
||||
onRefresh();
|
||||
}
|
||||
}
|
||||
});
|
||||
};
|
||||
|
||||
const handleDeleteUrl = async () => {
|
||||
if (type === "edit") {
|
||||
startDeleteTransition(async () => {
|
||||
const response = await fetch("/api/url/delete", {
|
||||
method: "POST",
|
||||
body: JSON.stringify({ url_id: initData?.id }),
|
||||
});
|
||||
if (!response.ok || response.status !== 200) {
|
||||
toast.error("Delete Failed", {
|
||||
description: response.statusText,
|
||||
});
|
||||
} else {
|
||||
await response.json();
|
||||
toast.success(`Success`);
|
||||
setShowForm(false);
|
||||
onRefresh();
|
||||
}
|
||||
});
|
||||
}
|
||||
};
|
||||
|
||||
return (
|
||||
<form
|
||||
className="rounded-lg border border-dashed p-4 shadow-sm animate-in fade-in-50"
|
||||
onSubmit={onSubmit}
|
||||
>
|
||||
<div className="items-center justify-start gap-4 md:flex">
|
||||
<FormSectionColumns title="Target">
|
||||
<div className="flex w-full items-center gap-2">
|
||||
<Label className="sr-only" htmlFor="target">
|
||||
Target
|
||||
</Label>
|
||||
<Input
|
||||
id="target"
|
||||
className="flex-1 shadow-inner"
|
||||
size={32}
|
||||
{...register("target")}
|
||||
/>
|
||||
</div>
|
||||
<div className="flex flex-col justify-between p-1">
|
||||
{errors?.target ? (
|
||||
<p className="pb-0.5 text-[13px] text-red-600">
|
||||
{errors.target.message}
|
||||
</p>
|
||||
) : (
|
||||
<p className="pb-0.5 text-[13px] text-muted-foreground">
|
||||
Required. https://your-origin-url
|
||||
</p>
|
||||
)}
|
||||
</div>
|
||||
</FormSectionColumns>
|
||||
<FormSectionColumns title="Url">
|
||||
<div className="flex w-full items-center gap-2">
|
||||
<Label className="sr-only" htmlFor="url">
|
||||
Url
|
||||
</Label>
|
||||
|
||||
<div className="relative flex items-center">
|
||||
<span className="pointer-events-none absolute left-3 whitespace-nowrap text-sm text-gray-400">
|
||||
https://wr.do/s/
|
||||
</span>
|
||||
<Input
|
||||
id="url"
|
||||
className="flex-1 pl-[116px] shadow-inner"
|
||||
size={20}
|
||||
{...register("url")}
|
||||
/>
|
||||
</div>
|
||||
</div>
|
||||
<div className="flex flex-col justify-between p-1">
|
||||
{errors?.url ? (
|
||||
<p className="pb-0.5 text-[13px] text-red-600">
|
||||
{errors.url.message}
|
||||
</p>
|
||||
) : (
|
||||
<p className="pb-0.5 text-[13px] text-muted-foreground">
|
||||
A random url.
|
||||
</p>
|
||||
)}
|
||||
</div>
|
||||
</FormSectionColumns>
|
||||
</div>
|
||||
|
||||
<div className="items-center justify-start gap-4 md:flex">
|
||||
<FormSectionColumns title="Visible">
|
||||
<div className="flex w-full items-center gap-2">
|
||||
<Label className="sr-only" htmlFor="visible">
|
||||
Visible
|
||||
</Label>
|
||||
<Switch
|
||||
id="visible"
|
||||
{...register("visible")}
|
||||
defaultChecked={initData?.visible === 1 || false}
|
||||
onCheckedChange={(value) => setValue("visible", value ? 1 : 0)}
|
||||
/>
|
||||
</div>
|
||||
<p className="p-1 text-[13px] text-muted-foreground">
|
||||
Public or private short url.
|
||||
</p>
|
||||
</FormSectionColumns>
|
||||
<FormSectionColumns title="Active">
|
||||
<div className="flex w-full items-center gap-2">
|
||||
<Label className="sr-only" htmlFor="active">
|
||||
Active
|
||||
</Label>
|
||||
<Switch
|
||||
id="active"
|
||||
{...register("active")}
|
||||
defaultChecked={initData?.active === 1 || true}
|
||||
onCheckedChange={(value) => setValue("active", value ? 1 : 0)}
|
||||
/>
|
||||
</div>
|
||||
<p className="p-1 text-[13px] text-muted-foreground">
|
||||
Enable or disable short url.
|
||||
</p>
|
||||
</FormSectionColumns>
|
||||
</div>
|
||||
|
||||
{/* Action buttons */}
|
||||
<div className="flex justify-end gap-3">
|
||||
{type === "edit" && (
|
||||
<Button
|
||||
type="button"
|
||||
variant="destructive"
|
||||
className="mr-auto w-[80px] px-0"
|
||||
onClick={() => handleDeleteUrl()}
|
||||
disabled={isDeleting}
|
||||
>
|
||||
{isDeleting ? (
|
||||
<Icons.spinner className="size-4 animate-spin" />
|
||||
) : (
|
||||
<p>Delete</p>
|
||||
)}
|
||||
</Button>
|
||||
)}
|
||||
<Button
|
||||
type="reset"
|
||||
variant="outline"
|
||||
className="w-[80px] px-0"
|
||||
onClick={() => setShowForm(false)}
|
||||
>
|
||||
Cancle
|
||||
</Button>
|
||||
<Button
|
||||
type="submit"
|
||||
variant="default"
|
||||
disabled={isPending}
|
||||
className="w-[80px] shrink-0 px-0"
|
||||
>
|
||||
{isPending ? (
|
||||
<Icons.spinner className="size-4 animate-spin" />
|
||||
) : (
|
||||
<p>{type === "edit" ? "Update" : "Save"}</p>
|
||||
)}
|
||||
</Button>
|
||||
</div>
|
||||
</form>
|
||||
);
|
||||
}
|
||||
@@ -0,0 +1,127 @@
|
||||
import { auth } from "@/auth";
|
||||
import { UrlMeta } from "@prisma/client";
|
||||
|
||||
import { prisma } from "@/lib/db";
|
||||
|
||||
export interface ShortUrlFormData {
|
||||
id?: string;
|
||||
userId: string;
|
||||
userName: string;
|
||||
target: string;
|
||||
url: string;
|
||||
visible: number;
|
||||
active: number;
|
||||
created_at?: string;
|
||||
updated_at?: string;
|
||||
}
|
||||
|
||||
export interface UserShortUrlInfo extends ShortUrlFormData {
|
||||
// meta: Omit<UrlMeta, "id">;
|
||||
meta?: UrlMeta;
|
||||
}
|
||||
|
||||
export async function getUserShortUrls(userId: string, active: number = 1) {
|
||||
return await prisma.userUrl.findMany({
|
||||
where: {
|
||||
userId,
|
||||
active,
|
||||
},
|
||||
});
|
||||
}
|
||||
|
||||
export async function getUserShortUrlCount(userId: string, active: number = 1) {
|
||||
try {
|
||||
return await prisma.userUrl.count({
|
||||
where: {
|
||||
userId,
|
||||
active,
|
||||
},
|
||||
});
|
||||
} catch (error) {
|
||||
return -1;
|
||||
}
|
||||
}
|
||||
|
||||
export async function createUserShortUrl(data: ShortUrlFormData) {
|
||||
try {
|
||||
const session = await auth();
|
||||
if (!session?.user) {
|
||||
throw new Error("Unauthorized");
|
||||
}
|
||||
|
||||
const res = await prisma.userUrl.create({
|
||||
data: {
|
||||
userId: session.user?.id!,
|
||||
userName: session.user?.name || "Anonymous",
|
||||
target: data.target,
|
||||
url: data.url,
|
||||
visible: data.visible,
|
||||
active: data.active,
|
||||
createdAt: new Date().toISOString(),
|
||||
updatedAt: new Date().toISOString(),
|
||||
},
|
||||
});
|
||||
return { status: "success", data: res };
|
||||
} catch (error) {
|
||||
return { status: error };
|
||||
}
|
||||
}
|
||||
|
||||
export async function updateUserShortUrl(data: ShortUrlFormData) {
|
||||
try {
|
||||
const res = await prisma.userUrl.update({
|
||||
where: {
|
||||
id: data.id,
|
||||
userId: data.userId,
|
||||
},
|
||||
data: {
|
||||
target: data.target,
|
||||
url: data.url,
|
||||
visible: data.visible,
|
||||
active: data.active,
|
||||
updatedAt: new Date().toISOString(),
|
||||
},
|
||||
});
|
||||
return { status: "success", data: res };
|
||||
} catch (error) {
|
||||
return { status: error };
|
||||
}
|
||||
}
|
||||
|
||||
export async function updateUserShortUrlVisibility(
|
||||
id: string,
|
||||
visible: number,
|
||||
) {
|
||||
try {
|
||||
const res = await prisma.userUrl.update({
|
||||
where: {
|
||||
id,
|
||||
},
|
||||
data: {
|
||||
visible,
|
||||
updatedAt: new Date().toISOString(),
|
||||
},
|
||||
});
|
||||
return { status: "success", data: res };
|
||||
} catch (error) {
|
||||
return { status: error };
|
||||
}
|
||||
}
|
||||
|
||||
export async function deleteUserShortUrl(userId: string, urlId: string) {
|
||||
return await prisma.userUrl.delete({
|
||||
where: {
|
||||
id: urlId,
|
||||
userId,
|
||||
},
|
||||
});
|
||||
}
|
||||
|
||||
export async function getUserUrlMetaInfo(userId: string, urlId: string) {
|
||||
return await prisma.urlMeta.findMany({
|
||||
where: {
|
||||
userId,
|
||||
urlId,
|
||||
},
|
||||
});
|
||||
}
|
||||
@@ -0,0 +1,9 @@
|
||||
import * as z from "zod";
|
||||
|
||||
export const createUrlSchema = z.object({
|
||||
id: z.string().optional(),
|
||||
target: z.string().min(6), // TODO
|
||||
url: z.string().min(2), // TODO
|
||||
visible: z.number().default(1),
|
||||
active: z.number().default(1),
|
||||
});
|
||||
@@ -106,7 +106,7 @@ CREATE TABLE "user_records"
|
||||
"modified_on" TIMESTAMP(3) NOT NULL DEFAULT CURRENT_TIMESTAMP,
|
||||
"active" INTEGER NOT NULL DEFAULT 1,
|
||||
|
||||
CONSTRAINT "user_records_pkey" PRIMARY KEY ("id"),
|
||||
CONSTRAINT "user_records_pkey" PRIMARY KEY ("id")
|
||||
);
|
||||
|
||||
-- CreateIndex
|
||||
@@ -117,3 +117,48 @@ CREATE UNIQUE INDEX "user_records_record_id_key" ON "user_records"("record_id");
|
||||
|
||||
-- AddForeignKey
|
||||
ALTER TABLE "user_records" ADD CONSTRAINT "user_records_userId_fkey" FOREIGN KEY ("userId") REFERENCES "users"("id") ON DELETE CASCADE ON UPDATE CASCADE;
|
||||
|
||||
-- CreateTable
|
||||
CREATE TABLE "user_urls"
|
||||
(
|
||||
"id" TEXT NOT NULL,
|
||||
"userId" TEXT NOT NULL,
|
||||
"userName" TEXT NOT NULL,
|
||||
"target" TEXT NOT NULL,
|
||||
"url" TEXT NOT NULL,
|
||||
"visible" INTEGER NOT NULL DEFAULT 0,
|
||||
"active" INTEGER NOT NULL DEFAULT 1,
|
||||
"created_at" TIMESTAMP(3) NOT NULL DEFAULT CURRENT_TIMESTAMP,
|
||||
"updated_at" TIMESTAMP(3) NOT NULL DEFAULT CURRENT_TIMESTAMP,
|
||||
|
||||
CONSTRAINT "user_urls_pkey" PRIMARY KEY ("id")
|
||||
);
|
||||
|
||||
-- CreateIndex
|
||||
CREATE INDEX "user_urls_userId_idx" ON "user_urls" ("userId");
|
||||
|
||||
-- CreateIndex
|
||||
CREATE UNIQUE INDEX "user_urls_url_key" ON "user_urls"("url");
|
||||
|
||||
-- AddForeignKey
|
||||
ALTER TABLE "user_urls" ADD CONSTRAINT "user_urls_userId_fkey" FOREIGN KEY ("userId") REFERENCES "users"("id") ON DELETE CASCADE ON UPDATE CASCADE;
|
||||
|
||||
-- CreateTable
|
||||
CREATE TABLE "url_metas"
|
||||
(
|
||||
"id" TEXT NOT NULL,
|
||||
"userId" TEXT NOT NULL,
|
||||
"urlId" TEXT NOT NULL,
|
||||
"click" INTEGER NOT NULL,
|
||||
"created_at" TIMESTAMP(3) NOT NULL DEFAULT CURRENT_TIMESTAMP,
|
||||
"updated_at" TIMESTAMP(3) NOT NULL DEFAULT CURRENT_TIMESTAMP,
|
||||
|
||||
CONSTRAINT "url_metas_pkey" PRIMARY KEY ("id")
|
||||
);
|
||||
|
||||
-- CreateIndex
|
||||
CREATE INDEX "url_metas_userId_urlId_idx" ON "url_metas" ("userId", "urlId");
|
||||
|
||||
-- AddForeignKey
|
||||
ALTER TABLE "url_metas" ADD CONSTRAINT "url_metas_userId_fkey" FOREIGN KEY ("userId") REFERENCES "users"("id") ON DELETE CASCADE ON UPDATE CASCADE;
|
||||
ALTER TABLE "url_metas" ADD CONSTRAINT "url_metas_urlId_fkey" FOREIGN KEY ("urlId") REFERENCES "user_urls"("id") ON DELETE CASCADE ON UPDATE CASCADE;
|
||||
@@ -64,6 +64,8 @@ model User {
|
||||
accounts Account[]
|
||||
sessions Session[]
|
||||
UserRecord UserRecord[]
|
||||
UserUrl UserUrl[]
|
||||
UrlMeta UrlMeta[]
|
||||
|
||||
@@map(name: "users")
|
||||
}
|
||||
@@ -100,3 +102,37 @@ model UserRecord {
|
||||
@@index([userId])
|
||||
@@map(name: "user_records")
|
||||
}
|
||||
|
||||
model UserUrl {
|
||||
id String @id @default(cuid())
|
||||
userId String
|
||||
userName String
|
||||
target String
|
||||
url String @unique
|
||||
visible Int @default(0)
|
||||
active Int @default(1)
|
||||
createdAt DateTime @default(now()) @map(name: "created_at")
|
||||
updatedAt DateTime @default(now()) @map(name: "updated_at")
|
||||
|
||||
user User @relation(fields: [userId], references: [id], onDelete: Cascade)
|
||||
UrlMeta UrlMeta[]
|
||||
|
||||
@@index([userId])
|
||||
@@map(name: "user_urls")
|
||||
}
|
||||
|
||||
model UrlMeta {
|
||||
id String @id @default(cuid())
|
||||
userId String
|
||||
urlId String
|
||||
click Int
|
||||
|
||||
createdAt DateTime @default(now()) @map(name: "created_at")
|
||||
updatedAt DateTime @default(now()) @map(name: "updated_at")
|
||||
|
||||
user User @relation(fields: [userId], references: [id], onDelete: Cascade)
|
||||
userUrl UserUrl @relation(fields: [urlId], references: [id], onDelete: Cascade)
|
||||
|
||||
@@index([userId, urlId])
|
||||
@@map(name: "url_metas")
|
||||
}
|
||||
|
||||
Reference in New Issue
Block a user