Skip to content
Merged
Show file tree
Hide file tree
Changes from all commits
Commits
File filter

Filter by extension

Filter by extension

Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
54 changes: 49 additions & 5 deletions apps/dashboard/src/App.tsx
Original file line number Diff line number Diff line change
@@ -1,11 +1,55 @@
import { Routes, Route } from "react-router-dom"
import { Routes, Route, Navigate } from "react-router-dom"
import { ThemeProvider } from "@/components/theme-provider"
import ErrorBoundary from "@/components/error-boundary"
import ProtectedRoute from "@/components/protected-route"
import AppLayout from "@/components/app-layout"
import AuthCallback from "@/pages/AuthCallback"
import OverviewPage from "@/pages/Overview"
import UrlsListPage from "@/pages/UrlsListPage"
import CreateUrlPage from "@/pages/CreateUrlPage"
import BulkCreatePage from "@/pages/BulkCreatePage"
import UrlDetailPage from "@/pages/UrlDetailPage"
import UrlSettingsPage from "@/pages/UrlSettingsPage"
import CollectionsListPage from "@/pages/CollectionsListPage"
import CollectionDetailPage from "@/pages/CollectionDetailPage"
import TagsListPage from "@/pages/TagsListPage"
import TagDetailPage from "@/pages/TagDetailPage"
import ApiKeysPage from "@/pages/ApiKeysPage"
import BillingPage from "@/pages/BillingPage"
import PlansPage from "@/pages/PlansPage"
import NotFoundPage from "@/pages/NotFound"

export default function App() {
return (
<Routes>
<Route path="/auth/callback" element={<AuthCallback />} />
<Route path="*" element={<h1>Dashboard</h1>} />
</Routes>
<ThemeProvider>
<ErrorBoundary>
<Routes>
<Route path="/auth/callback" element={<AuthCallback />} />
<Route
element={
<ProtectedRoute>
<AppLayout />
</ProtectedRoute>
}
>
<Route path="/" element={<Navigate to="/overview" replace />} />
<Route path="/overview" element={<OverviewPage />} />
<Route path="/urls" element={<UrlsListPage />} />
<Route path="/urls/new" element={<CreateUrlPage />} />
<Route path="/urls/bulk" element={<BulkCreatePage />} />
<Route path="/urls/:code" element={<UrlDetailPage />} />
<Route path="/urls/:code/settings" element={<UrlSettingsPage />} />
<Route path="/collections" element={<CollectionsListPage />} />
<Route path="/collections/:id" element={<CollectionDetailPage />} />
<Route path="/tags" element={<TagsListPage />} />
<Route path="/tags/:id" element={<TagDetailPage />} />
<Route path="/api-keys" element={<ApiKeysPage />} />
<Route path="/billing" element={<BillingPage />} />
<Route path="/billing/plans" element={<PlansPage />} />
<Route path="*" element={<NotFoundPage />} />
</Route>
</Routes>
</ErrorBoundary>
</ThemeProvider>
)
}
19 changes: 19 additions & 0 deletions apps/dashboard/src/components/app-layout.tsx
Original file line number Diff line number Diff line change
@@ -0,0 +1,19 @@
import { Outlet } from "react-router-dom"
import Sidebar from "@/components/sidebar"
import Topbar from "@/components/topbar"
import { Toaster } from "sonner"

export default function AppLayout() {
return (
<div className="flex min-h-screen">
<Sidebar />
<div className="flex flex-1 flex-col md:pl-56">
<Topbar />
<main className="flex-1 p-4 md:p-6">
<Outlet />
</main>
</div>
<Toaster richColors position="top-right" />
</div>
)
}
57 changes: 57 additions & 0 deletions apps/dashboard/src/components/error-boundary.tsx
Original file line number Diff line number Diff line change
@@ -0,0 +1,57 @@
import { Component, type ReactNode, type ErrorInfo } from "react"
import { Button } from "@/components/ui/button"
import { AlertTriangle } from "lucide-react"

interface Props {
children: ReactNode
fallback?: ReactNode
}

interface State {
hasError: boolean
error: Error | null
}

export default class ErrorBoundary extends Component<Props, State> {
constructor(props: Props) {
super(props)
this.state = { hasError: false, error: null }
}

static getDerivedStateFromError(error: Error): State {
return { hasError: true, error }
}

componentDidCatch(error: Error, errorInfo: ErrorInfo) {
console.error("ErrorBoundary caught:", error, errorInfo)
}

render() {
if (this.state.hasError) {
if (this.props.fallback) return this.props.fallback

return (
<div className="flex min-h-[400px] flex-col items-center justify-center gap-4 px-6">
<AlertTriangle className="h-10 w-10 text-destructive" />
<div className="text-center">
<h2 className="text-lg font-semibold text-foreground">Something went wrong</h2>
<p className="mt-1 text-sm text-muted-foreground">
{this.state.error?.message ?? "An unexpected error occurred"}
</p>
</div>
<Button
variant="outline"
onClick={() => {
this.setState({ hasError: false, error: null })
window.location.reload()
}}
>
Reload page
</Button>
</div>
)
}

return this.props.children
}
}
86 changes: 86 additions & 0 deletions apps/dashboard/src/components/overview/plan-status-card.tsx
Original file line number Diff line number Diff line change
@@ -0,0 +1,86 @@
import { Link } from "react-router-dom"
import { Card, CardContent, CardHeader, CardTitle } from "@/components/ui/card"
import { Badge } from "@/components/ui/badge"
import { Button } from "@/components/ui/button"
import { Skeleton } from "@/components/ui/skeleton"
import { formatDistanceToNow, format } from "date-fns"
import { CreditCard, Sparkles } from "lucide-react"
import type { UserSubscription } from "@linkify/shared"

