792 lines
29 KiB
TypeScript
792 lines
29 KiB
TypeScript
"use client";
|
|
|
|
import { useEffect, useMemo, useState, useTransition } from "react";
|
|
import Link from "next/link";
|
|
import { usePathname } from "next/navigation";
|
|
import { User } from "@prisma/client";
|
|
import { PenLine } from "lucide-react";
|
|
import { useTranslations } from "next-intl";
|
|
import { toast } from "sonner";
|
|
import useSWR, { useSWRConfig } from "swr";
|
|
|
|
import { ShortUrlFormData } from "@/lib/dto/short-urls";
|
|
import {
|
|
addUrlPrefix,
|
|
cn,
|
|
expirationTime,
|
|
extractHostname,
|
|
fetcher,
|
|
nFormatter,
|
|
removeUrlPrefix,
|
|
} from "@/lib/utils";
|
|
import { useMediaQuery } from "@/hooks/use-media-query";
|
|
import { Button } from "@/components/ui/button";
|
|
import {
|
|
DropdownMenu,
|
|
DropdownMenuContent,
|
|
DropdownMenuItem,
|
|
DropdownMenuSeparator,
|
|
DropdownMenuTrigger,
|
|
} from "@/components/ui/dropdown-menu";
|
|
import { Input } from "@/components/ui/input";
|
|
import { Modal } from "@/components/ui/modal";
|
|
import {
|
|
Select,
|
|
SelectContent,
|
|
SelectItem,
|
|
SelectTrigger,
|
|
SelectValue,
|
|
} from "@/components/ui/select";
|
|
import { Separator } from "@/components/ui/separator";
|
|
import { Skeleton } from "@/components/ui/skeleton";
|
|
import { Switch } from "@/components/ui/switch";
|
|
import {
|
|
Table,
|
|
TableBody,
|
|
TableCell,
|
|
TableHead,
|
|
TableHeader,
|
|
TableRow,
|
|
} from "@/components/ui/table";
|
|
import { Tabs, TabsContent, TabsList, TabsTrigger } from "@/components/ui/tabs";
|
|
import {
|
|
Tooltip,
|
|
TooltipContent,
|
|
TooltipProvider,
|
|
TooltipTrigger,
|
|
} from "@/components/ui/tooltip";
|
|
import { UrlStatus } from "@/components/dashboard/status-card";
|
|
import { FormType } from "@/components/forms/record-form";
|
|
import { UrlForm } from "@/components/forms/url-form";
|
|
import ApiReference from "@/components/shared/api-reference";
|
|
import BlurImage from "@/components/shared/blur-image";
|
|
import { CopyButton } from "@/components/shared/copy-button";
|
|
import { EmptyPlaceholder } from "@/components/shared/empty-placeholder";
|
|
import { Icons } from "@/components/shared/icons";
|
|
import { LinkInfoPreviewer } from "@/components/shared/link-previewer";
|
|
import { PaginationWrapper } from "@/components/shared/pagination";
|
|
import QRCodeEditor from "@/components/shared/qr";
|
|
import { TimeAgoIntl } from "@/components/shared/time-ago";
|
|
|
|
import { UrlExporter } from "./export";
|
|
import Globe from "./globe";
|
|
import LiveLog from "./live-logs";
|
|
import UserUrlMetaInfo from "./meta";
|
|
|
|
export interface UrlListProps {
|
|
user: Pick<User, "id" | "name" | "apiKey" | "role" | "team">;
|
|
action: string;
|
|
}
|
|
|
|
function TableColumnSekleton() {
|
|
return (
|
|
<TableRow className="grid grid-cols-3 items-center sm:grid-cols-11">
|
|
<TableCell className="col-span-1 sm:col-span-2">
|
|
<Skeleton className="h-5 w-20" />
|
|
</TableCell>
|
|
<TableCell className="col-span-1 sm:col-span-2">
|
|
<Skeleton className="h-5 w-20" />
|
|
</TableCell>
|
|
<TableCell className="col-span-1 hidden sm:flex">
|
|
<Skeleton className="h-5 w-16" />
|
|
</TableCell>
|
|
<TableCell className="col-span-1 hidden sm:flex">
|
|
<Skeleton className="h-5 w-16" />
|
|
</TableCell>
|
|
<TableCell className="col-span-1 hidden sm:flex">
|
|
<Skeleton className="h-5 w-16" />
|
|
</TableCell>
|
|
<TableCell className="col-span-1 hidden sm:flex">
|
|
<Skeleton className="h-5 w-16" />
|
|
</TableCell>
|
|
<TableCell className="col-span-1 hidden sm:flex">
|
|
<Skeleton className="h-5 w-16" />
|
|
</TableCell>
|
|
<TableCell className="col-span-1 flex">
|
|
<Skeleton className="h-5 w-16" />
|
|
</TableCell>
|
|
</TableRow>
|
|
);
|
|
}
|
|
|
|
export default function UserUrlsList({ user, action }: UrlListProps) {
|
|
const pathname = usePathname();
|
|
const { isMobile } = useMediaQuery();
|
|
const t = useTranslations("List");
|
|
const [currentView, setCurrentView] = useState<string>("List");
|
|
const [isShowForm, setShowForm] = useState(false);
|
|
const [formType, setFormType] = useState<FormType>("add");
|
|
const [currentEditUrl, setCurrentEditUrl] = useState<ShortUrlFormData | null>(
|
|
null,
|
|
);
|
|
const [currentPage, setCurrentPage] = useState(1);
|
|
const [pageSize, setPageSize] = useState(15);
|
|
const [isShowStats, setShowStats] = useState(false);
|
|
const [isShowQrcode, setShowQrcode] = useState(false);
|
|
const [selectedUrl, setSelectedUrl] = useState<ShortUrlFormData | null>(null);
|
|
const [searchParams, setSearchParams] = useState({
|
|
slug: "",
|
|
target: "",
|
|
userName: "",
|
|
});
|
|
const [isPending, startTransition] = useTransition();
|
|
const [currentListClickData, setCurrentListClickData] = useState<
|
|
Record<string, number>
|
|
>({});
|
|
|
|
const { mutate } = useSWRConfig();
|
|
const { data, isLoading } = useSWR<{
|
|
total: number;
|
|
list: ShortUrlFormData[];
|
|
}>(
|
|
`${action}?page=${currentPage}&size=${pageSize}&slug=${searchParams.slug}&userName=${searchParams.userName}&target=${searchParams.target}`,
|
|
fetcher,
|
|
{
|
|
revalidateOnFocus: false,
|
|
},
|
|
);
|
|
|
|
const currentListIds = useMemo(() => {
|
|
return data?.list?.map((item) => item.id ?? "") ?? [];
|
|
}, [data?.list]);
|
|
|
|
useEffect(() => {
|
|
handleGetUrlClicks();
|
|
}, [currentListIds]);
|
|
|
|
const handleGetUrlClicks = async () => {
|
|
startTransition(async () => {
|
|
if (currentListIds.length > 0 && currentView !== "Realtime") {
|
|
const res = await fetch(action, {
|
|
method: "POST",
|
|
body: JSON.stringify({ ids: currentListIds }),
|
|
});
|
|
if (res.ok) {
|
|
const data = await res.json();
|
|
setCurrentListClickData(data);
|
|
}
|
|
}
|
|
});
|
|
};
|
|
|
|
const handleRefresh = () => {
|
|
mutate(
|
|
`${action}?page=${currentPage}&size=${pageSize}&slug=${searchParams.slug}&userName=${searchParams.userName}&target=${searchParams.target}`,
|
|
undefined,
|
|
);
|
|
};
|
|
|
|
const handleChangeStatu = async (checked: boolean, id: string) => {
|
|
const res = await fetch(`/api/url/update/active`, {
|
|
method: "POST",
|
|
body: JSON.stringify({
|
|
id,
|
|
active: checked ? 1 : 0,
|
|
}),
|
|
});
|
|
if (res.ok) {
|
|
const data = await res.json();
|
|
if (data) {
|
|
toast.success("Successed!");
|
|
}
|
|
} else {
|
|
toast.error("Activation failed!");
|
|
}
|
|
};
|
|
|
|
const rendeEmpty = () => (
|
|
<EmptyPlaceholder className="col-span-full shadow-none">
|
|
<EmptyPlaceholder.Icon name="link" />
|
|
<EmptyPlaceholder.Title>{t("No urls")}</EmptyPlaceholder.Title>
|
|
<EmptyPlaceholder.Description>
|
|
You don't have any url yet. Start creating url.
|
|
</EmptyPlaceholder.Description>
|
|
</EmptyPlaceholder>
|
|
);
|
|
|
|
const renderSearchInputs = () => {
|
|
const [searchType, setSearchType] = useState<
|
|
"slug" | "target" | "userName"
|
|
>("slug");
|
|
|
|
const getCurrentSearchValue = () => {
|
|
switch (searchType) {
|
|
case "slug":
|
|
return searchParams.slug;
|
|
case "target":
|
|
return searchParams.target;
|
|
case "userName":
|
|
return searchParams.userName;
|
|
default:
|
|
return "";
|
|
}
|
|
};
|
|
|
|
const handleSearchChange = (value: string) => {
|
|
setSearchParams({
|
|
...searchParams,
|
|
slug: searchType === "slug" ? value : "",
|
|
target: searchType === "target" ? value : "",
|
|
userName: searchType === "userName" ? value : "",
|
|
});
|
|
};
|
|
|
|
const handleClearSearch = () => {
|
|
handleSearchChange("");
|
|
};
|
|
|
|
const getPlaceholder = () => {
|
|
switch (searchType) {
|
|
case "slug":
|
|
return t("Search by slug") + "...";
|
|
case "target":
|
|
return t("Search by target") + "...";
|
|
case "userName":
|
|
return t("Search by username") + "...";
|
|
default:
|
|
return t("Search") + "...";
|
|
}
|
|
};
|
|
|
|
const searchOptions = [
|
|
{ value: "slug", label: t("Link Slug") },
|
|
{ value: "target", label: t("Link Target") },
|
|
...(user.role === "ADMIN"
|
|
? [{ value: "userName", label: t("Username") }]
|
|
: []),
|
|
];
|
|
|
|
const currentSearchValue = getCurrentSearchValue();
|
|
|
|
return (
|
|
<div className="ml-auto flex items-center">
|
|
<Select
|
|
value={searchType}
|
|
onValueChange={(value: typeof searchType) => setSearchType(value)}
|
|
>
|
|
<SelectTrigger className="h-10 w-[85px] rounded-r-none bg-muted text-sm">
|
|
<SelectValue />
|
|
</SelectTrigger>
|
|
<SelectContent>
|
|
{searchOptions.map((option) => (
|
|
<SelectItem
|
|
key={option.value}
|
|
value={option.value}
|
|
className="text-sm"
|
|
>
|
|
{option.label}
|
|
</SelectItem>
|
|
))}
|
|
</SelectContent>
|
|
</Select>
|
|
<div className="relative flex-1">
|
|
<Input
|
|
className="h-10 rounded-l-none border-l-0 pr-8 text-sm"
|
|
placeholder={getPlaceholder()}
|
|
value={currentSearchValue}
|
|
onChange={(e) => handleSearchChange(e.target.value)}
|
|
/>
|
|
{currentSearchValue && (
|
|
<Button
|
|
className="absolute right-2 top-1/2 h-6 -translate-y-1/2 rounded-full px-1 text-gray-500 hover:text-gray-700"
|
|
onClick={handleClearSearch}
|
|
variant="ghost"
|
|
>
|
|
<Icons.close className="size-3" />
|
|
</Button>
|
|
)}
|
|
</div>
|
|
</div>
|
|
);
|
|
};
|
|
|
|
const rendeClicks = (short: ShortUrlFormData) => (
|
|
<>
|
|
<Icons.mousePointerClick className="size-[14px]" />
|
|
{isPending ? (
|
|
<Skeleton className="h-4 w-6 rounded" />
|
|
) : (
|
|
<p className="text-xs font-medium text-gray-700 dark:text-gray-50">
|
|
{(short.id && nFormatter(currentListClickData[short.id], 2)) || "-"}
|
|
</p>
|
|
)}
|
|
</>
|
|
);
|
|
|
|
const rendeStats = (short: ShortUrlFormData) =>
|
|
isShowStats &&
|
|
selectedUrl?.id === short.id && (
|
|
<UserUrlMetaInfo
|
|
user={{
|
|
id: user.id,
|
|
name: user.name || "",
|
|
team: user.team,
|
|
}}
|
|
action="/api/url/meta"
|
|
urlId={short.id!}
|
|
/>
|
|
);
|
|
|
|
const rendeList = () => (
|
|
<Table>
|
|
<TableHeader className="bg-gray-100/50 dark:bg-primary-foreground">
|
|
<TableRow className="grid grid-cols-3 items-center sm:grid-cols-11">
|
|
<TableHead className="col-span-1 flex items-center font-bold sm:col-span-2">
|
|
{t("Slug")}
|
|
</TableHead>
|
|
<TableHead className="col-span-1 flex items-center font-bold sm:col-span-2">
|
|
{t("Target")}
|
|
</TableHead>
|
|
<TableHead className="col-span-1 hidden items-center font-bold sm:flex">
|
|
{t("User")}
|
|
</TableHead>
|
|
<TableHead className="col-span-1 hidden items-center font-bold sm:flex">
|
|
{t("Enabled")}
|
|
</TableHead>
|
|
<TableHead className="col-span-1 hidden items-center font-bold sm:flex">
|
|
{t("Expiration")}
|
|
</TableHead>
|
|
<TableHead className="col-span-1 hidden items-center font-bold sm:flex">
|
|
{t("Clicks")}
|
|
</TableHead>
|
|
<TableHead className="col-span-1 hidden items-center font-bold sm:flex">
|
|
{t("Updated")}
|
|
</TableHead>
|
|
<TableHead className="col-span-1 flex items-center font-bold sm:col-span-2">
|
|
{t("Actions")}
|
|
</TableHead>
|
|
</TableRow>
|
|
</TableHeader>
|
|
<TableBody>
|
|
{isLoading ? (
|
|
<>
|
|
<TableColumnSekleton />
|
|
<TableColumnSekleton />
|
|
<TableColumnSekleton />
|
|
<TableColumnSekleton />
|
|
<TableColumnSekleton />
|
|
</>
|
|
) : data && data.list && data.list.length ? (
|
|
data.list.map((short) => (
|
|
<div className="border-b" key={short.id}>
|
|
<TableRow className="grid grid-cols-3 items-center sm:grid-cols-11">
|
|
<TableCell className="col-span-1 flex items-center gap-1 sm:col-span-2">
|
|
<Link
|
|
className="overflow-hidden text-ellipsis whitespace-normal text-slate-600 hover:text-blue-400 hover:underline dark:text-slate-400"
|
|
href={`https://${short.prefix}/${short.url}${short.password ? `?password=${short.password}` : ""}`}
|
|
target="_blank"
|
|
prefetch={false}
|
|
title={short.url}
|
|
>
|
|
{short.url}
|
|
</Link>
|
|
<CopyButton
|
|
value={`${short.prefix}/${short.url}${short.password ? `?password=${short.password}` : ""}`}
|
|
className={cn(
|
|
"size-[25px]",
|
|
"duration-250 transition-all group-hover:opacity-100",
|
|
)}
|
|
/>
|
|
{short.password && (
|
|
<Icons.lock className="size-3 text-neutral-600 dark:text-neutral-400" />
|
|
)}
|
|
</TableCell>
|
|
<TableCell className="col-span-1 flex items-center justify-start sm:col-span-2">
|
|
<LinkInfoPreviewer
|
|
apiKey={user.apiKey ?? ""}
|
|
url={addUrlPrefix(short.target)}
|
|
formatUrl={removeUrlPrefix(short.target)}
|
|
/>
|
|
</TableCell>
|
|
<TableCell className="col-span-1 hidden truncate sm:flex">
|
|
<TooltipProvider>
|
|
<Tooltip delayDuration={200}>
|
|
<TooltipTrigger className="truncate">
|
|
{short.userName ?? "Anonymous"}
|
|
</TooltipTrigger>
|
|
<TooltipContent>
|
|
{short.userName ?? "Anonymous"}
|
|
</TooltipContent>
|
|
</Tooltip>
|
|
</TooltipProvider>
|
|
</TableCell>
|
|
<TableCell className="col-span-1 hidden sm:flex">
|
|
<Switch
|
|
defaultChecked={short.active === 1}
|
|
onCheckedChange={(value) =>
|
|
handleChangeStatu(value, short.id || "")
|
|
}
|
|
/>
|
|
</TableCell>
|
|
<TableCell className="col-span-1 hidden sm:flex">
|
|
{expirationTime(short.expiration, short.updatedAt)}
|
|
</TableCell>
|
|
<TableCell className="col-span-1 hidden truncate sm:flex">
|
|
<div className="flex items-center gap-1 rounded-lg border bg-gray-50 px-2 py-1 dark:bg-gray-600/50">
|
|
{rendeClicks(short)}
|
|
</div>
|
|
</TableCell>
|
|
<TableCell className="col-span-1 hidden truncate sm:flex">
|
|
<TimeAgoIntl date={short.updatedAt as Date} />
|
|
</TableCell>
|
|
<TableCell className="col-span-1 flex items-center gap-1 sm:col-span-2">
|
|
<Button
|
|
className="h-7 px-1 text-xs hover:bg-slate-100 dark:hover:text-primary-foreground sm:px-1.5"
|
|
size="sm"
|
|
variant={"outline"}
|
|
onClick={() => {
|
|
setCurrentEditUrl(short);
|
|
setShowForm(false);
|
|
setFormType("edit");
|
|
setShowForm(!isShowForm);
|
|
}}
|
|
>
|
|
<p className="hidden text-nowrap sm:block">{t("Edit")}</p>
|
|
<PenLine className="mx-0.5 size-4 sm:ml-1 sm:size-3" />
|
|
</Button>
|
|
<Button
|
|
className="h-7 px-1 text-xs hover:bg-slate-100 dark:hover:text-primary-foreground"
|
|
size="sm"
|
|
variant={"outline"}
|
|
onClick={() => {
|
|
setSelectedUrl(short);
|
|
setShowQrcode(!isShowQrcode);
|
|
}}
|
|
>
|
|
<Icons.qrcode className="mx-0.5 size-4" />
|
|
</Button>
|
|
<Button
|
|
className="h-7 px-1 text-xs hover:bg-slate-100 dark:hover:text-primary-foreground"
|
|
size="sm"
|
|
variant="outline"
|
|
onClick={() => {
|
|
setSelectedUrl(short);
|
|
setCurrentView(short.id!);
|
|
if (isShowStats && selectedUrl?.id !== short.id) {
|
|
} else {
|
|
setShowStats(!isShowStats);
|
|
}
|
|
}}
|
|
>
|
|
<Icons.lineChart className="mx-0.5 size-4" />
|
|
</Button>
|
|
</TableCell>
|
|
</TableRow>
|
|
{/* {rendeStats(short)} */}
|
|
</div>
|
|
))
|
|
) : (
|
|
rendeEmpty()
|
|
)}
|
|
</TableBody>
|
|
{data && Math.ceil(data.total / pageSize) > 1 && (
|
|
<PaginationWrapper
|
|
layout={isMobile ? "right" : "split"}
|
|
total={data.total}
|
|
currentPage={currentPage}
|
|
setCurrentPage={setCurrentPage}
|
|
pageSize={pageSize}
|
|
setPageSize={setPageSize}
|
|
/>
|
|
)}
|
|
</Table>
|
|
);
|
|
|
|
const rendeGrid = () => (
|
|
<>
|
|
<section className="grid grid-cols-1 gap-3 md:grid-cols-2 xl:grid-cols-3">
|
|
{isLoading ? (
|
|
<>
|
|
{[1, 2, 3, 4, 5, 6].map((v) => (
|
|
<Skeleton key={v} className="h-24 w-full" />
|
|
))}
|
|
</>
|
|
) : data && data.list && data.list.length ? (
|
|
data.list.map((short) => (
|
|
<div
|
|
className={cn(
|
|
"h-24 rounded-lg border p-1 shadow-inner dark:bg-neutral-800",
|
|
)}
|
|
key={short.id}
|
|
>
|
|
<div className="flex h-full flex-col rounded-lg border border-dotted bg-white px-3 py-1.5 backdrop-blur-lg dark:bg-black">
|
|
<div className="flex items-center justify-between gap-1">
|
|
<BlurImage
|
|
src={`https://unavatar.io/${extractHostname(short.target)}?fallback=https://wr.do/logo.png`}
|
|
alt="logo"
|
|
width={30}
|
|
height={30}
|
|
className="rounded-md"
|
|
/>
|
|
<div className="ml-2 mr-auto flex flex-col justify-between truncate">
|
|
{/* url */}
|
|
<div className="flex items-center">
|
|
<Link
|
|
className="overflow-hidden text-ellipsis whitespace-normal text-sm font-semibold text-slate-600 hover:text-blue-400 hover:underline dark:text-slate-300"
|
|
href={`https://${short.prefix}/${short.url}${short.password ? `?password=${short.password}` : ""}`}
|
|
target="_blank"
|
|
prefetch={false}
|
|
title={short.url}
|
|
>
|
|
{short.url}
|
|
</Link>
|
|
<CopyButton
|
|
value={`https://${short.prefix}/${short.url}${short.password ? `?password=${short.password}` : ""}`}
|
|
className={cn(
|
|
"size-[25px]",
|
|
"duration-250 transition-all group-hover:opacity-100",
|
|
)}
|
|
/>
|
|
<Button
|
|
className="duration-250 size-[26px] p-1.5 text-foreground transition-all hover:border hover:text-foreground dark:text-foreground"
|
|
size="sm"
|
|
variant="ghost"
|
|
onClick={() => {
|
|
setSelectedUrl(short);
|
|
setShowQrcode(!isShowQrcode);
|
|
}}
|
|
>
|
|
<Icons.qrcode className="size-4" />
|
|
</Button>
|
|
{short.password && (
|
|
<Icons.lock className="size-3 text-neutral-600 dark:text-neutral-400" />
|
|
)}
|
|
</div>
|
|
|
|
{/* target */}
|
|
<div className="flex items-center gap-1 overflow-hidden truncate text-sm text-muted-foreground">
|
|
<Icons.forwardArrow className="size-4 shrink-0 text-gray-400" />
|
|
<LinkInfoPreviewer
|
|
apiKey={user.apiKey ?? ""}
|
|
url={short.target}
|
|
formatUrl={removeUrlPrefix(short.target)}
|
|
/>
|
|
</div>
|
|
</div>
|
|
|
|
<div className="ml-2 flex items-center gap-1 rounded-md border bg-gray-50 px-2 py-1 dark:bg-gray-600/50">
|
|
{rendeClicks(short)}
|
|
</div>
|
|
|
|
<DropdownMenu>
|
|
<DropdownMenuTrigger asChild>
|
|
<Button
|
|
className="size-[25px] p-1.5"
|
|
size="sm"
|
|
variant="ghost"
|
|
>
|
|
<Icons.moreVertical className="size-4" />
|
|
</Button>
|
|
</DropdownMenuTrigger>
|
|
<DropdownMenuContent>
|
|
<DropdownMenuItem asChild>
|
|
<Button
|
|
className="flex w-full items-center gap-2"
|
|
size="sm"
|
|
variant="ghost"
|
|
onClick={() => {
|
|
setSelectedUrl(short);
|
|
setCurrentView(short.id!);
|
|
if (isShowStats && selectedUrl?.id !== short.id) {
|
|
} else {
|
|
setShowStats(!isShowStats);
|
|
}
|
|
}}
|
|
>
|
|
<Icons.lineChart className="size-4" />
|
|
{t("Analytics")}
|
|
</Button>
|
|
</DropdownMenuItem>
|
|
<DropdownMenuSeparator />
|
|
<DropdownMenuItem asChild>
|
|
<Button
|
|
className="flex w-full items-center gap-2"
|
|
size="sm"
|
|
variant="ghost"
|
|
onClick={() => {
|
|
setCurrentEditUrl(short);
|
|
setShowForm(false);
|
|
setFormType("edit");
|
|
setShowForm(!isShowForm);
|
|
}}
|
|
>
|
|
<PenLine className="size-4" />
|
|
{t("Edit URL")}
|
|
</Button>
|
|
</DropdownMenuItem>
|
|
</DropdownMenuContent>
|
|
</DropdownMenu>
|
|
</div>
|
|
|
|
<div className="mt-auto flex items-center justify-end gap-1.5 text-xs text-muted-foreground">
|
|
<TooltipProvider>
|
|
<Tooltip delayDuration={200}>
|
|
<TooltipTrigger className="truncate">
|
|
{short.userName ?? "Anonymous"}
|
|
</TooltipTrigger>
|
|
<TooltipContent>
|
|
{short.userName ?? "Anonymous"}
|
|
</TooltipContent>
|
|
</Tooltip>
|
|
</TooltipProvider>
|
|
<Separator
|
|
className="h-4/5"
|
|
orientation="vertical"
|
|
></Separator>
|
|
{short.expiration !== "-1" && (
|
|
<>
|
|
<span>
|
|
Expiration:{" "}
|
|
{expirationTime(short.expiration, short.updatedAt)}
|
|
</span>
|
|
<Separator
|
|
className="h-4/5"
|
|
orientation="vertical"
|
|
></Separator>
|
|
</>
|
|
)}
|
|
<TimeAgoIntl date={short.updatedAt as Date} />
|
|
<Switch
|
|
className="scale-[0.6]"
|
|
defaultChecked={short.active === 1}
|
|
onCheckedChange={(value) =>
|
|
handleChangeStatu(value, short.id || "")
|
|
}
|
|
/>
|
|
</div>
|
|
</div>
|
|
</div>
|
|
))
|
|
) : (
|
|
rendeEmpty()
|
|
)}
|
|
</section>
|
|
|
|
{data && Math.ceil(data.total / pageSize) > 1 && (
|
|
<PaginationWrapper
|
|
layout={isMobile ? "right" : "split"}
|
|
total={data.total}
|
|
currentPage={currentPage}
|
|
setCurrentPage={setCurrentPage}
|
|
pageSize={pageSize}
|
|
setPageSize={setPageSize}
|
|
/>
|
|
)}
|
|
</>
|
|
);
|
|
|
|
return (
|
|
<>
|
|
<Tabs
|
|
className={cn(
|
|
"space-y-3 rounded-lg",
|
|
pathname === "/dashboard" && "border p-6",
|
|
)}
|
|
value={currentView}
|
|
>
|
|
{/* Tabs */}
|
|
<div className="flex flex-wrap items-center justify-between gap-2">
|
|
{pathname === "/dashboard" && (
|
|
<h2 className="mr-auto text-lg font-semibold">{t("Short URLs")}</h2>
|
|
)}
|
|
<TabsList>
|
|
<TabsTrigger onClick={() => setCurrentView("List")} value="List">
|
|
<Icons.list className="size-4" />
|
|
{/* List */}
|
|
</TabsTrigger>
|
|
<TabsTrigger onClick={() => setCurrentView("Grid")} value="Grid">
|
|
<Icons.layoutGrid className="size-4" />
|
|
{/* Grid */}
|
|
</TabsTrigger>
|
|
{selectedUrl?.id && (
|
|
<TabsTrigger
|
|
className="flex items-center gap-1 text-muted-foreground"
|
|
value={selectedUrl.id}
|
|
onClick={() => setCurrentView(selectedUrl.id!)}
|
|
>
|
|
<Icons.lineChart className="size-4" />
|
|
{selectedUrl.url}
|
|
</TabsTrigger>
|
|
)}
|
|
</TabsList>
|
|
<div className="flex items-center justify-end gap-3">
|
|
{renderSearchInputs()}
|
|
<UrlExporter data={data?.list || []} />
|
|
<Button
|
|
variant={"outline"}
|
|
onClick={() => handleRefresh()}
|
|
disabled={isLoading}
|
|
>
|
|
{isLoading ? (
|
|
<Icons.refreshCw className="size-4 animate-spin" />
|
|
) : (
|
|
<Icons.refreshCw className="size-4" />
|
|
)}
|
|
</Button>
|
|
{action.indexOf("admin") === -1 && (
|
|
<Button
|
|
className="flex shrink-0 gap-1"
|
|
variant="default"
|
|
onClick={() => {
|
|
setCurrentEditUrl(null);
|
|
setShowForm(false);
|
|
setFormType("add");
|
|
setShowForm(!isShowForm);
|
|
}}
|
|
>
|
|
<Icons.add className="size-4" />
|
|
<span className="hidden sm:inline">{t("Add URL")}</span>
|
|
</Button>
|
|
)}
|
|
</div>
|
|
</div>
|
|
|
|
{pathname !== "/dashboard" && <UrlStatus action={action} />}
|
|
|
|
<TabsContent className="mt-0 space-y-3" value="List">
|
|
{rendeList()}
|
|
</TabsContent>
|
|
<TabsContent className="mt-0 space-y-3" value="Grid">
|
|
{rendeGrid()}
|
|
</TabsContent>
|
|
{selectedUrl?.id && (
|
|
<TabsContent value={selectedUrl.id}>
|
|
{rendeStats(selectedUrl)}
|
|
</TabsContent>
|
|
)}
|
|
</Tabs>
|
|
|
|
{/* QR code editor */}
|
|
<Modal
|
|
className="md:max-w-lg"
|
|
showModal={isShowQrcode}
|
|
setShowModal={setShowQrcode}
|
|
>
|
|
{selectedUrl && (
|
|
<QRCodeEditor
|
|
user={{ id: user.id, apiKey: user.apiKey || "", team: user.team! }}
|
|
url={`https://${selectedUrl.prefix}/${selectedUrl.url}`}
|
|
/>
|
|
)}
|
|
</Modal>
|
|
|
|
{/* Url form */}
|
|
<Modal
|
|
className="md:max-w-2xl"
|
|
showModal={isShowForm}
|
|
setShowModal={setShowForm}
|
|
>
|
|
<UrlForm
|
|
user={{ id: user.id, name: user.name || "" }}
|
|
isShowForm={isShowForm}
|
|
setShowForm={setShowForm}
|
|
type={formType}
|
|
initData={currentEditUrl}
|
|
action={action}
|
|
onRefresh={handleRefresh}
|
|
/>
|
|
</Modal>
|
|
</>
|
|
);
|
|
}
|