This commit is contained in:
2025-08-09 13:45:16 +02:00
parent 298fae916b
commit d322283064
28 changed files with 135 additions and 80 deletions

View 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>
)
}

View 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>
)
}

View 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>
)
}

View File

@@ -0,0 +1,7 @@
import { getSession } from "@/lib/auth/session"
export default async function PasswordSettings() {
const {} = await getSession()
return null
}

View 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
}

View 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>
)
}

View 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>
)
}

View 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>
)
}

View 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>
)
}

View 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>
)
}

View 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>
)
}

View 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>
)
}

View 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>
</>
)
}

View 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>
)
}

View 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&apos;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&apos;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>
)
}

View 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>
)
}

View 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>
)
}

View 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>
)
}

View 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>
)
}

View 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>
)
}

View 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>
)
}