style: adjust url list layout

This commit is contained in:
oiov
2025-05-24 22:36:31 +08:00
parent 142cdf8b41
commit 59727b6be9
7 changed files with 363 additions and 336 deletions
+10 -25
View File
@@ -2,11 +2,8 @@ import { redirect } from "next/navigation";
import { getCurrentUser } from "@/lib/session";
import { constructMetadata } from "@/lib/utils";
import { Tabs, TabsContent, TabsList, TabsTrigger } from "@/components/ui/tabs";
import { DashboardHeader } from "@/components/dashboard/header";
import Globe from "../../dashboard/urls/globe";
import LiveLog from "../../dashboard/urls/live-logs";
import UserUrlsList from "../../dashboard/urls/url-list";
export const metadata = constructMetadata({
@@ -28,28 +25,16 @@ export default async function DashboardPage() {
linkText="short urls."
/>
<Tabs defaultValue="Links">
<TabsList>
<TabsTrigger value="Links">Links</TabsTrigger>
<TabsTrigger value="Realtime">Realtime</TabsTrigger>
</TabsList>
<TabsContent className="space-y-3" value="Links">
<UserUrlsList
user={{
id: user.id,
name: user.name || "",
apiKey: user.apiKey || "",
role: user.role,
team: user.team,
}}
action="/api/url/admin"
/>
<LiveLog admin={true} />
</TabsContent>
<TabsContent value="Realtime">
<Globe isAdmin />
</TabsContent>
</Tabs>
<UserUrlsList
user={{
id: user.id,
name: user.name || "",
apiKey: user.apiKey || "",
role: user.role,
team: user.team,
}}
action="/api/url/admin"
/>
</>
);
}
@@ -487,14 +487,14 @@ export default function Realtime({ isAdmin = false }: { isAdmin?: boolean }) {
};
return (
<div className="relative">
<div className="relative w-full">
<RealtimeTimePicker
timeRange={timeRange}
setTimeRange={handleTimeRangeChange}
/>
<div className="p-4 sm:relative">
<div className="sm:relative sm:p-4">
<RealtimeChart
className="left-0 top-2 z-10 sm:absolute"
className="left-0 top-0 z-10 rounded-t-none text-left sm:absolute"
chartData={chartData}
totalClicks={stats.totalClicks}
/>
@@ -506,7 +506,7 @@ export default function Realtime({ isAdmin = false }: { isAdmin?: boolean }) {
setHandleTrafficEvent={(fn) => (handleTrafficEventRef.current = fn)}
/>
<RealtimeLogs
className="right-0 top-2 z-10 sm:absolute"
className="-top-9 right-0 z-10 sm:absolute"
locations={locations}
/>
</div>
@@ -523,7 +523,7 @@ export function RealtimeTimePicker({
}) {
return (
<Select onValueChange={setTimeRange} name="time range" value={timeRange}>
<SelectTrigger className="absolute -top-[46px] right-0 z-20 hidden w-60 sm:inline-flex">
<SelectTrigger className="rounded-b-none border-b-0 sm:w-[326px]">
<SelectValue placeholder="Select a time range" />
</SelectTrigger>
<SelectContent>
@@ -123,7 +123,7 @@ export default function RealtimeGlobe({
globe = new Globe(container)
.width(wrapperWidth)
.height(wrapperWidth > 728 ? wrapperWidth * 0.9 : wrapperWidth)
.globeOffset([0, -100])
.globeOffset([0, -130])
.atmosphereColor("rgba(170, 170, 200, 0.8)")
.backgroundColor("rgba(0,0,0,0)")
.globeMaterial(
@@ -295,7 +295,7 @@ export default function RealtimeGlobe({
}, [cleanup]);
return (
<div ref={wrapperRef} className="relative">
<div ref={wrapperRef} className="relative max-h-screen overflow-hidden">
<div
ref={globeRef}
className="flex justify-center"
+10 -32
View File
@@ -2,12 +2,8 @@ import { redirect } from "next/navigation";
import { getCurrentUser } from "@/lib/session";
import { constructMetadata } from "@/lib/utils";
import { Tabs, TabsContent, TabsList, TabsTrigger } from "@/components/ui/tabs";
import { DashboardHeader } from "@/components/dashboard/header";
import ApiReference from "../../../../components/shared/api-reference";
import Globe from "./globe";
import LiveLog from "./live-logs";
import UserUrlsList from "./url-list";
export const metadata = constructMetadata({
@@ -28,34 +24,16 @@ export default async function DashboardPage() {
link="/docs/short-urls"
linkText="short urls."
/>
<Tabs defaultValue="Links">
<TabsList>
<TabsTrigger value="Links">Links</TabsTrigger>
<TabsTrigger value="Realtime">Realtime</TabsTrigger>
</TabsList>
<TabsContent className="space-y-3" value="Links">
<UserUrlsList
user={{
id: user.id,
name: user.name || "",
apiKey: user.apiKey || "",
role: user.role,
team: user.team,
}}
action="/api/url"
/>
<LiveLog admin={false} />
<ApiReference
badge="POST /api/v1/short"
target="creating short urls"
link="/docs/short-urls#api-reference"
/>
</TabsContent>
<TabsContent value="Realtime">
<Globe />
</TabsContent>
</Tabs>
<UserUrlsList
user={{
id: user.id,
name: user.name || "",
apiKey: user.apiKey || "",
role: user.role,
team: user.team,
}}
action="/api/url"
/>
</>
);
}
+278 -271
View File
@@ -16,13 +16,6 @@ import {
timeAgo,
} from "@/lib/utils";
import { Button } from "@/components/ui/button";
import {
Card,
CardContent,
CardDescription,
CardHeader,
CardTitle,
} from "@/components/ui/card";
import { Input } from "@/components/ui/input";
import { Modal } from "@/components/ui/modal";
import { Skeleton } from "@/components/ui/skeleton";
@@ -35,6 +28,7 @@ import {
TableHeader,
TableRow,
} from "@/components/ui/table";
import { Tabs, TabsContent, TabsList, TabsTrigger } from "@/components/ui/tabs";
import {
Tooltip,
TooltipContent,
@@ -43,6 +37,7 @@ import {
} from "@/components/ui/tooltip";
import { FormType } from "@/components/forms/record-form";
import { UrlForm } from "@/components/forms/url-form";
import ApiReference from "@/components/shared/api-reference";
import { CopyButton } from "@/components/shared/copy-button";
import { EmptyPlaceholder } from "@/components/shared/empty-placeholder";
import { Icons } from "@/components/shared/icons";
@@ -50,6 +45,8 @@ import { LinkInfoPreviewer } from "@/components/shared/link-previewer";
import { PaginationWrapper } from "@/components/shared/pagination";
import QRCodeEditor from "@/components/shared/qr";
import Globe from "./globe";
import LiveLog from "./live-logs";
import UserUrlMetaInfo from "./meta";
export interface UrlListProps {
@@ -142,18 +139,268 @@ export default function UserUrlsList({ user, action }: UrlListProps) {
}
};
const renderList = () => (
<div className="rounded-lg border p-4">
<div className="mb-2 flex-row items-center gap-2 space-y-2 sm:flex sm:space-y-0">
<div className="relative w-full">
<Input
className="h-8 text-xs md:text-xs"
placeholder="Search by slug..."
value={searchParams.slug}
onChange={(e) => {
setSearchParams({
...searchParams,
slug: e.target.value,
});
}}
/>
{searchParams.slug && (
<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={() => setSearchParams({ ...searchParams, slug: "" })}
variant={"ghost"}
>
<Icons.close className="size-3" />
</Button>
)}
</div>
<div className="relative w-full">
<Input
className="h-8 text-xs md:text-xs"
placeholder="Search by target..."
value={searchParams.target}
onChange={(e) => {
setSearchParams({
...searchParams,
target: e.target.value,
});
}}
/>
{searchParams.target && (
<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={() => setSearchParams({ ...searchParams, target: "" })}
variant={"ghost"}
>
<Icons.close className="size-3" />
</Button>
)}
</div>
{user.role === "ADMIN" && (
<div className="relative w-full">
<Input
className="h-8 text-xs md:text-xs"
placeholder="Search by user name..."
value={searchParams.userName}
onChange={(e) => {
setSearchParams({
...searchParams,
userName: e.target.value,
});
}}
/>
{searchParams.userName && (
<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={() =>
setSearchParams({ ...searchParams, userName: "" })
}
variant={"ghost"}
>
<Icons.close className="size-3" />
</Button>
)}
</div>
)}
</div>
<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">
Slug
</TableHead>
<TableHead className="col-span-1 flex items-center font-bold sm:col-span-2">
Target
</TableHead>
<TableHead className="col-span-1 hidden items-center font-bold sm:flex">
User
</TableHead>
<TableHead className="col-span-1 hidden items-center font-bold sm:flex">
Enabled
</TableHead>
<TableHead className="col-span-1 hidden items-center font-bold sm:flex">
Expiration
</TableHead>
<TableHead className="col-span-1 hidden items-center font-bold sm:flex">
Updated
</TableHead>
<TableHead className="col-span-1 hidden items-center font-bold sm:flex">
Created
</TableHead>
<TableHead className="col-span-1 flex items-center font-bold sm:col-span-2">
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}/s/${short.url}${short.password ? `?password=${short.password}` : ""}`}
target="_blank"
prefetch={false}
title={short.url}
>
{short.url}
</Link>
<CopyButton
value={`${short.prefix}/s/${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={short.target}
formatUrl={removeUrlSuffix(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">
{timeAgo(short.updatedAt as Date)}
</TableCell>
<TableCell className="col-span-1 hidden truncate sm:flex">
{timeAgo(short.createdAt 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"
size="sm"
variant={"outline"}
onClick={() => {
setCurrentEditUrl(short);
setShowForm(false);
setFormType("edit");
setShowForm(!isShowForm);
}}
>
<p className="hidden sm:block">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);
if (isShowStats && selectedUrl?.id !== short.id) {
} else {
setShowStats(!isShowStats);
}
}}
>
<Icons.lineChart className="mx-0.5 size-4" />
</Button>
</TableCell>
</TableRow>
{isShowStats && selectedUrl?.id === short.id && (
<UserUrlMetaInfo
user={{
id: user.id,
name: user.name || "",
team: user.team,
}}
action="/api/url/meta"
urlId={short.id!}
/>
)}
</div>
))
) : (
<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>
</EmptyPlaceholder>
)}
</TableBody>
{data && Math.ceil(data.total / pageSize) > 1 && (
<PaginationWrapper
total={data.total}
currentPage={currentPage}
setCurrentPage={setCurrentPage}
pageSize={pageSize}
setPageSize={setPageSize}
/>
)}
</Table>
</div>
);
return (
<>
<Card className="xl:col-span-2">
<CardHeader className="flex flex-row items-center">
{action.includes("/admin") ? (
<CardDescription className="text-balance text-lg font-bold">
<span>Total URLs:</span>{" "}
<span className="font-bold">{data && data.total}</span>
</CardDescription>
) : (
<CardTitle>Short URLs</CardTitle>
)}
<Tabs defaultValue="Links">
<div className="mb-4 flex items-center justify-between gap-2">
<TabsList>
<TabsTrigger value="Links">Links</TabsTrigger>
<TabsTrigger value="Realtime">Realtime</TabsTrigger>
</TabsList>
<div className="ml-auto flex items-center justify-end gap-3">
<Button
variant={"outline"}
@@ -180,261 +427,21 @@ export default function UserUrlsList({ user, action }: UrlListProps) {
<span className="hidden sm:inline">Add URL</span>
</Button>
</div>
</CardHeader>
<CardContent>
<div className="mb-2 flex-row items-center gap-2 space-y-2 sm:flex sm:space-y-0">
<div className="relative w-full">
<Input
className="h-8 text-xs md:text-xs"
placeholder="Search by slug..."
value={searchParams.slug}
onChange={(e) => {
setSearchParams({
...searchParams,
slug: e.target.value,
});
}}
/>
{searchParams.slug && (
<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={() => setSearchParams({ ...searchParams, slug: "" })}
variant={"ghost"}
>
<Icons.close className="size-3" />
</Button>
)}
</div>
</div>
<div className="relative w-full">
<Input
className="h-8 text-xs md:text-xs"
placeholder="Search by target..."
value={searchParams.target}
onChange={(e) => {
setSearchParams({
...searchParams,
target: e.target.value,
});
}}
/>
{searchParams.target && (
<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={() =>
setSearchParams({ ...searchParams, target: "" })
}
variant={"ghost"}
>
<Icons.close className="size-3" />
</Button>
)}
</div>
{user.role === "ADMIN" && (
<div className="relative w-full">
<Input
className="h-8 text-xs md:text-xs"
placeholder="Search by user name..."
value={searchParams.userName}
onChange={(e) => {
setSearchParams({
...searchParams,
userName: e.target.value,
});
}}
/>
{searchParams.userName && (
<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={() =>
setSearchParams({ ...searchParams, userName: "" })
}
variant={"ghost"}
>
<Icons.close className="size-3" />
</Button>
)}
</div>
)}
</div>
<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">
Slug
</TableHead>
<TableHead className="col-span-1 flex items-center font-bold sm:col-span-2">
Target
</TableHead>
<TableHead className="col-span-1 hidden items-center font-bold sm:flex">
User
</TableHead>
<TableHead className="col-span-1 hidden items-center font-bold sm:flex">
Enabled
</TableHead>
<TableHead className="col-span-1 hidden items-center font-bold sm:flex">
Expiration
</TableHead>
<TableHead className="col-span-1 hidden items-center font-bold sm:flex">
Updated
</TableHead>
<TableHead className="col-span-1 hidden items-center font-bold sm:flex">
Created
</TableHead>
<TableHead className="col-span-1 flex items-center font-bold sm:col-span-2">
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}/s/${short.url}${short.password ? `?password=${short.password}` : ""}`}
target="_blank"
prefetch={false}
title={short.url}
>
{short.url}
</Link>
<CopyButton
value={`${short.prefix}/s/${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={short.target}
formatUrl={removeUrlSuffix(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">
{timeAgo(short.updatedAt as Date)}
</TableCell>
<TableCell className="col-span-1 hidden truncate sm:flex">
{timeAgo(short.createdAt 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"
size="sm"
variant={"outline"}
onClick={() => {
setCurrentEditUrl(short);
setShowForm(false);
setFormType("edit");
setShowForm(!isShowForm);
}}
>
<p className="hidden sm:block">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);
if (isShowStats && selectedUrl?.id !== short.id) {
} else {
setShowStats(!isShowStats);
}
}}
>
<Icons.lineChart className="mx-0.5 size-4" />
</Button>
</TableCell>
</TableRow>
{isShowStats && selectedUrl?.id === short.id && (
<UserUrlMetaInfo
user={{
id: user.id,
name: user.name || "",
team: user.team,
}}
action="/api/url/meta"
urlId={short.id!}
/>
)}
</div>
))
) : (
<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>
</EmptyPlaceholder>
)}
</TableBody>
{data && Math.ceil(data.total / pageSize) > 1 && (
<PaginationWrapper
total={data.total}
currentPage={currentPage}
setCurrentPage={setCurrentPage}
pageSize={pageSize}
setPageSize={setPageSize}
/>
)}
</Table>
</CardContent>
</Card>
<TabsContent className="space-y-3" value="Links">
{renderList()}
<LiveLog admin={action.indexOf("admin") > -1} />
<ApiReference
badge="POST /api/v1/short"
target="creating short urls"
link="/docs/short-urls#api-reference"
/>
</TabsContent>
<TabsContent value="Realtime">
<Globe isAdmin={action.indexOf("admin") > -1} />
</TabsContent>
</Tabs>
{/* QR code editor */}
<Modal
@@ -0,0 +1,57 @@
"use client";
import { useState } from "react";
import { User } from "@prisma/client";
import { Tabs, TabsContent, TabsList, TabsTrigger } from "@/components/ui/tabs";
import ApiReference from "@/components/shared/api-reference";
import Globe from "./globe";
import LiveLog from "./live-logs";
import UserUrlsList from "./url-list";
export function Wrapper({
user,
}: {
user: Pick<User, "id" | "name" | "apiKey" | "role" | "team">;
}) {
const [tab, setTab] = useState("Links");
return (
<Tabs
value={tab}
onChangeCapture={(e) => console.log(e)}
defaultValue={tab}
>
<TabsList>
<TabsTrigger value="Links" onClick={() => setTab("Links")}>
Links
</TabsTrigger>
<TabsTrigger value="Realtime" onClick={() => setTab("Realtime")}>
Realtime
</TabsTrigger>
</TabsList>
)
<TabsContent className="space-y-3" value="Links">
<UserUrlsList
user={{
id: user.id,
name: user.name || "",
apiKey: user.apiKey || "",
role: user.role,
team: user.team,
}}
action="/api/url"
/>
<LiveLog admin={false} />
<ApiReference
badge="POST /api/v1/short"
target="creating short urls"
link="/docs/short-urls#api-reference"
/>
</TabsContent>
<TabsContent value="Realtime">
<Globe />
</TabsContent>
</Tabs>
);
}
+1 -1
View File
File diff suppressed because one or more lines are too long