feat: add short url crud

This commit is contained in:
oiov
2024-07-28 21:31:51 +08:00
parent 29fac3b989
commit f0027e6a0e
25 changed files with 802 additions and 92 deletions
View File
+3
View File
@@ -0,0 +1,3 @@
export default function IndexPage() {
return <></>;
}
+23 -8
View File
@@ -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>
+6 -1
View File
@@ -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&apos;t have any record yet. Start creating record.
+5 -2
View File
@@ -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 || "" }} />
</>
);
}
+102 -44
View File
@@ -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&apos;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&apos;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
View File
@@ -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,
+3 -4
View File
@@ -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",
});
+2 -3
View File
@@ -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 {
+2 -3
View File
@@ -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 {
+36
View File
@@ -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",
});
}
}
+28
View File
@@ -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",
});
}
}
+21
View File
@@ -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",
});
}
}
+44
View File
@@ -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",
});
}
}
+1 -1
View File
@@ -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" {
+23 -1
View File
@@ -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>
+2 -8
View File
@@ -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";
+266
View File
@@ -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>
);
}
+127
View File
@@ -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,
},
});
}
View File
+9
View File
@@ -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;
+36
View File
@@ -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")
}