setOpen(false)}
+ />
+
+ {sidebarContent}
+
+ >
+ )}
+ >
+ )
+ }
+
+ return (
+
+ )
+}
diff --git a/apps/dashboard/src/components/theme-provider.tsx b/apps/dashboard/src/components/theme-provider.tsx
new file mode 100644
index 0000000..0baef90
--- /dev/null
+++ b/apps/dashboard/src/components/theme-provider.tsx
@@ -0,0 +1,15 @@
+import { ThemeProvider as NextThemesProvider } from "next-themes"
+import type { ReactNode } from "react"
+
+export function ThemeProvider({ children }: { children: ReactNode }) {
+ return (
+
+ {children}
+
+ )
+}
diff --git a/apps/dashboard/src/components/theme-toggle.tsx b/apps/dashboard/src/components/theme-toggle.tsx
new file mode 100644
index 0000000..a04feff
--- /dev/null
+++ b/apps/dashboard/src/components/theme-toggle.tsx
@@ -0,0 +1,29 @@
+import { Moon, Sun } from "lucide-react"
+import { useTheme } from "next-themes"
+import { Button } from "@/components/ui/button"
+import { useEffect, useState } from "react"
+
+export default function ThemeToggle() {
+ const { theme, setTheme } = useTheme()
+ const [mounted, setMounted] = useState(false)
+
+ useEffect(() => {
+ setMounted(true)
+ }, [])
+
+ if (!mounted) {
+ return
+ }
+
+ return (
+
setTheme(theme === "dark" ? "light" : "dark")}
+ >
+
+
+ Toggle theme
+
+ )
+}
diff --git a/apps/dashboard/src/components/topbar.tsx b/apps/dashboard/src/components/topbar.tsx
new file mode 100644
index 0000000..3739162
--- /dev/null
+++ b/apps/dashboard/src/components/topbar.tsx
@@ -0,0 +1,64 @@
+import { useAuth } from "@/hooks/use-auth"
+import { Avatar, AvatarFallback } from "@/components/ui/avatar"
+import { Badge } from "@/components/ui/badge"
+import ThemeToggle from "@/components/theme-toggle"
+import Breadcrumb from "@/components/ui/breadcrumb"
+import { useBreadcrumbs } from "@/hooks/use-breadcrumbs"
+import {
+ DropdownMenu,
+ DropdownMenuContent,
+ DropdownMenuItem,
+ DropdownMenuSeparator,
+ DropdownMenuTrigger,
+} from "@/components/ui/dropdown-menu"
+import { LogOut } from "lucide-react"
+
+export default function Topbar() {
+ const { profile, signOut } = useAuth()
+ const breadcrumbs = useBreadcrumbs()
+
+ const initials = profile?.email
+ ? profile.email.charAt(0).toUpperCase()
+ : "U"
+
+ return (
+
+ )
+}
diff --git a/apps/dashboard/src/components/ui/avatar.tsx b/apps/dashboard/src/components/ui/avatar.tsx
new file mode 100644
index 0000000..399f58b
--- /dev/null
+++ b/apps/dashboard/src/components/ui/avatar.tsx
@@ -0,0 +1,45 @@
+import { cn } from "@/lib/utils"
+import { forwardRef, type HTMLAttributes } from "react"
+
+const Avatar = forwardRef
>(
+ ({ className, ...props }, ref) => (
+
+ )
+)
+Avatar.displayName = "Avatar"
+
+const AvatarImage = forwardRef<
+ HTMLImageElement,
+ React.ImgHTMLAttributes
+>(({ className, ...props }, ref) => (
+
+))
+AvatarImage.displayName = "AvatarImage"
+
+const AvatarFallback = forwardRef<
+ HTMLDivElement,
+ HTMLAttributes
+>(({ className, ...props }, ref) => (
+
+))
+AvatarFallback.displayName = "AvatarFallback"
+
+export { Avatar, AvatarImage, AvatarFallback }
diff --git a/apps/dashboard/src/components/ui/badge.tsx b/apps/dashboard/src/components/ui/badge.tsx
new file mode 100644
index 0000000..ca1cb9f
--- /dev/null
+++ b/apps/dashboard/src/components/ui/badge.tsx
@@ -0,0 +1,30 @@
+import { cn } from "@/lib/utils"
+import { type VariantProps, cva } from "class-variance-authority"
+
+const badgeVariants = cva(
+ "inline-flex items-center rounded-md px-2 py-0.5 text-xs font-medium transition-colors",
+ {
+ variants: {
+ variant: {
+ default: "bg-primary/10 text-primary",
+ secondary: "bg-muted text-muted-foreground",
+ outline: "border border-border text-foreground",
+ destructive: "bg-destructive/10 text-destructive",
+ success: "bg-emerald-500/10 text-emerald-600 dark:text-emerald-400",
+ },
+ },
+ defaultVariants: {
+ variant: "default",
+ },
+ }
+)
+
+export interface BadgeProps
+ extends React.HTMLAttributes,
+ VariantProps {}
+
+function Badge({ className, variant, ...props }: BadgeProps) {
+ return
+}
+
+export { Badge, badgeVariants }
diff --git a/apps/dashboard/src/components/ui/breadcrumb.tsx b/apps/dashboard/src/components/ui/breadcrumb.tsx
new file mode 100644
index 0000000..d6f3adf
--- /dev/null
+++ b/apps/dashboard/src/components/ui/breadcrumb.tsx
@@ -0,0 +1,40 @@
+import { cn } from "@/lib/utils"
+import { ChevronRight } from "lucide-react"
+import { Link } from "react-router-dom"
+import type { ReactNode } from "react"
+
+interface BreadcrumbItem {
+ label: string
+ href?: string
+}
+
+export default function Breadcrumb({ items }: { items: BreadcrumbItem[] }) {
+ return (
+
+
+ {items.map((item, i) => {
+ const isLast = i === items.length - 1
+ return (
+
+ {i > 0 && (
+
+ )}
+ {isLast ? (
+ {item.label}
+ ) : item.href ? (
+
+ {item.label}
+
+ ) : (
+ {item.label}
+ )}
+
+ )
+ })}
+
+
+ )
+}
diff --git a/apps/dashboard/src/components/ui/button.tsx b/apps/dashboard/src/components/ui/button.tsx
new file mode 100644
index 0000000..8d01961
--- /dev/null
+++ b/apps/dashboard/src/components/ui/button.tsx
@@ -0,0 +1,46 @@
+import { cn } from "@/lib/utils"
+import { type VariantProps, cva } from "class-variance-authority"
+import { forwardRef, type ButtonHTMLAttributes } from "react"
+
+const buttonVariants = cva(
+ "inline-flex items-center justify-center whitespace-nowrap rounded-lg text-sm font-medium transition-colors focus-visible:outline-none focus-visible:ring-2 focus-visible:ring-primary disabled:pointer-events-none disabled:opacity-50 select-none",
+ {
+ variants: {
+ variant: {
+ primary: "bg-primary text-primary-foreground hover:bg-accent active:translate-y-[1px]",
+ outline: "border border-border bg-background text-foreground hover:bg-muted active:translate-y-[1px]",
+ ghost: "text-foreground hover:bg-muted active:translate-y-[1px]",
+ destructive: "bg-destructive text-destructive-foreground hover:opacity-90 active:translate-y-[1px]",
+ },
+ size: {
+ default: "h-9 px-4 py-2",
+ sm: "h-8 px-3 text-xs",
+ lg: "h-10 px-6",
+ icon: "h-9 w-9",
+ },
+ },
+ defaultVariants: {
+ variant: "primary",
+ size: "default",
+ },
+ }
+)
+
+export interface ButtonProps
+ extends ButtonHTMLAttributes,
+ VariantProps {}
+
+const Button = forwardRef(
+ ({ className, variant, size, ...props }, ref) => {
+ return (
+
+ )
+ }
+)
+Button.displayName = "Button"
+
+export { Button, buttonVariants }
diff --git a/apps/dashboard/src/components/ui/card.tsx b/apps/dashboard/src/components/ui/card.tsx
new file mode 100644
index 0000000..4b928c6
--- /dev/null
+++ b/apps/dashboard/src/components/ui/card.tsx
@@ -0,0 +1,69 @@
+import { cn } from "@/lib/utils"
+import { forwardRef, type HTMLAttributes } from "react"
+
+const Card = forwardRef>(
+ ({ className, ...props }, ref) => (
+
+ )
+)
+Card.displayName = "Card"
+
+const CardHeader = forwardRef>(
+ ({ className, ...props }, ref) => (
+
+ )
+)
+CardHeader.displayName = "CardHeader"
+
+const CardTitle = forwardRef>(
+ ({ className, ...props }, ref) => (
+
+ )
+)
+CardTitle.displayName = "CardTitle"
+
+const CardDescription = forwardRef>(
+ ({ className, ...props }, ref) => (
+
+ )
+)
+CardDescription.displayName = "CardDescription"
+
+const CardContent = forwardRef>(
+ ({ className, ...props }, ref) => (
+
+ )
+)
+CardContent.displayName = "CardContent"
+
+const CardFooter = forwardRef>(
+ ({ className, ...props }, ref) => (
+
+ )
+)
+CardFooter.displayName = "CardFooter"
+
+export { Card, CardHeader, CardFooter, CardTitle, CardDescription, CardContent }
diff --git a/apps/dashboard/src/components/ui/dropdown-menu.tsx b/apps/dashboard/src/components/ui/dropdown-menu.tsx
new file mode 100644
index 0000000..ae7c855
--- /dev/null
+++ b/apps/dashboard/src/components/ui/dropdown-menu.tsx
@@ -0,0 +1,115 @@
+import { cn } from "@/lib/utils"
+import {
+ createContext,
+ useContext,
+ useState,
+ useRef,
+ useEffect,
+ type ReactNode,
+} from "react"
+
+interface DropdownContextValue {
+ open: boolean
+ setOpen: (open: boolean) => void
+}
+
+const DropdownContext = createContext(undefined)
+
+function useDropdown() {
+ const ctx = useContext(DropdownContext)
+ if (!ctx) throw new Error("Dropdown components must be used within DropdownMenu")
+ return ctx
+}
+
+export function DropdownMenu({ children }: { children: ReactNode }) {
+ const [open, setOpen] = useState(false)
+ return (
+
+ {children}
+
+ )
+}
+
+export function DropdownMenuTrigger({ children, asChild }: { children: ReactNode; asChild?: boolean }) {
+ const { open, setOpen } = useDropdown()
+ return (
+ setOpen(!open)}
+ className={asChild ? "" : "inline-flex items-center"}
+ >
+ {children}
+
+ )
+}
+
+export function DropdownMenuContent({
+ children,
+ className,
+ align = "end",
+}: {
+ children: ReactNode
+ className?: string
+ align?: "start" | "end"
+}) {
+ const { open, setOpen } = useDropdown()
+ const ref = useRef(null)
+
+ useEffect(() => {
+ function handleClickOutside(event: MouseEvent) {
+ if (ref.current && !ref.current.contains(event.target as Node)) {
+ setOpen(false)
+ }
+ }
+ if (open) {
+ document.addEventListener("mousedown", handleClickOutside)
+ return () => document.removeEventListener("mousedown", handleClickOutside)
+ }
+ }, [open, setOpen])
+
+ if (!open) return null
+
+ return (
+
+ {children}
+
+ )
+}
+
+export function DropdownMenuItem({
+ children,
+ className,
+ onClick,
+}: {
+ children: ReactNode
+ className?: string
+ onClick?: () => void
+}) {
+ const { setOpen } = useDropdown()
+ return (
+ {
+ onClick?.()
+ setOpen(false)
+ }}
+ >
+ {children}
+
+ )
+}
+
+export function DropdownMenuSeparator() {
+ return
+}
diff --git a/apps/dashboard/src/components/ui/input.tsx b/apps/dashboard/src/components/ui/input.tsx
new file mode 100644
index 0000000..f8acde2
--- /dev/null
+++ b/apps/dashboard/src/components/ui/input.tsx
@@ -0,0 +1,23 @@
+import { cn } from "@/lib/utils"
+import { forwardRef, type InputHTMLAttributes } from "react"
+
+export interface InputProps extends InputHTMLAttributes {}
+
+const Input = forwardRef(
+ ({ className, type, ...props }, ref) => {
+ return (
+
+ )
+ }
+)
+Input.displayName = "Input"
+
+export { Input }
diff --git a/apps/dashboard/src/components/ui/skeleton.tsx b/apps/dashboard/src/components/ui/skeleton.tsx
new file mode 100644
index 0000000..2b4045c
--- /dev/null
+++ b/apps/dashboard/src/components/ui/skeleton.tsx
@@ -0,0 +1,12 @@
+import { cn } from "@/lib/utils"
+
+function Skeleton({ className, ...props }: React.HTMLAttributes) {
+ return (
+
+ )
+}
+
+export { Skeleton }
diff --git a/apps/dashboard/src/hooks/use-breadcrumbs.ts b/apps/dashboard/src/hooks/use-breadcrumbs.ts
new file mode 100644
index 0000000..bee7be6
--- /dev/null
+++ b/apps/dashboard/src/hooks/use-breadcrumbs.ts
@@ -0,0 +1,46 @@
+import { useLocation } from "react-router-dom"
+
+interface BreadcrumbItem {
+ label: string
+ href?: string
+}
+
+const segmentLabels: Record = {
+ overview: "Overview",
+ urls: "URLs",
+ new: "Create URL",
+ bulk: "Bulk Create",
+ settings: "Settings",
+ collections: "Collections",
+ tags: "Tags",
+ "api-keys": "API Keys",
+ billing: "Billing",
+ plans: "Plans",
+}
+
+export function useBreadcrumbs(): BreadcrumbItem[] {
+ const location = useLocation()
+ const segments = location.pathname.split("/").filter(Boolean)
+
+ if (segments.length === 0) {
+ return [{ label: "Overview", href: "/overview" }]
+ }
+
+ const items: BreadcrumbItem[] = []
+
+ for (let i = 0; i < segments.length; i++) {
+ const segment = segments[i]
+ const path = "/" + segments.slice(0, i + 1).join("/")
+
+ const label = segmentLabels[segment] ?? segment
+
+ const isLast = i === segments.length - 1
+
+ items.push({
+ label,
+ ...(isLast ? {} : { href: path }),
+ })
+ }
+
+ return items
+}
diff --git a/apps/dashboard/src/hooks/use-mobile.ts b/apps/dashboard/src/hooks/use-mobile.ts
new file mode 100644
index 0000000..210cd6d
--- /dev/null
+++ b/apps/dashboard/src/hooks/use-mobile.ts
@@ -0,0 +1,19 @@
+import { useEffect, useState } from "react"
+
+const MOBILE_BREAKPOINT = 768
+
+export function useMobile() {
+ const [isMobile, setIsMobile] = useState(undefined)
+
+ useEffect(() => {
+ const mql = window.matchMedia(`(max-width: ${MOBILE_BREAKPOINT - 1}px)`)
+ const onChange = () => {
+ setIsMobile(window.innerWidth < MOBILE_BREAKPOINT)
+ }
+ mql.addEventListener("change", onChange)
+ setIsMobile(window.innerWidth < MOBILE_BREAKPOINT)
+ return () => mql.removeEventListener("change", onChange)
+ }, [])
+
+ return !!isMobile
+}
diff --git a/apps/dashboard/src/index.css b/apps/dashboard/src/index.css
index 4acf1a0..8399cf6 100644
--- a/apps/dashboard/src/index.css
+++ b/apps/dashboard/src/index.css
@@ -2,27 +2,29 @@
@import "tw-animate-css";
@import "@fontsource-variable/geist";
-@theme {
+@custom-variant dark (&:is(.dark *));
+
+@theme inline {
--font-sans: "Geist Variable", sans-serif;
- --color-background: oklch(1 0 0);
- --color-foreground: oklch(0.09 0 0);
- --color-primary: oklch(0.35 0.12 260);
- --color-primary-foreground: oklch(0.985 0 0);
- --color-muted: oklch(0.96 0.005 260);
- --color-muted-foreground: oklch(0.55 0.01 260);
- --color-accent: oklch(0.65 0.15 260);
- --color-accent-foreground: oklch(0.985 0 0);
- --color-border: oklch(0.88 0.005 260);
- --color-card: oklch(1 0 0);
- --color-card-foreground: oklch(0.09 0 0);
- --color-destructive: oklch(0.577 0.245 27.325);
- --color-destructive-foreground: oklch(0.985 0 0);
-
- --color-sidebar: oklch(0.96 0.005 260);
- --color-sidebar-foreground: oklch(0.09 0 0);
- --color-sidebar-accent: oklch(0.65 0.15 260);
- --color-sidebar-accent-foreground: oklch(0.985 0 0);
+ --color-background: var(--background);
+ --color-foreground: var(--foreground);
+ --color-primary: var(--primary);
+ --color-primary-foreground: var(--primary-foreground);
+ --color-muted: var(--muted);
+ --color-muted-foreground: var(--muted-foreground);
+ --color-accent: var(--accent);
+ --color-accent-foreground: var(--accent-foreground);
+ --color-border: var(--border);
+ --color-card: var(--card);
+ --color-card-foreground: var(--card-foreground);
+ --color-destructive: var(--destructive);
+ --color-destructive-foreground: var(--destructive-foreground);
+
+ --color-sidebar: var(--sidebar);
+ --color-sidebar-foreground: var(--sidebar-foreground);
+ --color-sidebar-accent: var(--sidebar-accent);
+ --color-sidebar-accent-foreground: var(--sidebar-accent-foreground);
--radius-sm: 6px;
--radius-md: 8px;
@@ -32,6 +34,46 @@
--shadow-hover: 0 8px 24px rgba(0, 0, 0, 0.1);
}
+:root {
+ --background: oklch(1 0 0);
+ --foreground: oklch(0.09 0 0);
+ --primary: oklch(0.35 0.12 260);
+ --primary-foreground: oklch(0.985 0 0);
+ --muted: oklch(0.96 0.005 260);
+ --muted-foreground: oklch(0.55 0.01 260);
+ --accent: oklch(0.65 0.15 260);
+ --accent-foreground: oklch(0.985 0 0);
+ --border: oklch(0.88 0.005 260);
+ --card: oklch(1 0 0);
+ --card-foreground: oklch(0.09 0 0);
+ --destructive: oklch(0.577 0.245 27.325);
+ --destructive-foreground: oklch(0.985 0 0);
+ --sidebar: oklch(0.96 0.005 260);
+ --sidebar-foreground: oklch(0.09 0 0);
+ --sidebar-accent: oklch(0.65 0.15 260);
+ --sidebar-accent-foreground: oklch(0.985 0 0);
+}
+
+.dark {
+ --background: oklch(0.12 0.005 260);
+ --foreground: oklch(0.985 0 0);
+ --primary: oklch(0.65 0.15 260);
+ --primary-foreground: oklch(0.12 0.005 260);
+ --muted: oklch(0.18 0.005 260);
+ --muted-foreground: oklch(0.6 0.01 260);
+ --accent: oklch(0.45 0.12 260);
+ --accent-foreground: oklch(0.985 0 0);
+ --border: oklch(0.22 0.005 260);
+ --card: oklch(0.14 0.005 260);
+ --card-foreground: oklch(0.985 0 0);
+ --destructive: oklch(0.704 0.191 22.216);
+ --destructive-foreground: oklch(0.985 0 0);
+ --sidebar: oklch(0.16 0.005 260);
+ --sidebar-foreground: oklch(0.985 0 0);
+ --sidebar-accent: oklch(0.22 0.005 260);
+ --sidebar-accent-foreground: oklch(0.985 0 0);
+}
+
@layer base {
* {
border-color: var(--color-border);
@@ -45,18 +87,3 @@
-moz-osx-font-smoothing: grayscale;
}
}
-
-@media (prefers-color-scheme: dark) {
- :root {
- --color-background: oklch(0.12 0.005 260);
- --color-foreground: oklch(0.985 0 0);
- --color-primary: oklch(0.65 0.15 260);
- --color-muted: oklch(0.18 0.005 260);
- --color-muted-foreground: oklch(0.6 0.01 260);
- --color-border: oklch(0.22 0.005 260);
- --color-card: oklch(0.12 0.005 260);
- --color-card-foreground: oklch(0.985 0 0);
- --color-sidebar: oklch(0.16 0.005 260);
- --color-sidebar-foreground: oklch(0.985 0 0);
- }
-}
diff --git a/apps/dashboard/src/pages/ApiKeysPage.tsx b/apps/dashboard/src/pages/ApiKeysPage.tsx
new file mode 100644
index 0000000..a459273
--- /dev/null
+++ b/apps/dashboard/src/pages/ApiKeysPage.tsx
@@ -0,0 +1,12 @@
+import PageHeader from "@/components/page-header"
+
+export default function ApiKeysPage() {
+ return (
+
+ )
+}
diff --git a/apps/dashboard/src/pages/BillingPage.tsx b/apps/dashboard/src/pages/BillingPage.tsx
new file mode 100644
index 0000000..52d3717
--- /dev/null
+++ b/apps/dashboard/src/pages/BillingPage.tsx
@@ -0,0 +1,12 @@
+import PageHeader from "@/components/page-header"
+
+export default function BillingPage() {
+ return (
+
+ )
+}
diff --git a/apps/dashboard/src/pages/BulkCreatePage.tsx b/apps/dashboard/src/pages/BulkCreatePage.tsx
new file mode 100644
index 0000000..22d95cf
--- /dev/null
+++ b/apps/dashboard/src/pages/BulkCreatePage.tsx
@@ -0,0 +1,12 @@
+import PageHeader from "@/components/page-header"
+
+export default function BulkCreatePage() {
+ return (
+
+ )
+}
diff --git a/apps/dashboard/src/pages/CollectionDetailPage.tsx b/apps/dashboard/src/pages/CollectionDetailPage.tsx
new file mode 100644
index 0000000..d7626d9
--- /dev/null
+++ b/apps/dashboard/src/pages/CollectionDetailPage.tsx
@@ -0,0 +1,12 @@
+import PageHeader from "@/components/page-header"
+
+export default function CollectionDetailPage() {
+ return (
+
+ )
+}
diff --git a/apps/dashboard/src/pages/CollectionsListPage.tsx b/apps/dashboard/src/pages/CollectionsListPage.tsx
new file mode 100644
index 0000000..36edf17
--- /dev/null
+++ b/apps/dashboard/src/pages/CollectionsListPage.tsx
@@ -0,0 +1,12 @@
+import PageHeader from "@/components/page-header"
+
+export default function CollectionsListPage() {
+ return (
+
+ )
+}
diff --git a/apps/dashboard/src/pages/CreateUrlPage.tsx b/apps/dashboard/src/pages/CreateUrlPage.tsx
new file mode 100644
index 0000000..7154b35
--- /dev/null
+++ b/apps/dashboard/src/pages/CreateUrlPage.tsx
@@ -0,0 +1,12 @@
+import PageHeader from "@/components/page-header"
+
+export default function CreateUrlPage() {
+ return (
+
+ )
+}
diff --git a/apps/dashboard/src/pages/NotFound.tsx b/apps/dashboard/src/pages/NotFound.tsx
new file mode 100644
index 0000000..3f9f6cd
--- /dev/null
+++ b/apps/dashboard/src/pages/NotFound.tsx
@@ -0,0 +1,17 @@
+import { Link } from "react-router-dom"
+import { Button } from "@/components/ui/button"
+
+export default function NotFoundPage() {
+ return (
+
+
404
+
Page not found
+
+ The page you are looking for doesn't exist or has been moved.
+
+
+
Back to Overview
+
+
+ )
+}
diff --git a/apps/dashboard/src/pages/Overview.tsx b/apps/dashboard/src/pages/Overview.tsx
new file mode 100644
index 0000000..77de547
--- /dev/null
+++ b/apps/dashboard/src/pages/Overview.tsx
@@ -0,0 +1,158 @@
+import { useState, useEffect, useCallback } from "react"
+import { useAuth } from "@/hooks/use-auth"
+import { getUsage, getSubscription, listUrls, createUrl } from "@/lib/api"
+import type { Usage, UserSubscription } from "@linkify/shared"
+import type { ShortUrl, Pagination } from "@linkify/shared"
+import { toast } from "sonner"
+import PageHeader from "@/components/page-header"
+import StatsCard from "@/components/overview/stats-card"
+import RecentLinksList from "@/components/overview/recent-links-list"
+import QuickCreateForm from "@/components/overview/quick-create-form"
+import PlanStatusCard from "@/components/overview/plan-status-card"
+import { Skeleton } from "@/components/ui/skeleton"
+import { Link2, MousePointerClick, Activity, Gauge } from "lucide-react"
+import { Button } from "@/components/ui/button"
+import { Link } from "react-router-dom"
+
+export default function OverviewPage() {
+ const { session } = useAuth()
+ const token = session?.access_token ?? ""
+
+ const [usage, setUsage] = useState(null)
+ const [subscription, setSubscription] = useState(null)
+ const [recentUrls, setRecentUrls] = useState([])
+ const [totalUrls, setTotalUrls] = useState(0)
+ const [loading, setLoading] = useState(true)
+ const [error, setError] = useState(null)
+
+ const fetchData = useCallback(async () => {
+ if (!token) return
+ setLoading(true)
+ setError(null)
+
+ try {
+ const [usageData, subData, urlsData] = await Promise.all([
+ getUsage(token).catch(() => null),
+ getSubscription(token).catch(() => null),
+ listUrls(token, {
+ limit: "5",
+ sortBy: "createdAt",
+ sortOrder: "desc",
+ }).catch(() => null),
+ ])
+
+ if (usageData) setUsage(usageData)
+ if (subData) setSubscription(subData)
+ if (urlsData) {
+ setRecentUrls(urlsData.urls)
+ setTotalUrls(urlsData.pagination.total)
+ }
+ } catch {
+ setError("Failed to load overview data")
+ } finally {
+ setLoading(false)
+ }
+ }, [token])
+
+ useEffect(() => {
+ fetchData()
+ }, [fetchData])
+
+ const handleShorten = async (url: string) => {
+ const result = await createUrl(token, { url })
+ await fetchData()
+ return result
+ }
+
+ if (loading) {
+ return (
+
+
+
+ {Array.from({ length: 4 }).map((_, i) => (
+
+ ))}
+
+
+
+ )
+ }
+
+ if (error) {
+ return (
+
+ )
+ }
+
+ const plan = usage?.plan
+ const maxLinks = plan?.maxLinks ?? 100
+ const linksUsed = usage?.totalLinks ?? 0
+ const usagePercent = maxLinks > 0 ? Math.round((linksUsed / maxLinks) * 100) : 0
+ const activeLinks = recentUrls.filter(
+ (u) => !u.expiresAt || new Date(u.expiresAt) > new Date()
+ ).length
+
+ return (
+
+
+
+
+ }
+ />
+ }
+ />
+ }
+ />
+ }
+ progress={{ value: usagePercent, max: 100 }}
+ subtitle={`${linksUsed} / ${maxLinks} links`}
+ />
+
+
+
+
+
+
+
+
+
+ {recentUrls.length === 0 && (
+
+
+
No links yet
+
+ Create your first short link to get started.
+
+
+
+ Create your first link
+
+
+
+ )}
+
+
+
+ )
+}
diff --git a/apps/dashboard/src/pages/PlansPage.tsx b/apps/dashboard/src/pages/PlansPage.tsx
new file mode 100644
index 0000000..c888cc4
--- /dev/null
+++ b/apps/dashboard/src/pages/PlansPage.tsx
@@ -0,0 +1,12 @@
+import PageHeader from "@/components/page-header"
+
+export default function PlansPage() {
+ return (
+
+ )
+}
diff --git a/apps/dashboard/src/pages/TagDetailPage.tsx b/apps/dashboard/src/pages/TagDetailPage.tsx
new file mode 100644
index 0000000..395dabf
--- /dev/null
+++ b/apps/dashboard/src/pages/TagDetailPage.tsx
@@ -0,0 +1,12 @@
+import PageHeader from "@/components/page-header"
+
+export default function TagDetailPage() {
+ return (
+
+ )
+}
diff --git a/apps/dashboard/src/pages/TagsListPage.tsx b/apps/dashboard/src/pages/TagsListPage.tsx
new file mode 100644
index 0000000..eb0ae97
--- /dev/null
+++ b/apps/dashboard/src/pages/TagsListPage.tsx
@@ -0,0 +1,12 @@
+import PageHeader from "@/components/page-header"
+
+export default function TagsListPage() {
+ return (
+
+ )
+}
diff --git a/apps/dashboard/src/pages/UrlDetailPage.tsx b/apps/dashboard/src/pages/UrlDetailPage.tsx
new file mode 100644
index 0000000..2b8bf68
--- /dev/null
+++ b/apps/dashboard/src/pages/UrlDetailPage.tsx
@@ -0,0 +1,12 @@
+import PageHeader from "@/components/page-header"
+
+export default function UrlDetailPage() {
+ return (
+
+ )
+}
diff --git a/apps/dashboard/src/pages/UrlSettingsPage.tsx b/apps/dashboard/src/pages/UrlSettingsPage.tsx
new file mode 100644
index 0000000..242210a
--- /dev/null
+++ b/apps/dashboard/src/pages/UrlSettingsPage.tsx
@@ -0,0 +1,12 @@
+import PageHeader from "@/components/page-header"
+
+export default function UrlSettingsPage() {
+ return (
+
+ )
+}
diff --git a/apps/dashboard/src/pages/UrlsListPage.tsx b/apps/dashboard/src/pages/UrlsListPage.tsx
new file mode 100644
index 0000000..9016cf4
--- /dev/null
+++ b/apps/dashboard/src/pages/UrlsListPage.tsx
@@ -0,0 +1,30 @@
+import PageHeader from "@/components/page-header"
+import { Button } from "@/components/ui/button"
+import { Link } from "react-router-dom"
+import { Plus } from "lucide-react"
+
+export default function UrlsListPage() {
+ return (
+
+
+
+
+
+ Create URL
+
+
+
+
+ Bulk Create
+
+
+
+ }
+ />
+
+ )
+}
diff --git a/apps/dashboard/vite.config.ts b/apps/dashboard/vite.config.ts
index a6a92f0..99c3c23 100644
--- a/apps/dashboard/vite.config.ts
+++ b/apps/dashboard/vite.config.ts
@@ -1,9 +1,10 @@
import react from "@vitejs/plugin-react"
+import tailwindcss from "@tailwindcss/vite"
import path from "path"
import { defineConfig } from "vite"
export default defineConfig({
- plugins: [react()],
+ plugins: [react(), tailwindcss()],
resolve: {
alias: {
"@": path.resolve(__dirname, "./src"),
diff --git a/specs/02-second-phase.md b/specs/02-second-phase.md
new file mode 100644
index 0000000..ffe542f
--- /dev/null
+++ b/specs/02-second-phase.md
@@ -0,0 +1,26 @@
+## Building the Second Phase of app/dashboard.
+
+You have to build the phase 1 of the phases.md. It has all the needed information about what you willl build. You have to properly and professioanlly implement it.
+
+## Instructions.
+
+Think like a professional and experienced developer with years of experience. Write professional and production-ready code and professional code logics and best-practices. think like a 100x engineer. do same as what he would do. follow proper typescript. Do not break anything. Build everything written in the phase.
+
+# What to DO.
+
+write production-ready code and professional code and best-practices. Follow professional code guidelines.Create a minimal aesthetic professional and popping out UI and UX that follows modern design principles and professional design hirarchy and strictly follow the DESIGN.md file for the design theme and layout and everything.
+
+# What not to do.
+
+Dont use hardcoded talwind colors.
+
+## Context
+
+- AGENTS.md
+- docs/API.md
+- docs/PROJECT.md
+- README.md
+- phases.md
+- DESIGN.md
+
+these all files have all the context related to project backend and full guides.
\ No newline at end of file