interface PlanStatusCardProps {
subscription: UserSubscription | null
}

export default function PlanStatusCard({ subscription }: PlanStatusCardProps) {
if (!subscription) {
return (
<Card>
<CardHeader>
<CardTitle>Plan</CardTitle>
</CardHeader>
<CardContent>
<div className="flex flex-col items-center gap-3 py-4 text-center">
<Sparkles className="h-8 w-8 text-muted-foreground/50" />
<p className="text-sm text-muted-foreground">
No active subscription
</p>
<Link to="/billing/plans">
<Button size="sm">Choose a plan</Button>
</Link>
</div>
</CardContent>
</Card>
)
}

const { plan } = subscription
const cancelScheduled = plan.cancelAtPeriodEnd
const statusColors: Record<string, "default" | "success" | "outline" | "destructive"> = {
active: "success",
trialing: "default",
past_due: "destructive",
canceled: "outline",
unpaid: "destructive",
}

return (
<Card>
<CardHeader className="flex flex-row items-center justify-between">
<CardTitle>Plan</CardTitle>
<CreditCard className="h-4 w-4 text-muted-foreground" />
</CardHeader>
<CardContent className="space-y-3">
<div className="flex items-center justify-between">
<span className="text-sm font-medium text-foreground">
{plan.planName}
</span>
<Badge variant={statusColors[plan.status] ?? "outline"}>
{cancelScheduled ? "Cancelling" : plan.status}
</Badge>
</div>

{plan.currentPeriodEnd && (
<div className="space-y-1">
<p className="text-xs text-muted-foreground">
{cancelScheduled
? "Ends"
: "Renews"}{" "}
{formatDistanceToNow(new Date(plan.currentPeriodEnd), { addSuffix: true })}
</p>
<p className="text-xs text-muted-foreground">
{format(new Date(plan.currentPeriodEnd), "MMM d, yyyy")}
</p>
</div>
)}

<div className="pt-1">
<Link to="/billing">
<Button variant="outline" size="sm" className="w-full">
Manage
</Button>
</Link>
</div>
</CardContent>
</Card>
)
}
107 changes: 107 additions & 0 deletions apps/dashboard/src/components/overview/quick-create-form.tsx
Original file line number Diff line number Diff line change
@@ -0,0 +1,107 @@
import { useState } from "react"
import { Card, CardContent } from "@/components/ui/card"
import { Input } from "@/components/ui/input"
import { Button } from "@/components/ui/button"
import { Badge } from "@/components/ui/badge"
import { Copy, Check, Link2, Loader2 } from "lucide-react"
import { toast } from "sonner"
import type { ShortUrl } from "@linkify/shared"

interface QuickCreateFormProps {
onShorten: (url: string) => Promise<ShortUrl>
}

export default function QuickCreateForm({ onShorten }: QuickCreateFormProps) {
const [url, setUrl] = useState("")
const [loading, setLoading] = useState(false)
const [result, setResult] = useState<ShortUrl | null>(null)
const [copied, setCopied] = useState(false)

const handleSubmit = async (e: React.FormEvent) => {
e.preventDefault()
if (!url.trim()) return

let normalized = url.trim()
if (!/^https?:\/\//i.test(normalized)) {
normalized = `https://${normalized}`
}

setLoading(true)
setResult(null)

try {
const shortUrl = await onShorten(normalized)
setResult(shortUrl)
setUrl("")
toast.success("Link created!")
} catch (err: unknown) {
const message = err instanceof Error ? err.message : "Failed to create link"
toast.error(message)
} finally {
setLoading(false)
}
}

const handleCopy = async () => {
if (!result) return
try {
await navigator.clipboard.writeText(result.shortUrl)
setCopied(true)
toast.success("Copied to clipboard")
setTimeout(() => setCopied(false), 2000)
} catch {
toast.error("Failed to copy")
}
}

return (
<Card>
<CardContent className="p-5">
<form onSubmit={handleSubmit} className="flex gap-2">
<div className="relative flex-1">
<Link2 className="absolute left-3 top-1/2 h-4 w-4 -translate-y-1/2 text-muted-foreground" />
<Input
type="url"
placeholder="Paste a long URL to shorten..."
value={url}
onChange={(e) => setUrl(e.target.value)}
className="pl-9"
disabled={loading}
/>
</div>
<Button type="submit" disabled={loading || !url.trim()}>
{loading ? (
<Loader2 className="h-4 w-4 animate-spin" />
) : (
"Shorten"
)}
</Button>
</form>

{result && (
<div className="mt-3 flex items-center gap-2 rounded-lg border border-border bg-muted/50 px-3 py-2 animate-in slide-in-from-top-1 fade-in duration-200">
<div className="flex-1 truncate">
<span className="text-sm font-medium text-primary">{result.shortUrl}</span>
</div>
<Button
type="button"
variant="ghost"
size="icon"
className="h-7 w-7 shrink-0"
onClick={handleCopy}
>
{copied ? (
<Check className="h-3.5 w-3.5 text-emerald-500" />
) : (
<Copy className="h-3.5 w-3.5" />
)}
</Button>
<Badge variant="success" className="shrink-0 text-[10px]">
created
</Badge>
</div>
)}
</CardContent>
</Card>
)
}
Loading
Loading