Moved things and added title extraction
This commit is contained in:
@@ -1,53 +0,0 @@
|
||||
"use client"
|
||||
|
||||
import { AuthentikIcon } from "@/components/svgs/authentik"
|
||||
import { Button } from "@/components/ui/button"
|
||||
import { signIn } from "@/lib/auth/auth-client"
|
||||
import { Loader2 } from "lucide-react"
|
||||
import { useState } from "react"
|
||||
|
||||
interface OAuthSignInButtonProps {
|
||||
className?: string
|
||||
}
|
||||
|
||||
export function OAuthSignInButton({
|
||||
className
|
||||
}: OAuthSignInButtonProps) {
|
||||
const [isLoading, setIsLoading] = useState(false)
|
||||
|
||||
const handleSignIn = async () => {
|
||||
try {
|
||||
setIsLoading(true)
|
||||
await signIn.oauth2({
|
||||
providerId: "authentik",
|
||||
callbackURL: "/dashboard"
|
||||
})
|
||||
} catch (error) {
|
||||
console.error("Sign in failed:", error)
|
||||
setIsLoading(false)
|
||||
}
|
||||
}
|
||||
|
||||
return (
|
||||
<Button
|
||||
onClick={handleSignIn}
|
||||
disabled={isLoading}
|
||||
className={className}
|
||||
size="lg"
|
||||
>
|
||||
{isLoading ?
|
||||
(
|
||||
<>
|
||||
<Loader2 className="mr-2 h-4 w-4 animate-spin" />
|
||||
Signing in...
|
||||
</>
|
||||
) :
|
||||
(
|
||||
<>
|
||||
<AuthentikIcon className="mr-2 h-5 w-5" />
|
||||
Sign in with Authentik
|
||||
</>
|
||||
)}
|
||||
</Button>
|
||||
)
|
||||
}
|
||||
@@ -1,217 +0,0 @@
|
||||
"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 } from "@/lib/actions/url"
|
||||
import { advancedUrlSchema } 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 AdvancedUrlFormValues = z.infer<typeof advancedUrlSchema>
|
||||
|
||||
export function AdvancedUrlFormCard() {
|
||||
const form = useForm<AdvancedUrlFormValues>({
|
||||
resolver: zodResolver(advancedUrlSchema),
|
||||
defaultValues: {
|
||||
url: "",
|
||||
slug: "",
|
||||
title: "",
|
||||
maxVisits: 0,
|
||||
expDate: undefined,
|
||||
forwardQueryParams: true,
|
||||
crawlable: false
|
||||
}
|
||||
})
|
||||
|
||||
async function handleSubmit(data: AdvancedUrlFormValues) {
|
||||
const res = await createAdvanceUrl(data)
|
||||
|
||||
if (res.error) {
|
||||
toast.error(res.message)
|
||||
} else {
|
||||
toast.success(res.message)
|
||||
form.reset()
|
||||
}
|
||||
}
|
||||
|
||||
return (
|
||||
<Card>
|
||||
<CardHeader>
|
||||
<CardTitle>Create Advanced Short Link</CardTitle>
|
||||
<CardDescription>
|
||||
Create a short link with advanced configuration options.
|
||||
</CardDescription>
|
||||
</CardHeader>
|
||||
<CardContent>
|
||||
<Form {...form}>
|
||||
<form
|
||||
onSubmit={form.handleSubmit(handleSubmit)}
|
||||
className="space-y-4"
|
||||
>
|
||||
<div className="grid grid-cols-1 md:grid-cols-2 gap-4">
|
||||
<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 (Optional)</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 items-center justify-between rounded-lg border p-4">
|
||||
<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 items-center justify-between rounded-lg border p-4">
|
||||
<div className="space-y-0.5">
|
||||
<FormLabel className="text-base">
|
||||
Crawlable (Optional)
|
||||
</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 (Optional)</FormLabel>
|
||||
<FormControl>
|
||||
<Input
|
||||
placeholder="My Link Title"
|
||||
{...field}
|
||||
/>
|
||||
</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 (Optional)</FormLabel>
|
||||
<FormControl>
|
||||
<Input
|
||||
type="number"
|
||||
placeholder="100"
|
||||
{...field}
|
||||
onChange={e => field.onChange(e.target.value ? parseInt(e.target.value) : undefined)}
|
||||
/>
|
||||
</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 (Optional)</FormLabel>
|
||||
<FormControl>
|
||||
<DatePicker
|
||||
value={field.value}
|
||||
onChange={field.onChange}
|
||||
placeholder="Select expiration date"
|
||||
/>
|
||||
</FormControl>
|
||||
<FormDescription>
|
||||
When this link should expire and become inaccessible.
|
||||
</FormDescription>
|
||||
<FormMessage />
|
||||
</FormItem>
|
||||
)}
|
||||
/>
|
||||
</div>
|
||||
</div>
|
||||
<Button
|
||||
type="submit"
|
||||
className="w-full"
|
||||
disabled={form.formState.isSubmitting}
|
||||
>
|
||||
{form.formState.isSubmitting ? "Creating..." : "Create Short Link"}
|
||||
</Button>
|
||||
</form>
|
||||
</Form>
|
||||
</CardContent>
|
||||
</Card>
|
||||
)
|
||||
}
|
||||
@@ -1,85 +0,0 @@
|
||||
"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="h-4 w-4" />
|
||||
<span>Theme</span>
|
||||
</SidebarMenuButton>
|
||||
</SidebarMenuItem>
|
||||
</SidebarMenu>
|
||||
)
|
||||
}
|
||||
|
||||
const getIcon = () => {
|
||||
switch (theme) {
|
||||
case "light":
|
||||
return <Sun className="h-4 w-4" />
|
||||
case "dark":
|
||||
return <Moon className="h-4 w-4" />
|
||||
default:
|
||||
return <Monitor className="h-4 w-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 h-4 w-4" />
|
||||
</SidebarMenuButton>
|
||||
</DropdownMenuTrigger>
|
||||
<DropdownMenuContent align="end" side="top">
|
||||
<DropdownMenuItem onClick={() => setTheme("light")}>
|
||||
<Sun className="h-4 w-4 mr-2" />
|
||||
Light
|
||||
</DropdownMenuItem>
|
||||
<DropdownMenuItem onClick={() => setTheme("dark")}>
|
||||
<Moon className="h-4 w-4 mr-2" />
|
||||
Dark
|
||||
</DropdownMenuItem>
|
||||
<DropdownMenuItem onClick={() => setTheme("system")}>
|
||||
<Monitor className="h-4 w-4 mr-2" />
|
||||
System
|
||||
</DropdownMenuItem>
|
||||
</DropdownMenuContent>
|
||||
</DropdownMenu>
|
||||
</SidebarMenuItem>
|
||||
</SidebarMenu>
|
||||
)
|
||||
}
|
||||
@@ -1,132 +0,0 @@
|
||||
"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 { signOut, useSession } from "@/lib/auth/auth-client"
|
||||
import { LogOut, Settings, User } from "lucide-react"
|
||||
import Link from "next/link"
|
||||
import { useRouter } from "next/navigation"
|
||||
|
||||
export function SidebarUserDropdown() {
|
||||
const { data: session, isPending } = useSession()
|
||||
const { state } = useSidebar()
|
||||
const router = useRouter()
|
||||
|
||||
const handleSignOut = async () => {
|
||||
try {
|
||||
await signOut({
|
||||
fetchOptions: {
|
||||
onSuccess: () => {
|
||||
router.push("/")
|
||||
}
|
||||
}
|
||||
})
|
||||
} catch (err) {
|
||||
console.error("Failed to sign out:", err)
|
||||
}
|
||||
}
|
||||
|
||||
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="h-8 w-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-left text-sm leading-tight">
|
||||
<span className="truncate font-semibold">
|
||||
{user.name || "User"}
|
||||
</span>
|
||||
<span className="truncate text-xs text-muted-foreground">
|
||||
{user.email}
|
||||
</span>
|
||||
</div>
|
||||
</SidebarMenuButton>
|
||||
</DropdownMenuTrigger>
|
||||
<DropdownMenuContent
|
||||
className="w-[--radix-dropdown-menu-trigger-width] min-w-56 rounded-lg"
|
||||
side={state === "collapsed" ? "right" : "bottom"}
|
||||
align="end"
|
||||
sideOffset={4}
|
||||
>
|
||||
<DropdownMenuLabel className="p-0 font-normal">
|
||||
<div className="flex items-center gap-2 px-1 py-1.5 text-left text-sm">
|
||||
<Avatar className="h-8 w-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-left text-sm leading-tight">
|
||||
<span className="truncate font-semibold">
|
||||
{user.name || "User"}
|
||||
</span>
|
||||
<span className="truncate text-xs text-muted-foreground">
|
||||
{user.email}
|
||||
</span>
|
||||
</div>
|
||||
</div>
|
||||
</DropdownMenuLabel>
|
||||
<DropdownMenuSeparator />
|
||||
<DropdownMenuItem asChild>
|
||||
<Link href="/dashboard">
|
||||
<Settings className="mr-2 h-4 w-4" />
|
||||
<span>Settings</span>
|
||||
</Link>
|
||||
</DropdownMenuItem>
|
||||
<DropdownMenuSeparator />
|
||||
<DropdownMenuItem onClick={handleSignOut}>
|
||||
<LogOut className="mr-2 h-4 w-4" />
|
||||
<span>Log out</span>
|
||||
</DropdownMenuItem>
|
||||
</DropdownMenuContent>
|
||||
</DropdownMenu>
|
||||
</SidebarMenuItem>
|
||||
</SidebarMenu>
|
||||
)
|
||||
}
|
||||
@@ -1,91 +0,0 @@
|
||||
"use client"
|
||||
|
||||
import { LayoutDashboard, List, PanelLeft, Plus } 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 items = [
|
||||
{
|
||||
title: "Dashboard",
|
||||
url: "/dashboard",
|
||||
icon: LayoutDashboard
|
||||
},
|
||||
{
|
||||
title: "List",
|
||||
url: "/dashboard/list",
|
||||
icon: List
|
||||
},
|
||||
{
|
||||
title: "Create",
|
||||
url: "/dashboard/create",
|
||||
icon: Plus
|
||||
}
|
||||
]
|
||||
|
||||
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 className="group-data-[collapsible=icon]:hidden">
|
||||
Navigation
|
||||
</SidebarGroupLabel>
|
||||
<SidebarGroupContent>
|
||||
<SidebarMenu>
|
||||
{items.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>
|
||||
)
|
||||
}
|
||||
@@ -1,25 +0,0 @@
|
||||
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 items-center justify-between space-y-0 pb-2">
|
||||
<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>
|
||||
)
|
||||
}
|
||||
@@ -1,100 +0,0 @@
|
||||
"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>
|
||||
)
|
||||
}
|
||||
@@ -1,432 +0,0 @@
|
||||
"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)[]
|
||||
}
|
||||
|
||||
export const columns: ColumnDef<UrlRecord>[] = [
|
||||
{
|
||||
accessorKey: "slug",
|
||||
header: ({ column }) => {
|
||||
return (
|
||||
<Button
|
||||
variant="ghost"
|
||||
onClick={() => column.toggleSorting(column.getIsSorted() === "asc")}
|
||||
>
|
||||
Slug
|
||||
<ArrowUpDown className="ml-2 h-4 w-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 h-4 w-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 h-4 w-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 h-4 w-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 h-4 w-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 h-4 w-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 h-4 w-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 h-4 w-4" />
|
||||
</Button>
|
||||
)
|
||||
},
|
||||
cell: ({ row }) => {
|
||||
const crawlable = row.getValue("crawlable") as boolean
|
||||
return (
|
||||
<div className="flex justify-center">
|
||||
{crawlable ? <Check className="h-4 w-4 text-green-600" /> : <X className="h-4 w-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 h-4 w-4" />
|
||||
</Button>
|
||||
)
|
||||
},
|
||||
cell: ({ row }) => {
|
||||
const forwardQueryParams = row.getValue("forwardQueryParams") as boolean
|
||||
return (
|
||||
<div className="flex justify-center">
|
||||
{forwardQueryParams ? <Check className="h-4 w-4 text-green-600" /> : <X className="h-4 w-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="h-8 w-8 p-0">
|
||||
<span className="sr-only">Open menu</span>
|
||||
<MoreHorizontal className="h-4 w-4" />
|
||||
</Button>
|
||||
</DropdownMenuTrigger>
|
||||
<DropdownMenuContent align="end">
|
||||
<DropdownMenuLabel>Actions</DropdownMenuLabel>
|
||||
<DropdownMenuItem
|
||||
onClick={() => {
|
||||
handleCopy(`${window.location.origin}/r/${urlRecord.slug}`)
|
||||
}}
|
||||
>
|
||||
<Copy className="mr-2 h-4 w-4" />
|
||||
Copy URL
|
||||
</DropdownMenuItem>
|
||||
<DropdownMenuSeparator />
|
||||
<DropdownMenuItem
|
||||
className="text-red-600"
|
||||
onClick={() => {
|
||||
handleDelete(urlRecord.id)
|
||||
}}
|
||||
>
|
||||
<Trash2 className="mr-2 h-4 w-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 items-center justify-end space-x-2 py-4">
|
||||
<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>
|
||||
)
|
||||
}
|
||||
@@ -12,7 +12,6 @@ import { cn } from "@/lib/utils"
|
||||
interface DatePickerProps {
|
||||
value?: Date
|
||||
onChange?: (date: Date | undefined) => void
|
||||
placeholder?: string
|
||||
disabled?: boolean
|
||||
className?: string
|
||||
}
|
||||
@@ -20,7 +19,6 @@ interface DatePickerProps {
|
||||
export function DatePicker({
|
||||
value,
|
||||
onChange,
|
||||
placeholder = "Pick a date",
|
||||
disabled = false,
|
||||
className
|
||||
}: DatePickerProps) {
|
||||
@@ -37,7 +35,7 @@ export function DatePicker({
|
||||
disabled={disabled}
|
||||
>
|
||||
<CalendarIcon className="mr-2 h-4 w-4" />
|
||||
{value ? format(value, "PPP") : <span>{placeholder}</span>}
|
||||
{value ? format(value, "PPP") : <span>Pick a date</span>}
|
||||
</Button>
|
||||
</PopoverTrigger>
|
||||
<PopoverContent className="w-auto p-0" align="start">
|
||||
@@ -45,7 +43,6 @@ export function DatePicker({
|
||||
mode="single"
|
||||
selected={value}
|
||||
onSelect={onChange}
|
||||
initialFocus
|
||||
/>
|
||||
</PopoverContent>
|
||||
</Popover>
|
||||
|
||||
Reference in New Issue
Block a user