Refactor
This commit is contained in:
16
src/app/(admin)/admin/_components/dashboard-title.tsx
Normal file
16
src/app/(admin)/admin/_components/dashboard-title.tsx
Normal file
@@ -0,0 +1,16 @@
|
||||
type DashBoardTitleProps = {
|
||||
renderSubtitle: true
|
||||
subtitle: string
|
||||
} | {
|
||||
renderSubtitle?: false
|
||||
subtitle?: never
|
||||
}
|
||||
|
||||
export default function DashBoardTitle({ title, subtitle, renderSubtitle }: DashBoardTitleProps & { title: string }) {
|
||||
return (
|
||||
<div className="pb-6">
|
||||
<h1 className="mb-2 text-2xl font-bold text-foreground">{title}</h1>
|
||||
{renderSubtitle && <p className="text-sm text-muted-foreground">{subtitle}</p>}
|
||||
</div>
|
||||
)
|
||||
}
|
||||
118
src/app/(admin)/admin/_components/passkey.tsx
Normal file
118
src/app/(admin)/admin/_components/passkey.tsx
Normal file
@@ -0,0 +1,118 @@
|
||||
"use client"
|
||||
|
||||
import { Button } from "@/components/ui/button"
|
||||
import { Card, CardContent, CardHeader, CardTitle } from "@/components/ui/card"
|
||||
import { Dialog, DialogClose, DialogContent, DialogDescription, DialogFooter, DialogHeader, DialogTitle, DialogTrigger } from "@/components/ui/dialog"
|
||||
import { Form, FormControl, FormField, FormItem, FormLabel, FormMessage } from "@/components/ui/form"
|
||||
import { Input } from "@/components/ui/input"
|
||||
import { authClient } from "@/lib/auth/auth-client"
|
||||
import { zodResolver } from "@hookform/resolvers/zod"
|
||||
import { KeyRoundIcon } from "lucide-react"
|
||||
import { useRouter } from "next/navigation"
|
||||
import { useState } from "react"
|
||||
import { useForm } from "react-hook-form"
|
||||
import { toast } from "sonner"
|
||||
import { z } from "zod"
|
||||
|
||||
export function PasskeyAdd() {
|
||||
const router = useRouter()
|
||||
const form = useForm<{ name: string }>({
|
||||
resolver: zodResolver(z.object({ name: z.string().min(1).max(50) })),
|
||||
defaultValues: { name: "passkey" }
|
||||
})
|
||||
|
||||
async function handleSubmit({ name }: { name: string }) {
|
||||
const res = await authClient.passkey.addPasskey({ name })
|
||||
|
||||
if (res && res.error) {
|
||||
toast.error(res.error.message)
|
||||
return
|
||||
}
|
||||
|
||||
toast.success("Passkey added successfully")
|
||||
router.refresh()
|
||||
form.reset({ name: "" })
|
||||
}
|
||||
|
||||
return (
|
||||
<Card className="max-w-lg">
|
||||
<CardHeader>
|
||||
<CardTitle>Passkeys</CardTitle>
|
||||
</CardHeader>
|
||||
<CardContent>
|
||||
<Form {...form}>
|
||||
<form onSubmit={form.handleSubmit(handleSubmit)} className="space-y-4 mb-5 max-w-48">
|
||||
<FormField
|
||||
control={form.control}
|
||||
name="name"
|
||||
render={({ field }) => (
|
||||
<FormItem>
|
||||
<FormLabel>Name</FormLabel>
|
||||
<FormControl>
|
||||
<Input {...field} />
|
||||
</FormControl>
|
||||
<FormMessage />
|
||||
</FormItem>
|
||||
)}
|
||||
/>
|
||||
<Button type="submit" disabled={form.formState.isSubmitting}>
|
||||
{form.formState.isSubmitting ? "Adding..." : "Add Passkey"}
|
||||
</Button>
|
||||
</form>
|
||||
</Form>
|
||||
</CardContent>
|
||||
</Card>
|
||||
)
|
||||
}
|
||||
|
||||
export function PasskeyItem({ id, name, createdAt }: { id: string, name?: string, createdAt: Date }) {
|
||||
const [isLoading, setIsLoading] = useState(false)
|
||||
const router = useRouter()
|
||||
|
||||
async function handleDelete() {
|
||||
setIsLoading(true)
|
||||
await authClient.passkey.deletePasskey({ id })
|
||||
router.refresh()
|
||||
}
|
||||
|
||||
return (
|
||||
<Card>
|
||||
<CardContent>
|
||||
<p className="font-medium flex gap-2">
|
||||
<span>
|
||||
<KeyRoundIcon />
|
||||
</span>
|
||||
<span>{name}</span>
|
||||
</p>
|
||||
<p className="text-sm text-muted-foreground">
|
||||
Created at: {new Date(createdAt).toLocaleString()}
|
||||
</p>
|
||||
<Dialog>
|
||||
<DialogTrigger asChild className="mt-2">
|
||||
<Button variant="destructive">
|
||||
Delete
|
||||
</Button>
|
||||
</DialogTrigger>
|
||||
<DialogContent>
|
||||
<DialogHeader>
|
||||
<DialogTitle>Are you sure?</DialogTitle>
|
||||
<DialogDescription>
|
||||
This action cannot be undone. This will permanently delete the passkey.
|
||||
</DialogDescription>
|
||||
</DialogHeader>
|
||||
<DialogFooter>
|
||||
<DialogClose asChild>
|
||||
<Button variant="outline">
|
||||
Cancel
|
||||
</Button>
|
||||
</DialogClose>
|
||||
<Button className="ml-2" variant="destructive" disabled={isLoading} onClick={handleDelete}>
|
||||
{isLoading ? "Deleting..." : "Delete"}
|
||||
</Button>
|
||||
</DialogFooter>
|
||||
</DialogContent>
|
||||
</Dialog>
|
||||
</CardContent>
|
||||
</Card>
|
||||
)
|
||||
}
|
||||
26
src/app/(admin)/admin/_components/passkeys-list.tsx
Normal file
26
src/app/(admin)/admin/_components/passkeys-list.tsx
Normal file
@@ -0,0 +1,26 @@
|
||||
import { auth } from "@/lib/auth/auth"
|
||||
import { headers } from "next/headers"
|
||||
import { PasskeyItem } from "./passkey"
|
||||
|
||||
export default async function PasskeysList() {
|
||||
const passkeys = await auth.api.listPasskeys({
|
||||
headers: await headers()
|
||||
})
|
||||
|
||||
return (
|
||||
<div className="space-y-4 max-w-lg">
|
||||
{passkeys.length === 0 ? <p className="text-sm text-muted-foreground">No passkeys registered.</p> : (
|
||||
<div>
|
||||
{passkeys.map(passkey => (
|
||||
<PasskeyItem
|
||||
key={passkey.id}
|
||||
id={passkey.id}
|
||||
name={passkey.name}
|
||||
createdAt={passkey.createdAt}
|
||||
/>
|
||||
))}
|
||||
</div>
|
||||
)}
|
||||
</div>
|
||||
)
|
||||
}
|
||||
7
src/app/(admin)/admin/_components/password.tsx
Normal file
7
src/app/(admin)/admin/_components/password.tsx
Normal file
@@ -0,0 +1,7 @@
|
||||
import { getSession } from "@/lib/auth/session"
|
||||
|
||||
export default async function PasswordSettings() {
|
||||
const {} = await getSession()
|
||||
|
||||
return null
|
||||
}
|
||||
23
src/app/(admin)/admin/_components/sidebar-client.tsx
Normal file
23
src/app/(admin)/admin/_components/sidebar-client.tsx
Normal file
@@ -0,0 +1,23 @@
|
||||
"use client"
|
||||
|
||||
import { SidebarTrigger } from "@/components/ui/sidebar"
|
||||
import { useIsMobile } from "@/hooks/use-mobile"
|
||||
import { ReactNode } from "react"
|
||||
|
||||
export function SidebarClient({ children }: { children: ReactNode }) {
|
||||
const isMobile = useIsMobile()
|
||||
|
||||
if (isMobile) {
|
||||
return (
|
||||
<div className="flex flex-col w-full">
|
||||
<div className="flex gap-1 items-center p-2 border-b">
|
||||
<SidebarTrigger />
|
||||
<span className="text-xl">Linker</span>
|
||||
</div>
|
||||
<div className="flex flex-1">{children}</div>
|
||||
</div>
|
||||
)
|
||||
}
|
||||
|
||||
return children
|
||||
}
|
||||
85
src/app/(admin)/admin/_components/sidebar-theme-toggle.tsx
Normal file
85
src/app/(admin)/admin/_components/sidebar-theme-toggle.tsx
Normal file
@@ -0,0 +1,85 @@
|
||||
"use client"
|
||||
|
||||
import { DropdownMenu, DropdownMenuContent, DropdownMenuItem, DropdownMenuTrigger } from "@/components/ui/dropdown-menu"
|
||||
import { SidebarMenu, SidebarMenuButton, SidebarMenuItem } from "@/components/ui/sidebar"
|
||||
import { ChevronDown, Monitor, Moon, Sun } from "lucide-react"
|
||||
import { useTheme } from "next-themes"
|
||||
import { useEffect, useState } from "react"
|
||||
|
||||
export function SidebarThemeToggle() {
|
||||
const { theme, setTheme } = useTheme()
|
||||
const [mounted, setMounted] = useState(false)
|
||||
|
||||
// Prevent hydration mismatch
|
||||
useEffect(() => {
|
||||
setMounted(true)
|
||||
}, [])
|
||||
|
||||
if (!mounted) {
|
||||
return (
|
||||
<SidebarMenu>
|
||||
<SidebarMenuItem>
|
||||
<SidebarMenuButton disabled tooltip="Theme">
|
||||
<Sun className="w-4 h-4" />
|
||||
<span>Theme</span>
|
||||
</SidebarMenuButton>
|
||||
</SidebarMenuItem>
|
||||
</SidebarMenu>
|
||||
)
|
||||
}
|
||||
|
||||
const getIcon = () => {
|
||||
switch (theme) {
|
||||
case "light":
|
||||
return <Sun className="w-4 h-4" />
|
||||
case "dark":
|
||||
return <Moon className="w-4 h-4" />
|
||||
default:
|
||||
return <Monitor className="w-4 h-4" />
|
||||
}
|
||||
}
|
||||
|
||||
const getThemeLabel = () => {
|
||||
switch (theme) {
|
||||
case "light":
|
||||
return "Light"
|
||||
case "dark":
|
||||
return "Dark"
|
||||
default:
|
||||
return "System"
|
||||
}
|
||||
}
|
||||
|
||||
return (
|
||||
<SidebarMenu>
|
||||
<SidebarMenuItem>
|
||||
<DropdownMenu>
|
||||
<DropdownMenuTrigger asChild>
|
||||
<SidebarMenuButton
|
||||
tooltip={`Theme: ${getThemeLabel()}`}
|
||||
className="pl-4"
|
||||
>
|
||||
{getIcon()}
|
||||
<span>Theme ({getThemeLabel()})</span>
|
||||
<ChevronDown className="ml-auto w-4 h-4" />
|
||||
</SidebarMenuButton>
|
||||
</DropdownMenuTrigger>
|
||||
<DropdownMenuContent align="end" side="top">
|
||||
<DropdownMenuItem onClick={() => setTheme("light")}>
|
||||
<Sun className="mr-2 w-4 h-4" />
|
||||
Light
|
||||
</DropdownMenuItem>
|
||||
<DropdownMenuItem onClick={() => setTheme("dark")}>
|
||||
<Moon className="mr-2 w-4 h-4" />
|
||||
Dark
|
||||
</DropdownMenuItem>
|
||||
<DropdownMenuItem onClick={() => setTheme("system")}>
|
||||
<Monitor className="mr-2 w-4 h-4" />
|
||||
System
|
||||
</DropdownMenuItem>
|
||||
</DropdownMenuContent>
|
||||
</DropdownMenu>
|
||||
</SidebarMenuItem>
|
||||
</SidebarMenu>
|
||||
)
|
||||
}
|
||||
126
src/app/(admin)/admin/_components/sidebar-user-dropdown.tsx
Normal file
126
src/app/(admin)/admin/_components/sidebar-user-dropdown.tsx
Normal file
@@ -0,0 +1,126 @@
|
||||
"use client"
|
||||
|
||||
import { Avatar, AvatarFallback, AvatarImage } from "@/components/ui/avatar"
|
||||
import {
|
||||
DropdownMenu,
|
||||
DropdownMenuContent,
|
||||
DropdownMenuItem,
|
||||
DropdownMenuLabel,
|
||||
DropdownMenuSeparator,
|
||||
DropdownMenuTrigger
|
||||
} from "@/components/ui/dropdown-menu"
|
||||
import { SidebarMenu, SidebarMenuButton, SidebarMenuItem, useSidebar } from "@/components/ui/sidebar"
|
||||
import { authClient } from "@/lib/auth/auth-client"
|
||||
import { LogOut, User } from "lucide-react"
|
||||
import Link from "next/link"
|
||||
import { useRouter } from "next/navigation"
|
||||
import { toast } from "sonner"
|
||||
|
||||
export function SidebarUserDropdown() {
|
||||
const { data: session, isPending } = authClient.useSession()
|
||||
const { state } = useSidebar()
|
||||
const router = useRouter()
|
||||
|
||||
const handleSignOut = async () => {
|
||||
const res = await authClient.signOut({
|
||||
fetchOptions: {
|
||||
onSuccess: () => {
|
||||
router.push("/")
|
||||
}
|
||||
}
|
||||
})
|
||||
|
||||
if (res && res.error) {
|
||||
toast.error(res.error.message)
|
||||
}
|
||||
}
|
||||
|
||||
if (isPending) {
|
||||
return (
|
||||
<SidebarMenu>
|
||||
<SidebarMenuItem>
|
||||
<SidebarMenuButton disabled>
|
||||
<User />
|
||||
<span>Loading...</span>
|
||||
</SidebarMenuButton>
|
||||
</SidebarMenuItem>
|
||||
</SidebarMenu>
|
||||
)
|
||||
}
|
||||
|
||||
if (!session?.user) {
|
||||
return (
|
||||
<SidebarMenu>
|
||||
<SidebarMenuItem>
|
||||
<SidebarMenuButton asChild>
|
||||
<Link href="/sign-in">
|
||||
<User />
|
||||
<span>Sign in</span>
|
||||
</Link>
|
||||
</SidebarMenuButton>
|
||||
</SidebarMenuItem>
|
||||
</SidebarMenu>
|
||||
)
|
||||
}
|
||||
|
||||
const user = session.user
|
||||
const userInitials = user.name
|
||||
? user.name.split(" ").map(n => n[0]).join("").toUpperCase()
|
||||
: user.email?.[0]?.toUpperCase() || "U"
|
||||
|
||||
return (
|
||||
<SidebarMenu>
|
||||
<SidebarMenuItem>
|
||||
<DropdownMenu>
|
||||
<DropdownMenuTrigger asChild>
|
||||
<SidebarMenuButton
|
||||
size="lg"
|
||||
className="data-[state=open]:bg-sidebar-accent data-[state=open]:text-sidebar-accent-foreground"
|
||||
>
|
||||
<Avatar className="w-8 h-8 rounded-lg">
|
||||
<AvatarImage src={user.image || undefined} alt={user.name || "User"} />
|
||||
<AvatarFallback className="rounded-lg">{userInitials}</AvatarFallback>
|
||||
</Avatar>
|
||||
<div className="grid flex-1 text-sm leading-tight text-left">
|
||||
<span className="font-semibold truncate">
|
||||
{user.name || "User"}
|
||||
</span>
|
||||
<span className="text-xs truncate text-muted-foreground">
|
||||
{user.email}
|
||||
</span>
|
||||
</div>
|
||||
</SidebarMenuButton>
|
||||
</DropdownMenuTrigger>
|
||||
<DropdownMenuContent
|
||||
className="rounded-lg w-[--radix-dropdown-menu-trigger-width] min-w-56"
|
||||
side={state === "collapsed" ? "right" : "bottom"}
|
||||
align="end"
|
||||
sideOffset={4}
|
||||
>
|
||||
<DropdownMenuLabel className="p-0 font-normal">
|
||||
<div className="flex gap-2 items-center py-1.5 px-1 text-sm text-left">
|
||||
<Avatar className="w-8 h-8 rounded-lg">
|
||||
<AvatarImage src={user.image || undefined} alt={user.name || "User"} />
|
||||
<AvatarFallback className="rounded-lg">{userInitials}</AvatarFallback>
|
||||
</Avatar>
|
||||
<div className="grid flex-1 text-sm leading-tight text-left">
|
||||
<span className="font-semibold truncate">
|
||||
{user.name || "User"}
|
||||
</span>
|
||||
<span className="text-xs truncate text-muted-foreground">
|
||||
{user.email}
|
||||
</span>
|
||||
</div>
|
||||
</div>
|
||||
</DropdownMenuLabel>
|
||||
<DropdownMenuSeparator />
|
||||
<DropdownMenuItem onClick={handleSignOut}>
|
||||
<LogOut className="mr-2 w-4 h-4" />
|
||||
<span>Log out</span>
|
||||
</DropdownMenuItem>
|
||||
</DropdownMenuContent>
|
||||
</DropdownMenu>
|
||||
</SidebarMenuItem>
|
||||
</SidebarMenu>
|
||||
)
|
||||
}
|
||||
144
src/app/(admin)/admin/_components/sidebar.tsx
Normal file
144
src/app/(admin)/admin/_components/sidebar.tsx
Normal file
@@ -0,0 +1,144 @@
|
||||
"use client"
|
||||
|
||||
import { HomeIcon, KeyRoundIcon, LayoutDashboard, List, PanelLeft, Plus, User2 } from "lucide-react"
|
||||
import Link from "next/link"
|
||||
import { usePathname } from "next/navigation"
|
||||
|
||||
import {
|
||||
Sidebar,
|
||||
SidebarContent,
|
||||
SidebarFooter,
|
||||
SidebarGroup,
|
||||
SidebarGroupContent,
|
||||
SidebarGroupLabel,
|
||||
SidebarHeader,
|
||||
SidebarMenu,
|
||||
SidebarMenuButton,
|
||||
SidebarMenuItem,
|
||||
useSidebar
|
||||
} from "@/components/ui/sidebar"
|
||||
import { SidebarThemeToggle } from "./sidebar-theme-toggle"
|
||||
import { SidebarUserDropdown } from "./sidebar-user-dropdown"
|
||||
|
||||
const dashboardItems = [
|
||||
{
|
||||
title: "Dashboard",
|
||||
url: "/admin/dashboard",
|
||||
icon: LayoutDashboard
|
||||
},
|
||||
{
|
||||
title: "List",
|
||||
url: "/admin/dashboard/list",
|
||||
icon: List
|
||||
},
|
||||
{
|
||||
title: "Create",
|
||||
url: "/admin/dashboard/create",
|
||||
icon: Plus
|
||||
}
|
||||
]
|
||||
|
||||
const userItems = [
|
||||
{
|
||||
title: "Profile",
|
||||
url: "/admin/user",
|
||||
icon: User2
|
||||
},
|
||||
{
|
||||
title: "Auth",
|
||||
url: "/admin/user/auth",
|
||||
icon: KeyRoundIcon
|
||||
}
|
||||
]
|
||||
|
||||
export function DashboardSidebar() {
|
||||
const pathname = usePathname()
|
||||
const { toggleSidebar } = useSidebar()
|
||||
|
||||
return (
|
||||
<Sidebar collapsible="icon">
|
||||
<SidebarHeader>
|
||||
<SidebarMenu>
|
||||
<SidebarMenuItem>
|
||||
<SidebarMenuButton
|
||||
onClick={toggleSidebar}
|
||||
tooltip="Toggle Sidebar"
|
||||
>
|
||||
<PanelLeft />
|
||||
<span>Toggle Sidebar</span>
|
||||
</SidebarMenuButton>
|
||||
</SidebarMenuItem>
|
||||
</SidebarMenu>
|
||||
</SidebarHeader>
|
||||
<SidebarContent>
|
||||
<SidebarGroup>
|
||||
<SidebarGroupLabel>
|
||||
Menu
|
||||
</SidebarGroupLabel>
|
||||
<SidebarGroupContent>
|
||||
<SidebarMenu>
|
||||
<SidebarMenuItem>
|
||||
<SidebarMenuButton asChild tooltip="Home">
|
||||
<Link href="/">
|
||||
<HomeIcon />
|
||||
<span>Home</span>
|
||||
</Link>
|
||||
</SidebarMenuButton>
|
||||
</SidebarMenuItem>
|
||||
</SidebarMenu>
|
||||
</SidebarGroupContent>
|
||||
</SidebarGroup>
|
||||
<SidebarGroup>
|
||||
<SidebarGroupLabel className="group-data-[collapsible=icon]:hidden">
|
||||
Navigation
|
||||
</SidebarGroupLabel>
|
||||
<SidebarGroupContent>
|
||||
<SidebarMenu>
|
||||
{dashboardItems.map((item) => (
|
||||
<SidebarMenuItem key={item.title}>
|
||||
<SidebarMenuButton
|
||||
asChild
|
||||
isActive={pathname === item.url}
|
||||
tooltip={item.title}
|
||||
>
|
||||
<Link href={item.url}>
|
||||
<item.icon />
|
||||
<span>{item.title}</span>
|
||||
</Link>
|
||||
</SidebarMenuButton>
|
||||
</SidebarMenuItem>
|
||||
))}
|
||||
</SidebarMenu>
|
||||
</SidebarGroupContent>
|
||||
</SidebarGroup>
|
||||
<SidebarGroup>
|
||||
<SidebarGroupLabel className="group-data-[collapsible=icon]:hidden">
|
||||
User
|
||||
</SidebarGroupLabel>
|
||||
<SidebarGroupContent>
|
||||
<SidebarMenu>
|
||||
{userItems.map((item) => (
|
||||
<SidebarMenuItem key={item.title}>
|
||||
<SidebarMenuButton
|
||||
asChild
|
||||
isActive={pathname === item.url}
|
||||
tooltip={item.title}
|
||||
>
|
||||
<Link href={item.url}>
|
||||
<item.icon />
|
||||
<span>{item.title}</span>
|
||||
</Link>
|
||||
</SidebarMenuButton>
|
||||
</SidebarMenuItem>
|
||||
))}
|
||||
</SidebarMenu>
|
||||
</SidebarGroupContent>
|
||||
</SidebarGroup>
|
||||
</SidebarContent>
|
||||
<SidebarFooter>
|
||||
<SidebarThemeToggle />
|
||||
<SidebarUserDropdown />
|
||||
</SidebarFooter>
|
||||
</Sidebar>
|
||||
)
|
||||
}
|
||||
100
src/app/(admin)/admin/_components/simple-url-form-card.tsx
Normal file
100
src/app/(admin)/admin/_components/simple-url-form-card.tsx
Normal file
@@ -0,0 +1,100 @@
|
||||
"use client"
|
||||
|
||||
import { Button } from "@/components/ui/button"
|
||||
import { Card, CardContent, CardDescription, CardHeader, CardTitle } from "@/components/ui/card"
|
||||
import { Form, FormControl, FormDescription, FormField, FormItem, FormLabel, FormMessage } from "@/components/ui/form"
|
||||
import { Input } from "@/components/ui/input"
|
||||
import { addUrl } from "@/lib/actions/url"
|
||||
import { urlFormSchema } from "@/lib/schema/url"
|
||||
import { zodResolver } from "@hookform/resolvers/zod"
|
||||
import { useForm } from "react-hook-form"
|
||||
import { toast } from "sonner"
|
||||
import { z } from "zod"
|
||||
|
||||
type UrlFormValues = z.infer<typeof urlFormSchema>
|
||||
|
||||
export function UrlFormCard() {
|
||||
const form = useForm<UrlFormValues>({
|
||||
resolver: zodResolver(urlFormSchema),
|
||||
defaultValues: {
|
||||
url: "",
|
||||
slug: ""
|
||||
}
|
||||
})
|
||||
|
||||
async function handleSubmit(data: UrlFormValues) {
|
||||
const res = await addUrl(data)
|
||||
|
||||
if (res.error) {
|
||||
toast.error(res.message)
|
||||
} else {
|
||||
toast.success(res.message)
|
||||
form.reset()
|
||||
}
|
||||
}
|
||||
|
||||
return (
|
||||
<Card>
|
||||
<CardHeader>
|
||||
<CardTitle>Create Short Link</CardTitle>
|
||||
<CardDescription>
|
||||
Enter a URL and create a custom slug for your short link.
|
||||
</CardDescription>
|
||||
</CardHeader>
|
||||
<CardContent>
|
||||
<Form {...form}>
|
||||
<form
|
||||
onSubmit={form.handleSubmit(handleSubmit)}
|
||||
className="space-y-4"
|
||||
>
|
||||
<FormField
|
||||
control={form.control}
|
||||
name="url"
|
||||
render={({ field }) => (
|
||||
<FormItem>
|
||||
<FormLabel>Original URL</FormLabel>
|
||||
<FormControl>
|
||||
<Input
|
||||
placeholder="https://example.com"
|
||||
{...field}
|
||||
/>
|
||||
</FormControl>
|
||||
<FormDescription>
|
||||
The URL you want to shorten.
|
||||
</FormDescription>
|
||||
<FormMessage />
|
||||
</FormItem>
|
||||
)}
|
||||
/>
|
||||
<FormField
|
||||
control={form.control}
|
||||
name="slug"
|
||||
render={({ field }) => (
|
||||
<FormItem>
|
||||
<FormLabel>Custom Slug</FormLabel>
|
||||
<FormControl>
|
||||
<Input
|
||||
placeholder="my-custom-link"
|
||||
{...field}
|
||||
/>
|
||||
</FormControl>
|
||||
<FormDescription>
|
||||
A unique identifier for your short link.
|
||||
</FormDescription>
|
||||
<FormMessage />
|
||||
</FormItem>
|
||||
)}
|
||||
/>
|
||||
<Button
|
||||
type="submit"
|
||||
className="w-full"
|
||||
disabled={form.formState.isSubmitting}
|
||||
>
|
||||
{form.formState.isSubmitting ? "Creating..." : "Create Short Link"}
|
||||
</Button>
|
||||
</form>
|
||||
</Form>
|
||||
</CardContent>
|
||||
</Card>
|
||||
)
|
||||
}
|
||||
25
src/app/(admin)/admin/_components/stats-card.tsx
Normal file
25
src/app/(admin)/admin/_components/stats-card.tsx
Normal file
@@ -0,0 +1,25 @@
|
||||
import { Card, CardContent, CardHeader, CardTitle } from "@/components/ui/card"
|
||||
|
||||
interface StatsCardProps {
|
||||
title: string
|
||||
value: number | string
|
||||
icon?: React.ReactNode
|
||||
description?: string
|
||||
}
|
||||
|
||||
export function StatsCard({ title, value, icon, description }: StatsCardProps) {
|
||||
const displayValue = typeof value === "number" ? value.toLocaleString() : value
|
||||
|
||||
return (
|
||||
<Card>
|
||||
<CardHeader className="flex flex-row justify-between items-center pb-2 space-y-0">
|
||||
<CardTitle className="text-sm font-medium">{title}</CardTitle>
|
||||
{icon}
|
||||
</CardHeader>
|
||||
<CardContent>
|
||||
<div className="text-2xl font-bold">{displayValue}</div>
|
||||
{description && <p className="text-xs text-muted-foreground">{description}</p>}
|
||||
</CardContent>
|
||||
</Card>
|
||||
)
|
||||
}
|
||||
456
src/app/(admin)/admin/_components/url-form-card.tsx
Normal file
456
src/app/(admin)/admin/_components/url-form-card.tsx
Normal file
@@ -0,0 +1,456 @@
|
||||
"use client"
|
||||
|
||||
import { DatePicker } from "@/components/date-picker"
|
||||
import { Button } from "@/components/ui/button"
|
||||
import { Card, CardContent, CardDescription, CardHeader, CardTitle } from "@/components/ui/card"
|
||||
import { Form, FormControl, FormDescription, FormField, FormItem, FormLabel, FormMessage } from "@/components/ui/form"
|
||||
import { Input } from "@/components/ui/input"
|
||||
import { Switch } from "@/components/ui/switch"
|
||||
import { createAdvanceUrl, updateUrl } from "@/lib/actions/url"
|
||||
import { urls } from "@/lib/drizzle/schema"
|
||||
import { advancedUrlSchema, editUrlSchema } from "@/lib/schema/url"
|
||||
import { zodResolver } from "@hookform/resolvers/zod"
|
||||
import { useRouter } from "next/navigation"
|
||||
import { useForm } from "react-hook-form"
|
||||
import { toast } from "sonner"
|
||||
import { z } from "zod"
|
||||
|
||||
type AdvancedUrlFormValues = z.infer<typeof advancedUrlSchema>
|
||||
type EditUrlFormValues = z.infer<typeof editUrlSchema>
|
||||
|
||||
function AdvancedUrlForm() {
|
||||
const form = useForm<AdvancedUrlFormValues>({
|
||||
resolver: zodResolver(advancedUrlSchema),
|
||||
defaultValues: {
|
||||
url: "",
|
||||
slug: null,
|
||||
title: null,
|
||||
maxVisits: null,
|
||||
expDate: undefined,
|
||||
forwardQueryParams: true,
|
||||
crawlable: false
|
||||
}
|
||||
})
|
||||
|
||||
async function handleSubmit(formData: AdvancedUrlFormValues) {
|
||||
const res = await createAdvanceUrl(formData)
|
||||
|
||||
if (res.error) {
|
||||
toast.error(res.message)
|
||||
} else {
|
||||
toast.success(res.message)
|
||||
form.reset()
|
||||
}
|
||||
}
|
||||
|
||||
return (
|
||||
<Form {...form}>
|
||||
<form onSubmit={form.handleSubmit(handleSubmit)} className="space-y-4">
|
||||
<div className="grid grid-cols-1 gap-4 md:grid-cols-2">
|
||||
<div className="space-y-4">
|
||||
<FormField
|
||||
control={form.control}
|
||||
name="url"
|
||||
render={({ field }) => (
|
||||
<FormItem>
|
||||
<FormLabel required>Original URL</FormLabel>
|
||||
<FormControl>
|
||||
<Input placeholder="https://example.com" {...field} />
|
||||
</FormControl>
|
||||
<FormDescription>
|
||||
The URL you want to shorten.
|
||||
</FormDescription>
|
||||
<FormMessage />
|
||||
</FormItem>
|
||||
)}
|
||||
/>
|
||||
<FormField
|
||||
control={form.control}
|
||||
name="slug"
|
||||
render={({ field }) => (
|
||||
<FormItem>
|
||||
<FormLabel>Custom Slug</FormLabel>
|
||||
<FormControl>
|
||||
<Input placeholder="my-custom-link" {...field} value={field.value ?? ""} />
|
||||
</FormControl>
|
||||
<FormDescription>
|
||||
A unique identifier for your short link (max 10 characters).
|
||||
</FormDescription>
|
||||
<FormMessage />
|
||||
</FormItem>
|
||||
)}
|
||||
/>
|
||||
<FormField
|
||||
control={form.control}
|
||||
name="forwardQueryParams"
|
||||
render={({ field }) => (
|
||||
<FormItem className="flex justify-between items-center p-4 rounded-lg border">
|
||||
<div className="space-y-0.5">
|
||||
<FormLabel className="text-base">
|
||||
Forward Query Parameters
|
||||
</FormLabel>
|
||||
<FormDescription>
|
||||
Forward query parameters from the short link to the destination URL.
|
||||
</FormDescription>
|
||||
</div>
|
||||
<FormControl>
|
||||
<Switch
|
||||
checked={field.value}
|
||||
onCheckedChange={field.onChange}
|
||||
/>
|
||||
</FormControl>
|
||||
</FormItem>
|
||||
)}
|
||||
/>
|
||||
<FormField
|
||||
control={form.control}
|
||||
name="crawlable"
|
||||
render={({ field }) => (
|
||||
<FormItem className="flex justify-between items-center p-4 rounded-lg border">
|
||||
<div className="space-y-0.5">
|
||||
<FormLabel className="text-base">
|
||||
Crawlable
|
||||
</FormLabel>
|
||||
<FormDescription>
|
||||
Allow search engines to crawl and index this link.
|
||||
</FormDescription>
|
||||
</div>
|
||||
<FormControl>
|
||||
<Switch
|
||||
checked={field.value}
|
||||
onCheckedChange={field.onChange}
|
||||
/>
|
||||
</FormControl>
|
||||
</FormItem>
|
||||
)}
|
||||
/>
|
||||
</div>
|
||||
<div className="space-y-4">
|
||||
<FormField
|
||||
control={form.control}
|
||||
name="title"
|
||||
render={({ field }) => (
|
||||
<FormItem>
|
||||
<FormLabel>Title</FormLabel>
|
||||
<FormControl>
|
||||
<Input placeholder="My Link Title" {...field} value={field.value ?? ""} />
|
||||
</FormControl>
|
||||
<FormDescription>
|
||||
A descriptive title for your link (max 100 characters).
|
||||
</FormDescription>
|
||||
<FormMessage />
|
||||
</FormItem>
|
||||
)}
|
||||
/>
|
||||
<FormField
|
||||
control={form.control}
|
||||
name="maxVisits"
|
||||
render={({ field }) => (
|
||||
<FormItem>
|
||||
<FormLabel>Max Visits</FormLabel>
|
||||
<FormControl>
|
||||
<Input
|
||||
type="number"
|
||||
placeholder="100"
|
||||
{...field}
|
||||
value={field.value ?? ""}
|
||||
onChange={e =>
|
||||
field.onChange(
|
||||
isNaN(e.target.valueAsNumber)
|
||||
? null
|
||||
: e.target.valueAsNumber
|
||||
)}
|
||||
/>
|
||||
</FormControl>
|
||||
<FormDescription>
|
||||
Maximum number of visits before the link expires.
|
||||
</FormDescription>
|
||||
<FormMessage />
|
||||
</FormItem>
|
||||
)}
|
||||
/>
|
||||
<FormField
|
||||
control={form.control}
|
||||
name="expDate"
|
||||
render={({ field }) => (
|
||||
<FormItem>
|
||||
<FormLabel>Expiration Date</FormLabel>
|
||||
<FormControl>
|
||||
<DatePicker
|
||||
value={field.value}
|
||||
onChange={field.onChange}
|
||||
/>
|
||||
</FormControl>
|
||||
<FormDescription>
|
||||
When this link should expire and become inaccessible.
|
||||
</FormDescription>
|
||||
<FormMessage />
|
||||
</FormItem>
|
||||
)}
|
||||
/>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
<div>
|
||||
<Button
|
||||
type="submit"
|
||||
disabled={form.formState.isSubmitting}
|
||||
>
|
||||
{form.formState.isSubmitting ? "Creating..." : "Create Short Link"}
|
||||
</Button>
|
||||
</div>
|
||||
</form>
|
||||
</Form>
|
||||
)
|
||||
}
|
||||
|
||||
function EditUrlForm({ data }: { data: typeof urls.$inferSelect }) {
|
||||
const router = useRouter()
|
||||
const form = useForm<EditUrlFormValues>({
|
||||
resolver: zodResolver(editUrlSchema),
|
||||
defaultValues: {
|
||||
url: data.url,
|
||||
slug: data.slug,
|
||||
title: data.title || null,
|
||||
maxVisits: data.maxVisits || null,
|
||||
expDate: data.expDate || null,
|
||||
forwardQueryParams: data.forwardQueryParams,
|
||||
crawlable: data.crawlable
|
||||
}
|
||||
})
|
||||
|
||||
async function handleSubmit(formData: EditUrlFormValues) {
|
||||
const res = await updateUrl(data.id, formData)
|
||||
|
||||
if (res.error) {
|
||||
toast.error(res.message)
|
||||
} else {
|
||||
toast.success(res.message)
|
||||
router.push("/admin/dashboard/list")
|
||||
}
|
||||
}
|
||||
|
||||
const handleCancel = () => {
|
||||
router.push("/admin/dashboard/list")
|
||||
}
|
||||
|
||||
return (
|
||||
<Form {...form}>
|
||||
<form onSubmit={form.handleSubmit(handleSubmit)} className="space-y-4">
|
||||
<div className="grid grid-cols-1 gap-4 md:grid-cols-2">
|
||||
<div className="space-y-4">
|
||||
<FormField
|
||||
control={form.control}
|
||||
name="url"
|
||||
render={({ field }) => (
|
||||
<FormItem>
|
||||
<FormLabel>Original URL</FormLabel>
|
||||
<FormControl>
|
||||
<Input placeholder="https://example.com" {...field} />
|
||||
</FormControl>
|
||||
<FormDescription>
|
||||
The URL you want to shorten.
|
||||
</FormDescription>
|
||||
<FormMessage />
|
||||
</FormItem>
|
||||
)}
|
||||
/>
|
||||
<FormField
|
||||
control={form.control}
|
||||
name="slug"
|
||||
render={({ field }) => (
|
||||
<FormItem>
|
||||
<FormLabel>Custom Slug</FormLabel>
|
||||
<FormControl>
|
||||
<Input placeholder="my-custom-link" {...field} />
|
||||
</FormControl>
|
||||
<FormDescription>
|
||||
A unique identifier for your short link (max 10 characters).
|
||||
</FormDescription>
|
||||
<FormMessage />
|
||||
</FormItem>
|
||||
)}
|
||||
/>
|
||||
<FormField
|
||||
control={form.control}
|
||||
name="forwardQueryParams"
|
||||
render={({ field }) => (
|
||||
<FormItem className="flex justify-between items-center p-4 rounded-lg border">
|
||||
<div className="space-y-0.5">
|
||||
<FormLabel className="text-base">
|
||||
Forward Query Parameters
|
||||
</FormLabel>
|
||||
<FormDescription>
|
||||
Forward query parameters from the short link to the destination URL.
|
||||
</FormDescription>
|
||||
</div>
|
||||
<FormControl>
|
||||
<Switch
|
||||
checked={field.value}
|
||||
onCheckedChange={field.onChange}
|
||||
/>
|
||||
</FormControl>
|
||||
</FormItem>
|
||||
)}
|
||||
/>
|
||||
<FormField
|
||||
control={form.control}
|
||||
name="crawlable"
|
||||
render={({ field }) => (
|
||||
<FormItem className="flex justify-between items-center p-4 rounded-lg border">
|
||||
<div className="space-y-0.5">
|
||||
<FormLabel className="text-base">
|
||||
Crawlable
|
||||
</FormLabel>
|
||||
<FormDescription>
|
||||
Allow search engines to crawl and index this link.
|
||||
</FormDescription>
|
||||
</div>
|
||||
<FormControl>
|
||||
<Switch
|
||||
checked={field.value ?? false}
|
||||
onCheckedChange={field.onChange}
|
||||
/>
|
||||
</FormControl>
|
||||
</FormItem>
|
||||
)}
|
||||
/>
|
||||
</div>
|
||||
<div className="space-y-4">
|
||||
<FormField
|
||||
control={form.control}
|
||||
name="title"
|
||||
render={({ field }) => (
|
||||
<FormItem>
|
||||
<FormLabel>Title</FormLabel>
|
||||
<FormControl>
|
||||
<Input placeholder="My Link Title" {...field} value={field.value ?? ""} />
|
||||
</FormControl>
|
||||
<FormDescription>
|
||||
A descriptive title for your link (max 100 characters).
|
||||
</FormDescription>
|
||||
<FormMessage />
|
||||
</FormItem>
|
||||
)}
|
||||
/>
|
||||
<FormField
|
||||
control={form.control}
|
||||
name="maxVisits"
|
||||
render={({ field }) => (
|
||||
<FormItem>
|
||||
<FormLabel>Max Visits</FormLabel>
|
||||
<FormControl>
|
||||
<Input
|
||||
type="number"
|
||||
placeholder="100"
|
||||
{...field}
|
||||
value={field.value ?? ""}
|
||||
onChange={e =>
|
||||
field.onChange(
|
||||
isNaN(e.target.valueAsNumber)
|
||||
? null
|
||||
: e.target.valueAsNumber
|
||||
)}
|
||||
/>
|
||||
</FormControl>
|
||||
<FormDescription>
|
||||
Maximum number of visits before the link expires.
|
||||
</FormDescription>
|
||||
<FormMessage />
|
||||
</FormItem>
|
||||
)}
|
||||
/>
|
||||
<FormField
|
||||
control={form.control}
|
||||
name="expDate"
|
||||
render={({ field }) => (
|
||||
<FormItem>
|
||||
<FormLabel>Expiration Date</FormLabel>
|
||||
<FormControl>
|
||||
<DatePicker
|
||||
value={field.value}
|
||||
onChange={e => {
|
||||
field.onChange(e instanceof Date ? e : null)
|
||||
}}
|
||||
/>
|
||||
</FormControl>
|
||||
<FormDescription>
|
||||
When this link should expire and become inaccessible.
|
||||
</FormDescription>
|
||||
<FormMessage />
|
||||
</FormItem>
|
||||
)}
|
||||
/>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
<div className="flex gap-2">
|
||||
<Button
|
||||
type="submit"
|
||||
className="flex-1"
|
||||
disabled={form.formState.isSubmitting}
|
||||
>
|
||||
{form.formState.isSubmitting ? "Updating..." : "Update Short Link"}
|
||||
</Button>
|
||||
<Button
|
||||
type="button"
|
||||
variant="outline"
|
||||
onClick={handleCancel}
|
||||
disabled={form.formState.isSubmitting}
|
||||
>
|
||||
Cancel
|
||||
</Button>
|
||||
</div>
|
||||
</form>
|
||||
</Form>
|
||||
)
|
||||
}
|
||||
|
||||
type UrlFormCardProps = {
|
||||
editMode: true
|
||||
data: typeof urls.$inferSelect
|
||||
} | {
|
||||
editMode?: false
|
||||
data?: never
|
||||
}
|
||||
|
||||
export function UrlFormCard({ editMode, data }: UrlFormCardProps) {
|
||||
const getTitle = () => {
|
||||
switch (editMode) {
|
||||
case true:
|
||||
return "Edit Short Link"
|
||||
default:
|
||||
return "Create Short Link"
|
||||
}
|
||||
}
|
||||
|
||||
const getDescription = () => {
|
||||
switch (editMode) {
|
||||
case true:
|
||||
return "Update the details of your short link."
|
||||
default:
|
||||
return "Create a short link with advanced configuration options."
|
||||
}
|
||||
}
|
||||
|
||||
const renderForm = () => {
|
||||
switch (editMode) {
|
||||
case true:
|
||||
return <EditUrlForm data={data} />
|
||||
default:
|
||||
return <AdvancedUrlForm />
|
||||
}
|
||||
}
|
||||
|
||||
return (
|
||||
<Card>
|
||||
<CardHeader>
|
||||
<CardTitle>{getTitle()}</CardTitle>
|
||||
<CardDescription>{getDescription()}</CardDescription>
|
||||
</CardHeader>
|
||||
<CardContent>
|
||||
{renderForm()}
|
||||
</CardContent>
|
||||
</Card>
|
||||
)
|
||||
}
|
||||
438
src/app/(admin)/admin/_components/urls-data-table.tsx
Normal file
438
src/app/(admin)/admin/_components/urls-data-table.tsx
Normal file
@@ -0,0 +1,438 @@
|
||||
"use client"
|
||||
|
||||
import {
|
||||
ColumnDef,
|
||||
ColumnFiltersState,
|
||||
flexRender,
|
||||
getCoreRowModel,
|
||||
getFilteredRowModel,
|
||||
getPaginationRowModel,
|
||||
getSortedRowModel,
|
||||
SortingState,
|
||||
useReactTable,
|
||||
VisibilityState
|
||||
} from "@tanstack/react-table"
|
||||
import { ArrowUpDown, Check, Copy, MoreHorizontal, Trash2, X } from "lucide-react"
|
||||
import * as React from "react"
|
||||
|
||||
import { Button } from "@/components/ui/button"
|
||||
import {
|
||||
DropdownMenu,
|
||||
DropdownMenuCheckboxItem,
|
||||
DropdownMenuContent,
|
||||
DropdownMenuItem,
|
||||
DropdownMenuLabel,
|
||||
DropdownMenuSeparator,
|
||||
DropdownMenuTrigger
|
||||
} from "@/components/ui/dropdown-menu"
|
||||
import { Input } from "@/components/ui/input"
|
||||
import { Table, TableBody, TableCell, TableHead, TableHeader, TableRow } from "@/components/ui/table"
|
||||
import { deleteUrl } from "@/lib/actions/url"
|
||||
import { urls, visits } from "@/lib/drizzle/schema"
|
||||
import Link from "next/link"
|
||||
import { useRouter } from "next/navigation"
|
||||
import { toast } from "sonner"
|
||||
|
||||
type UrlRecord = typeof urls.$inferSelect & {
|
||||
visits?: (typeof visits.$inferSelect)[]
|
||||
}
|
||||
|
||||
const columns: ColumnDef<UrlRecord>[] = [
|
||||
{
|
||||
accessorKey: "slug",
|
||||
header: ({ column }) => {
|
||||
return (
|
||||
<Button
|
||||
variant="ghost"
|
||||
onClick={() => column.toggleSorting(column.getIsSorted() === "asc")}
|
||||
>
|
||||
Slug
|
||||
<ArrowUpDown className="ml-2 w-4 h-4" />
|
||||
</Button>
|
||||
)
|
||||
},
|
||||
cell: ({ row }) => <div className="font-mono text-sm">{row.getValue("slug")}</div>
|
||||
},
|
||||
{
|
||||
accessorKey: "url",
|
||||
header: ({ column }) => {
|
||||
return (
|
||||
<Button
|
||||
variant="ghost"
|
||||
onClick={() => column.toggleSorting(column.getIsSorted() === "asc")}
|
||||
>
|
||||
URL
|
||||
<ArrowUpDown className="ml-2 w-4 h-4" />
|
||||
</Button>
|
||||
)
|
||||
},
|
||||
cell: ({ row }) => {
|
||||
const url = row.getValue("url") as string
|
||||
return (
|
||||
<div className="max-w-[300px] truncate" title={url}>
|
||||
<Link href={url}>
|
||||
{url.replace(/^(https?:\/\/)/, "")}
|
||||
</Link>
|
||||
</div>
|
||||
)
|
||||
}
|
||||
},
|
||||
{
|
||||
accessorKey: "title",
|
||||
header: "Title",
|
||||
cell: ({ row }) => {
|
||||
const title = row.getValue("title") as string | null
|
||||
return <div className="max-w-[200px] truncate">{title || "No title"}</div>
|
||||
}
|
||||
},
|
||||
{
|
||||
id: "visits",
|
||||
header: ({ column }) => {
|
||||
return (
|
||||
<Button
|
||||
variant="ghost"
|
||||
onClick={() => column.toggleSorting(column.getIsSorted() === "asc")}
|
||||
>
|
||||
Visits
|
||||
<ArrowUpDown className="ml-2 w-4 h-4" />
|
||||
</Button>
|
||||
)
|
||||
},
|
||||
cell: ({ row }) => {
|
||||
const urlRecord = row.original
|
||||
const visitCount = urlRecord.visits?.length || 0
|
||||
return <div className="text-center">{visitCount}</div>
|
||||
},
|
||||
sortingFn: (rowA, rowB) => {
|
||||
const visitsA = rowA.original.visits?.length || 0
|
||||
const visitsB = rowB.original.visits?.length || 0
|
||||
return visitsA - visitsB
|
||||
}
|
||||
},
|
||||
{
|
||||
accessorKey: "maxVisits",
|
||||
header: ({ column }) => {
|
||||
return (
|
||||
<Button
|
||||
variant="ghost"
|
||||
onClick={() => column.toggleSorting(column.getIsSorted() === "asc")}
|
||||
>
|
||||
Max Visits
|
||||
<ArrowUpDown className="ml-2 w-4 h-4" />
|
||||
</Button>
|
||||
)
|
||||
},
|
||||
cell: ({ row }) => {
|
||||
const maxVisits = row.getValue("maxVisits") as number | null
|
||||
return <div>{maxVisits || "Unlimited"}</div>
|
||||
}
|
||||
},
|
||||
{
|
||||
accessorKey: "expDate",
|
||||
header: ({ column }) => {
|
||||
return (
|
||||
<Button
|
||||
variant="ghost"
|
||||
onClick={() => column.toggleSorting(column.getIsSorted() === "asc")}
|
||||
>
|
||||
Expires
|
||||
<ArrowUpDown className="ml-2 w-4 h-4" />
|
||||
</Button>
|
||||
)
|
||||
},
|
||||
cell: ({ row }) => {
|
||||
const expDate = row.getValue("expDate") as Date | null
|
||||
if (!expDate) return <div>Never</div>
|
||||
return <div>{expDate.toLocaleDateString()}</div>
|
||||
}
|
||||
},
|
||||
{
|
||||
accessorKey: "createdAt",
|
||||
header: ({ column }) => {
|
||||
return (
|
||||
<Button
|
||||
variant="ghost"
|
||||
onClick={() => column.toggleSorting(column.getIsSorted() === "asc")}
|
||||
>
|
||||
Created
|
||||
<ArrowUpDown className="ml-2 w-4 h-4" />
|
||||
</Button>
|
||||
)
|
||||
},
|
||||
cell: ({ row }) => {
|
||||
const createdAt = row.getValue("createdAt") as Date
|
||||
return <div>{createdAt.toLocaleDateString()}</div>
|
||||
}
|
||||
},
|
||||
{
|
||||
accessorKey: "updatedAt",
|
||||
header: ({ column }) => {
|
||||
return (
|
||||
<Button
|
||||
variant="ghost"
|
||||
onClick={() => column.toggleSorting(column.getIsSorted() === "asc")}
|
||||
>
|
||||
Updated
|
||||
<ArrowUpDown className="ml-2 w-4 h-4" />
|
||||
</Button>
|
||||
)
|
||||
},
|
||||
cell: ({ row }) => {
|
||||
const updatedAt = row.getValue("updatedAt") as Date
|
||||
return <div>{updatedAt.toLocaleDateString()}</div>
|
||||
}
|
||||
},
|
||||
{
|
||||
accessorKey: "crawlable",
|
||||
header: ({ column }) => {
|
||||
return (
|
||||
<Button
|
||||
variant="ghost"
|
||||
onClick={() => column.toggleSorting(column.getIsSorted() === "asc")}
|
||||
>
|
||||
Crawlable
|
||||
<ArrowUpDown className="ml-2 w-4 h-4" />
|
||||
</Button>
|
||||
)
|
||||
},
|
||||
cell: ({ row }) => {
|
||||
const crawlable = row.getValue("crawlable") as boolean
|
||||
return (
|
||||
<div className="flex justify-center">
|
||||
{crawlable ? <Check className="w-4 h-4 text-green-600" /> : <X className="w-4 h-4 text-red-600" />}
|
||||
</div>
|
||||
)
|
||||
}
|
||||
},
|
||||
{
|
||||
accessorKey: "forwardQueryParams",
|
||||
header: ({ column }) => {
|
||||
return (
|
||||
<Button
|
||||
variant="ghost"
|
||||
onClick={() => column.toggleSorting(column.getIsSorted() === "asc")}
|
||||
>
|
||||
Forward Params
|
||||
<ArrowUpDown className="ml-2 w-4 h-4" />
|
||||
</Button>
|
||||
)
|
||||
},
|
||||
cell: ({ row }) => {
|
||||
const forwardQueryParams = row.getValue("forwardQueryParams") as boolean
|
||||
return (
|
||||
<div className="flex justify-center">
|
||||
{forwardQueryParams ? <Check className="w-4 h-4 text-green-600" /> : <X className="w-4 h-4 text-red-600" />}
|
||||
</div>
|
||||
)
|
||||
}
|
||||
}
|
||||
]
|
||||
|
||||
interface UrlsDataTableProps {
|
||||
data: UrlRecord[]
|
||||
}
|
||||
|
||||
export function UrlsDataTable({ data }: UrlsDataTableProps) {
|
||||
const [sorting, setSorting] = React.useState<SortingState>([])
|
||||
const [columnFilters, setColumnFilters] = React.useState<ColumnFiltersState>([])
|
||||
const [columnVisibility, setColumnVisibility] = React.useState<VisibilityState>({})
|
||||
const router = useRouter()
|
||||
|
||||
const handleCopy = React.useCallback((string: string) => {
|
||||
navigator.clipboard.writeText(string).then(() => {
|
||||
toast.success("URL copied to clipboard")
|
||||
}).catch(() => {
|
||||
toast.error("Failed to copy URL")
|
||||
})
|
||||
}, [])
|
||||
|
||||
const handleDelete = React.useCallback(async (id: string) => {
|
||||
const res = await deleteUrl(id)
|
||||
|
||||
if (res.error) {
|
||||
toast.error("Failed to delete URL")
|
||||
} else {
|
||||
toast.success("URL deleted successfully")
|
||||
router.refresh()
|
||||
}
|
||||
}, [router])
|
||||
|
||||
const tableColumns = React.useMemo(() => {
|
||||
const actionRow: ColumnDef<UrlRecord> = {
|
||||
id: "actions",
|
||||
enableHiding: false,
|
||||
cell: ({ row }) => {
|
||||
const urlRecord = row.original
|
||||
|
||||
return (
|
||||
<DropdownMenu>
|
||||
<DropdownMenuTrigger asChild>
|
||||
<Button variant="ghost" className="p-0 w-8 h-8">
|
||||
<span className="sr-only">Open menu</span>
|
||||
<MoreHorizontal className="w-4 h-4" />
|
||||
</Button>
|
||||
</DropdownMenuTrigger>
|
||||
<DropdownMenuContent align="end">
|
||||
<DropdownMenuLabel>Actions</DropdownMenuLabel>
|
||||
<DropdownMenuItem
|
||||
onClick={() => {
|
||||
handleCopy(`${window.location.origin}/r/${urlRecord.slug}`)
|
||||
}}
|
||||
>
|
||||
<Copy className="mr-2 w-4 h-4" />
|
||||
Copy URL
|
||||
</DropdownMenuItem>
|
||||
<DropdownMenuItem asChild>
|
||||
<Link href={`/admin/dashboard/edit/${urlRecord.id}`}>
|
||||
<Copy className="mr-2 w-4 h-4" />
|
||||
Edit URL
|
||||
</Link>
|
||||
</DropdownMenuItem>
|
||||
<DropdownMenuSeparator />
|
||||
<DropdownMenuItem
|
||||
className="text-red-600"
|
||||
onClick={() => {
|
||||
handleDelete(urlRecord.id)
|
||||
}}
|
||||
>
|
||||
<Trash2 className="mr-2 w-4 h-4" />
|
||||
Delete
|
||||
</DropdownMenuItem>
|
||||
</DropdownMenuContent>
|
||||
</DropdownMenu>
|
||||
)
|
||||
}
|
||||
}
|
||||
|
||||
return [...columns, actionRow]
|
||||
}, [handleCopy, handleDelete])
|
||||
|
||||
const table = useReactTable({
|
||||
data,
|
||||
columns: tableColumns,
|
||||
onSortingChange: setSorting,
|
||||
onColumnFiltersChange: setColumnFilters,
|
||||
getCoreRowModel: getCoreRowModel(),
|
||||
getPaginationRowModel: getPaginationRowModel(),
|
||||
getSortedRowModel: getSortedRowModel(),
|
||||
getFilteredRowModel: getFilteredRowModel(),
|
||||
onColumnVisibilityChange: setColumnVisibility,
|
||||
state: {
|
||||
sorting,
|
||||
columnFilters,
|
||||
columnVisibility
|
||||
}
|
||||
})
|
||||
|
||||
return (
|
||||
<div className="w-full">
|
||||
<div className="flex items-center py-4">
|
||||
<Input
|
||||
placeholder="Filter URLs by slug..."
|
||||
value={(table.getColumn("slug")?.getFilterValue() as string) ?? ""}
|
||||
onChange={(event) => table.getColumn("slug")?.setFilterValue(event.target.value)}
|
||||
className="max-w-sm"
|
||||
/>
|
||||
<DropdownMenu>
|
||||
<DropdownMenuTrigger asChild>
|
||||
<Button variant="outline" className="ml-auto">
|
||||
Columns
|
||||
</Button>
|
||||
</DropdownMenuTrigger>
|
||||
<DropdownMenuContent align="end">
|
||||
{table
|
||||
.getAllColumns()
|
||||
.filter((column) => column.getCanHide())
|
||||
.map((column) => {
|
||||
return (
|
||||
<DropdownMenuCheckboxItem
|
||||
key={column.id}
|
||||
className="capitalize"
|
||||
checked={column.getIsVisible()}
|
||||
onCheckedChange={(value) => column.toggleVisibility(!!value)}
|
||||
>
|
||||
{column.id}
|
||||
</DropdownMenuCheckboxItem>
|
||||
)
|
||||
})}
|
||||
</DropdownMenuContent>
|
||||
</DropdownMenu>
|
||||
</div>
|
||||
<div className="rounded-md border">
|
||||
<Table>
|
||||
<TableHeader>
|
||||
{table.getHeaderGroups().map((headerGroup) => (
|
||||
<TableRow key={headerGroup.id}>
|
||||
{headerGroup.headers.map((header) => {
|
||||
return (
|
||||
<TableHead key={header.id}>
|
||||
{header.isPlaceholder
|
||||
? null
|
||||
: flexRender(
|
||||
header.column.columnDef.header,
|
||||
header.getContext()
|
||||
)}
|
||||
</TableHead>
|
||||
)
|
||||
})}
|
||||
</TableRow>
|
||||
))}
|
||||
</TableHeader>
|
||||
<TableBody>
|
||||
{table.getRowModel().rows?.length ?
|
||||
(
|
||||
table.getRowModel().rows.map((row) => (
|
||||
<TableRow
|
||||
key={row.id}
|
||||
data-state={row.getIsSelected() && "selected"}
|
||||
>
|
||||
{row.getVisibleCells().map((cell) => (
|
||||
<TableCell key={cell.id}>
|
||||
{flexRender(
|
||||
cell.column.columnDef.cell,
|
||||
cell.getContext()
|
||||
)}
|
||||
</TableCell>
|
||||
))}
|
||||
</TableRow>
|
||||
))
|
||||
) :
|
||||
(
|
||||
<TableRow>
|
||||
<TableCell
|
||||
colSpan={tableColumns.length}
|
||||
className="h-24 text-center"
|
||||
>
|
||||
No results.
|
||||
</TableCell>
|
||||
</TableRow>
|
||||
)}
|
||||
</TableBody>
|
||||
</Table>
|
||||
</div>
|
||||
<div className="flex justify-end items-center py-4 space-x-2">
|
||||
<div className="text-sm text-muted-foreground">
|
||||
{table.getFilteredRowModel().rows.length} row(s) total.
|
||||
</div>
|
||||
<div className="space-x-2">
|
||||
<Button
|
||||
variant="outline"
|
||||
size="sm"
|
||||
onClick={() => table.previousPage()}
|
||||
disabled={!table.getCanPreviousPage()}
|
||||
>
|
||||
Previous
|
||||
</Button>
|
||||
<Button
|
||||
variant="outline"
|
||||
size="sm"
|
||||
onClick={() => table.nextPage()}
|
||||
disabled={!table.getCanNextPage()}
|
||||
>
|
||||
Next
|
||||
</Button>
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
)
|
||||
}
|
||||
220
src/app/(admin)/admin/_components/user-profile.tsx
Normal file
220
src/app/(admin)/admin/_components/user-profile.tsx
Normal file
@@ -0,0 +1,220 @@
|
||||
"use client"
|
||||
|
||||
import { Avatar, AvatarFallback, AvatarImage } from "@/components/ui/avatar"
|
||||
import { Button } from "@/components/ui/button"
|
||||
import { Card, CardContent, CardHeader, CardTitle } from "@/components/ui/card"
|
||||
import { Form, FormControl, FormField, FormItem, FormLabel, FormMessage } from "@/components/ui/form"
|
||||
import { Input } from "@/components/ui/input"
|
||||
import { Label } from "@/components/ui/label"
|
||||
import { authClient } from "@/lib/auth/auth-client"
|
||||
import { zodResolver } from "@hookform/resolvers/zod"
|
||||
import { Calendar, Image, User, User2 } from "lucide-react"
|
||||
import { useRouter } from "next/navigation"
|
||||
import { useState } from "react"
|
||||
import { useForm } from "react-hook-form"
|
||||
import { toast } from "sonner"
|
||||
import { z } from "zod"
|
||||
|
||||
export default function UserProfile() {
|
||||
const { data: session, isPending } = authClient.useSession()
|
||||
|
||||
if (isPending) {
|
||||
return (
|
||||
<Card className="max-w-lg">
|
||||
<CardContent className="p-6">
|
||||
<div className="animate-pulse">
|
||||
<div className="flex items-center space-x-4">
|
||||
<div className="w-16 h-16 bg-gray-200 rounded-full"></div>
|
||||
<div className="space-y-2">
|
||||
<div className="h-4 bg-gray-200 rounded w-32"></div>
|
||||
<div className="h-3 bg-gray-200 rounded w-24"></div>
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
</CardContent>
|
||||
</Card>
|
||||
)
|
||||
}
|
||||
|
||||
if (!session?.user) {
|
||||
return (
|
||||
<Card className="max-w-lg">
|
||||
<CardContent className="p-6">
|
||||
<p className="text-center text-gray-500">Not authenticated</p>
|
||||
</CardContent>
|
||||
</Card>
|
||||
)
|
||||
}
|
||||
|
||||
const { user } = session
|
||||
const initials = user.name
|
||||
? user.name.split(" ").map(n => n[0]).join("").toUpperCase()
|
||||
: user.email?.[0]?.toUpperCase() || "U"
|
||||
|
||||
return (
|
||||
<Card className="max-w-lg">
|
||||
<CardHeader className="text-center">
|
||||
<div className="flex justify-center mb-4">
|
||||
<Avatar className="w-20 h-20">
|
||||
<AvatarImage src={user.image || undefined} alt={user.name || "User"} />
|
||||
<AvatarFallback className="text-lg">
|
||||
{initials}
|
||||
</AvatarFallback>
|
||||
</Avatar>
|
||||
</div>
|
||||
<div className="flex items-center justify-between">
|
||||
<CardTitle className="text-xl">Profile</CardTitle>
|
||||
</div>
|
||||
</CardHeader>
|
||||
<CardContent className="space-y-4">
|
||||
<div className="space-y-2">
|
||||
<Label className="text-sm font-medium flex items-center gap-2">
|
||||
<User className="w-4 h-4" />
|
||||
User ID
|
||||
</Label>
|
||||
<Input
|
||||
value={user.id}
|
||||
disabled
|
||||
className="bg-muted text-muted-foreground"
|
||||
/>
|
||||
</div>
|
||||
|
||||
<div className="space-y-2">
|
||||
<Label className="text-sm font-medium flex items-center gap-2">
|
||||
<Calendar className="w-4 h-4" />
|
||||
Joined
|
||||
</Label>
|
||||
<Input
|
||||
value={new Date(user.createdAt).toLocaleDateString()}
|
||||
disabled
|
||||
className="bg-muted text-muted-foreground"
|
||||
/>
|
||||
</div>
|
||||
<UserUpdateForm name={user.name} image={user.image ? user.image : undefined} email={user.email} />
|
||||
</CardContent>
|
||||
</Card>
|
||||
)
|
||||
}
|
||||
|
||||
const userUpdateSchema = z.object({
|
||||
name: z.string().min(1),
|
||||
image: z.string().url().optional()
|
||||
})
|
||||
|
||||
function UserUpdateForm({ name, image, email }: z.infer<typeof userUpdateSchema> & { email: string }) {
|
||||
const [editing, setEditing] = useState(false)
|
||||
const router = useRouter()
|
||||
const form = useForm<z.infer<typeof userUpdateSchema>>({
|
||||
resolver: zodResolver(userUpdateSchema),
|
||||
defaultValues: { name, image },
|
||||
disabled: !editing
|
||||
})
|
||||
|
||||
async function handleSubmit(data: z.infer<typeof userUpdateSchema>) {
|
||||
const res = await authClient.updateUser({
|
||||
name: data.name,
|
||||
image: data.image
|
||||
})
|
||||
|
||||
if (res && res.error) {
|
||||
toast.error(res.error.message)
|
||||
return
|
||||
}
|
||||
|
||||
toast.success("Profile updated successfully")
|
||||
setEditing(false)
|
||||
router.refresh()
|
||||
}
|
||||
|
||||
async function handleGravatar() {
|
||||
const res = await fetch(`/api/gravatar/${email}`)
|
||||
|
||||
if (!res.ok) {
|
||||
toast.error("Failed to fetch Gravatar")
|
||||
return
|
||||
}
|
||||
|
||||
const data = await res.json()
|
||||
|
||||
if (data.error) {
|
||||
toast.error(data.message)
|
||||
return
|
||||
}
|
||||
|
||||
console.log(data)
|
||||
|
||||
form.setValue("image", data.url)
|
||||
}
|
||||
|
||||
return (
|
||||
<>
|
||||
<Form {...form}>
|
||||
<form onSubmit={form.handleSubmit(handleSubmit)} className="space-y-4">
|
||||
<FormField
|
||||
control={form.control}
|
||||
name="name"
|
||||
render={({ field }) => (
|
||||
<FormItem>
|
||||
<FormLabel className="text-sm font-medium flex items-center gap-2">
|
||||
<User2 className="w-4 h-4" />
|
||||
Name
|
||||
</FormLabel>
|
||||
<FormControl>
|
||||
<Input {...field} />
|
||||
</FormControl>
|
||||
<FormMessage />
|
||||
</FormItem>
|
||||
)}
|
||||
/>
|
||||
<FormField
|
||||
control={form.control}
|
||||
name="image"
|
||||
render={({ field }) => (
|
||||
<FormItem>
|
||||
<FormLabel className="text-sm font-medium flex items-center gap-2">
|
||||
{/* eslint-disable-next-line jsx-a11y/alt-text */}
|
||||
<Image className="w-4 h-4" />
|
||||
Image
|
||||
</FormLabel>
|
||||
<FormControl>
|
||||
<div className="flex gap-2">
|
||||
<Input {...field} />
|
||||
<Button type="button" variant="outline" hidden={!editing} onClick={handleGravatar}>
|
||||
Gravatar
|
||||
</Button>
|
||||
</div>
|
||||
</FormControl>
|
||||
<FormMessage />
|
||||
</FormItem>
|
||||
)}
|
||||
/>
|
||||
<div className="flex justify-end">
|
||||
{editing === true ?
|
||||
(
|
||||
<div className="flex gap-2">
|
||||
<Button
|
||||
onClick={() => {
|
||||
setEditing(false)
|
||||
form.reset()
|
||||
}}
|
||||
variant="outline"
|
||||
type="button"
|
||||
>
|
||||
Cancel
|
||||
</Button>
|
||||
<Button type="submit">
|
||||
Save
|
||||
</Button>
|
||||
</div>
|
||||
) :
|
||||
(
|
||||
<Button onClick={() => setEditing(true)}>
|
||||
Edit
|
||||
</Button>
|
||||
)}
|
||||
</div>
|
||||
</form>
|
||||
</Form>
|
||||
</>
|
||||
)
|
||||
}
|
||||
19
src/app/(admin)/admin/dashboard/create/page.tsx
Normal file
19
src/app/(admin)/admin/dashboard/create/page.tsx
Normal file
@@ -0,0 +1,19 @@
|
||||
import { getSession } from "@/lib/auth/session"
|
||||
import { UrlFormCard } from "../../_components/url-form-card"
|
||||
|
||||
export default async function DashboardCreatePage() {
|
||||
const { session, redirectToSignIn } = await getSession()
|
||||
|
||||
if (!session) {
|
||||
redirectToSignIn()
|
||||
}
|
||||
|
||||
return (
|
||||
<div className="space-y-6">
|
||||
<div>
|
||||
<h1 className="mb-2 text-2xl font-bold text-foreground">Create Short Link</h1>
|
||||
</div>
|
||||
<UrlFormCard />
|
||||
</div>
|
||||
)
|
||||
}
|
||||
44
src/app/(admin)/admin/dashboard/edit/[id]/not-found.tsx
Normal file
44
src/app/(admin)/admin/dashboard/edit/[id]/not-found.tsx
Normal file
@@ -0,0 +1,44 @@
|
||||
import { Button } from "@/components/ui/button"
|
||||
import { Card, CardContent, CardDescription, CardHeader, CardTitle } from "@/components/ui/card"
|
||||
import Link from "next/link"
|
||||
|
||||
export default function NotFound() {
|
||||
return (
|
||||
<div className="container py-8 mx-auto">
|
||||
<div className="mx-auto max-w-2xl">
|
||||
<Card>
|
||||
<CardHeader>
|
||||
<CardTitle>URL Not Found</CardTitle>
|
||||
<CardDescription>
|
||||
The short link you're trying to edit could not be found.
|
||||
</CardDescription>
|
||||
</CardHeader>
|
||||
<CardContent>
|
||||
<div className="space-y-4">
|
||||
<p className="text-sm text-muted-foreground">
|
||||
This could happen if:
|
||||
</p>
|
||||
<ul className="space-y-1 text-sm list-disc list-inside text-muted-foreground">
|
||||
<li>The link has been deleted</li>
|
||||
<li>You don't have permission to edit this link</li>
|
||||
<li>The link ID is invalid</li>
|
||||
</ul>
|
||||
<div className="flex gap-2">
|
||||
<Button asChild>
|
||||
<Link href="/admin/dashboard/list">
|
||||
View All Links
|
||||
</Link>
|
||||
</Button>
|
||||
<Button asChild variant="outline">
|
||||
<Link href="/admin/dashboard/create">
|
||||
Create New Link
|
||||
</Link>
|
||||
</Button>
|
||||
</div>
|
||||
</div>
|
||||
</CardContent>
|
||||
</Card>
|
||||
</div>
|
||||
</div>
|
||||
)
|
||||
}
|
||||
33
src/app/(admin)/admin/dashboard/edit/[id]/page.tsx
Normal file
33
src/app/(admin)/admin/dashboard/edit/[id]/page.tsx
Normal file
@@ -0,0 +1,33 @@
|
||||
import { getSession } from "@/lib/auth/session"
|
||||
import { getUrlById } from "@/lib/db/urls"
|
||||
import { notFound } from "next/navigation"
|
||||
import { UrlFormCard } from "../../../_components/url-form-card"
|
||||
|
||||
export default async function EditPage({
|
||||
params
|
||||
}: {
|
||||
params: Promise<{ id: string }>
|
||||
}) {
|
||||
const { session, redirectToSignIn } = await getSession()
|
||||
|
||||
if (!session) {
|
||||
redirectToSignIn()
|
||||
}
|
||||
|
||||
const { id } = await params
|
||||
|
||||
const url = await getUrlById(id)
|
||||
|
||||
if (!url) {
|
||||
notFound()
|
||||
}
|
||||
|
||||
return (
|
||||
<div className="space-y-6">
|
||||
<div>
|
||||
<h1 className="mb-2 text-2xl font-bold text-foreground">Edit Short Link</h1>
|
||||
</div>
|
||||
<UrlFormCard editMode data={url} />
|
||||
</div>
|
||||
)
|
||||
}
|
||||
23
src/app/(admin)/admin/dashboard/list/page.tsx
Normal file
23
src/app/(admin)/admin/dashboard/list/page.tsx
Normal file
@@ -0,0 +1,23 @@
|
||||
import { getSession } from "@/lib/auth/session"
|
||||
import { getAllUrls } from "@/lib/db/urls"
|
||||
import { UrlsDataTable } from "../../_components/urls-data-table"
|
||||
|
||||
export default async function DashboardListPage() {
|
||||
const { session, redirectToSignIn } = await getSession()
|
||||
|
||||
if (!session) {
|
||||
redirectToSignIn()
|
||||
}
|
||||
|
||||
const urls = await getAllUrls()
|
||||
|
||||
return (
|
||||
<div>
|
||||
<div className="mb-6">
|
||||
<h1 className="block mb-2 text-2xl font-bold text-foreground">URLs</h1>
|
||||
<h1 className="block text-muted-foreground">Manage all your shortened URLs.</h1>
|
||||
</div>
|
||||
<UrlsDataTable data={urls} />
|
||||
</div>
|
||||
)
|
||||
}
|
||||
49
src/app/(admin)/admin/dashboard/page.tsx
Normal file
49
src/app/(admin)/admin/dashboard/page.tsx
Normal file
@@ -0,0 +1,49 @@
|
||||
import { getSession } from "@/lib/auth/session"
|
||||
import { getDashboardStats } from "@/lib/dashboard/stats"
|
||||
import { LinkIcon, MousePointerClick, TrendingUp } from "lucide-react"
|
||||
import { UrlFormCard } from "../_components/simple-url-form-card"
|
||||
import { StatsCard } from "../_components/stats-card"
|
||||
|
||||
export default async function Dashboard() {
|
||||
const { session, redirectToSignIn } = await getSession()
|
||||
|
||||
if (!session) {
|
||||
redirectToSignIn()
|
||||
}
|
||||
|
||||
const stats = await getDashboardStats()
|
||||
|
||||
const mostVisitedDisplay = stats.mostVisitedUrl
|
||||
? stats.mostVisitedUrl.visitCount > 0
|
||||
? `${stats.mostVisitedUrl.title || stats.mostVisitedUrl.slug || "Untitled"} (${stats.mostVisitedUrl.visitCount})`
|
||||
: "No visits"
|
||||
: "No URLs"
|
||||
|
||||
return (
|
||||
<div>
|
||||
<h1 className="block mb-4 text-2xl font-bold text-foreground">Dashboard</h1>
|
||||
<div className="grid grid-cols-1 gap-4 mb-6 md:grid-cols-2 lg:grid-cols-3">
|
||||
<StatsCard
|
||||
title="Shortened URLs"
|
||||
value={stats.totalUrls}
|
||||
icon={<LinkIcon className="w-4 h-4 text-muted-foreground" />}
|
||||
description="Total number of shortened links"
|
||||
/>
|
||||
<StatsCard
|
||||
title="Total Visits"
|
||||
value={stats.totalVisits}
|
||||
icon={<MousePointerClick className="w-4 h-4 text-muted-foreground" />}
|
||||
description="Combined visits across all links"
|
||||
/>
|
||||
<StatsCard
|
||||
title="Most Visited URL"
|
||||
value={mostVisitedDisplay}
|
||||
icon={<TrendingUp className="w-4 h-4 text-muted-foreground" />}
|
||||
description="Most popular shortened link"
|
||||
/>
|
||||
</div>
|
||||
|
||||
<UrlFormCard />
|
||||
</div>
|
||||
)
|
||||
}
|
||||
23
src/app/(admin)/admin/layout.tsx
Normal file
23
src/app/(admin)/admin/layout.tsx
Normal file
@@ -0,0 +1,23 @@
|
||||
import { SidebarProvider } from "@/components/ui/sidebar"
|
||||
import { ReactNode } from "react"
|
||||
import { DashboardSidebar } from "./_components/sidebar"
|
||||
import { SidebarClient } from "./_components/sidebar-client"
|
||||
|
||||
export default function DashboardLayout({
|
||||
children
|
||||
}: {
|
||||
children: ReactNode
|
||||
}) {
|
||||
return (
|
||||
<SidebarProvider>
|
||||
<SidebarClient>
|
||||
<div className="flex w-full min-h-screen">
|
||||
<DashboardSidebar />
|
||||
<main className="overflow-auto flex-1 p-6">
|
||||
{children}
|
||||
</main>
|
||||
</div>
|
||||
</SidebarClient>
|
||||
</SidebarProvider>
|
||||
)
|
||||
}
|
||||
22
src/app/(admin)/admin/user/auth/page.tsx
Normal file
22
src/app/(admin)/admin/user/auth/page.tsx
Normal file
@@ -0,0 +1,22 @@
|
||||
import { getSession } from "@/lib/auth/session"
|
||||
import DashBoardTitle from "../../_components/dashboard-title"
|
||||
import { PasskeyAdd } from "../../_components/passkey"
|
||||
import PasskeysList from "../../_components/passkeys-list"
|
||||
|
||||
export default async function UserAuthPage() {
|
||||
const { session, redirectToSignIn } = await getSession()
|
||||
|
||||
if (!session) {
|
||||
redirectToSignIn()
|
||||
}
|
||||
|
||||
return (
|
||||
<div>
|
||||
<DashBoardTitle title="User Auth" renderSubtitle subtitle="Manage user settings" />
|
||||
<div className="flex flex-col gap-6">
|
||||
<PasskeyAdd />
|
||||
<PasskeysList />
|
||||
</div>
|
||||
</div>
|
||||
)
|
||||
}
|
||||
18
src/app/(admin)/admin/user/page.tsx
Normal file
18
src/app/(admin)/admin/user/page.tsx
Normal file
@@ -0,0 +1,18 @@
|
||||
import { getSession } from "@/lib/auth/session"
|
||||
import DashBoardTitle from "../_components/dashboard-title"
|
||||
import UserProfile from "../_components/user-profile"
|
||||
|
||||
export default async function UserPage() {
|
||||
const { session, redirectToSignIn } = await getSession()
|
||||
|
||||
if (!session) {
|
||||
redirectToSignIn()
|
||||
}
|
||||
|
||||
return (
|
||||
<div>
|
||||
<DashBoardTitle title="User Profile" renderSubtitle subtitle="Manage Profile" />
|
||||
<UserProfile />
|
||||
</div>
|
||||
)
|
||||
}
|
||||
Reference in New Issue
Block a user