diff --git a/README.md b/README.md index 9d91c9e..b965f1d 100644 --- a/README.md +++ b/README.md @@ -30,7 +30,7 @@ | **Collections** | Nested folders with drag-style reordering and public sharing | | **Tags** | Colour-coded labels with bulk assignment | | **Custom Domains** | Bring your own domain with DNS verification + SSL tracking | -| **QR Codes** | PNG/SVG generation with optional logo embedding | +| **QR Codes** | PNG/SVG generation with caching, optional logo embedding, and time-limited expiry | | **Bulk Operations** | Tag, move, extend expiry, or delete multiple links at once | | **CSV Import/Export** | Import up to 500 URLs; export visit logs as CSV | | **Link Chaining** | Point short links to other short links (max 5 hops, cycle detection) | @@ -164,7 +164,8 @@ curl -L http://localhost:3000/aB3xK9m | `GET /api/urls/:code/visits` | Visit log | | `GET /api/urls/:code/stats` | Analytics (hourly/daily aggregation) | | `GET /api/urls/:code/visits/export` | CSV export of visits | -| `GET /api/urls/:code/qr` | QR code generation | +| `GET /api/urls/:code/qr` | QR code generation (cached, time-limited) | +| `POST /api/urls/:code/qr/regenerate` | Regenerate expired QR code | | `POST /api/urls/:code/verify-password` | Password verification | | `PATCH /api/urls/:code/settings` | Update link settings | | `POST /api/collections` | Create collection | diff --git a/apps/api/src/__tests__/index.test.ts b/apps/api/src/__tests__/index.test.ts index fa505a7..d221c4a 100644 --- a/apps/api/src/__tests__/index.test.ts +++ b/apps/api/src/__tests__/index.test.ts @@ -40,25 +40,33 @@ vi.mock('../jobs/seedPlans', () => ({ })) describe('Server entry point (index.ts)', () => { + beforeEach(() => { + vi.resetModules() + vi.clearAllMocks() + }) + it('starts the server on the configured port', async () => { await import('../index') - - expect(mockListen).toHaveBeenCalledWith(4000, expect.any(Function)) + await vi.waitFor(() => { + expect(mockListen).toHaveBeenCalledWith(4000, expect.any(Function)) + }) }) it('logs server start message', async () => { await import('../index') - - expect(mockLogger.info).toHaveBeenCalledWith( - { port: 4000 }, - 'Server started', - ) + await vi.waitFor(() => { + expect(mockLogger.info).toHaveBeenCalledWith( + { port: 4000 }, + 'Server started', + ) + }) }) it('starts health check job when interval is configured', async () => { await import('../index') - - expect(mockStartHealthCheckJob).toHaveBeenCalledWith(3600000) + await vi.waitFor(() => { + expect(mockStartHealthCheckJob).toHaveBeenCalledWith(3600000) + }) expect(mockLogger.info).toHaveBeenCalledWith( { interval: 3600000 }, 'Health check job started', @@ -67,7 +75,8 @@ describe('Server entry point (index.ts)', () => { it('seeds plans on startup (fire-and-forget)', async () => { await import('../index') - - expect(mockSeedPlans).toHaveBeenCalled() + await vi.waitFor(() => { + expect(mockSeedPlans).toHaveBeenCalled() + }) }) }) diff --git a/apps/dashboard/src/components/copy-button.tsx b/apps/dashboard/src/components/copy-button.tsx new file mode 100644 index 0000000..783a6f7 --- /dev/null +++ b/apps/dashboard/src/components/copy-button.tsx @@ -0,0 +1,51 @@ +import { useState, useCallback } from "react" +import { Button } from "@/components/ui/button" +import { Check, Copy } from "lucide-react" +import { cn } from "@/lib/utils" + +interface CopyButtonProps { + text: string + className?: string + variant?: "primary" | "outline" | "ghost" + size?: "default" | "sm" | "lg" | "icon" +} + +export default function CopyButton({ text, className, variant = "outline", size = "icon" }: CopyButtonProps) { + const [copied, setCopied] = useState(false) + + const handleCopy = useCallback(async () => { + try { + await navigator.clipboard.writeText(text) + setCopied(true) + setTimeout(() => setCopied(false), 1500) + } catch { + const input = document.createElement("input") + input.value = text + input.style.position = "fixed" + input.style.opacity = "0" + document.body.appendChild(input) + try { + input.select() + if (document.execCommand("copy")) { + setCopied(true) + setTimeout(() => setCopied(false), 1500) + } + } finally { + document.body.removeChild(input) + } + } + }, [text]) + + return ( + + ) +} diff --git a/apps/dashboard/src/components/status-badge.tsx b/apps/dashboard/src/components/status-badge.tsx new file mode 100644 index 0000000..f33bdb8 --- /dev/null +++ b/apps/dashboard/src/components/status-badge.tsx @@ -0,0 +1,44 @@ +import { Badge } from "@/components/ui/badge" +import { cn } from "@/lib/utils" + +interface StatusBadgeProps { + status: string + className?: string +} + +const statusConfig: Record = { + active: { label: "Active", variant: "success" }, + expired: { label: "Expired", variant: "destructive" }, + scheduled: { label: "Scheduled", variant: "outline" }, + "password-protected": { label: "Password", variant: "default" }, + "blocked-bots": { label: "Bots Blocked", variant: "secondary" }, + inactive: { label: "Inactive", variant: "secondary" }, +} + +export default function StatusBadge({ status, className }: StatusBadgeProps) { + const config = statusConfig[status] ?? { label: status, variant: "outline" as const } + + return ( + + {config.label} + + ) +} + +export function getLinkStatus(link: { expiresAt: string | null; activeAt: string | null; hasPassword: boolean; blockBots: boolean }): string { + const now = new Date() + + if (link.activeAt && new Date(link.activeAt) > now) { + return "scheduled" + } + if (link.expiresAt && new Date(link.expiresAt) < now) { + return "expired" + } + if (link.hasPassword) { + return "password-protected" + } + if (link.blockBots) { + return "blocked-bots" + } + return "active" +} diff --git a/apps/dashboard/src/components/ui/command.tsx b/apps/dashboard/src/components/ui/command.tsx new file mode 100644 index 0000000..964ba59 --- /dev/null +++ b/apps/dashboard/src/components/ui/command.tsx @@ -0,0 +1,136 @@ +import { cn } from "@/lib/utils" +import { createContext, useContext, useState, useRef, useEffect, type ReactNode } from "react" + +interface CommandContextValue { + search: string + setSearch: (search: string) => void + filter?: (value: string, search: string) => boolean + inputRef: React.RefObject +} + +const CommandContext = createContext(undefined) + +function useCommand() { + const ctx = useContext(CommandContext) + if (!ctx) throw new Error("Command components must be used within Command") + return ctx +} + +interface CommandProps { + children: ReactNode + className?: string + filter?: (value: string, search: string) => boolean +} + +export function Command({ children, className, filter }: CommandProps) { + const [search, setSearch] = useState("") + const inputRef = useRef(null) + + useEffect(() => { + setTimeout(() => inputRef.current?.focus(), 50) + }, []) + + return ( + +
+ {children} +
+
+ ) +} + +interface CommandInputProps { + placeholder?: string + className?: string +} + +export function CommandInput({ placeholder = "Search...", className }: CommandInputProps) { + const { search, setSearch, inputRef } = useCommand() + return ( +
+ setSearch(e.target.value)} + placeholder={placeholder} + className={cn( + "flex h-9 w-full rounded-md bg-transparent py-2 text-sm text-foreground outline-none placeholder:text-muted-foreground", + className + )} + /> +
+ ) +} + +interface CommandListProps { + children: ReactNode + className?: string +} + +export function CommandList({ children, className }: CommandListProps) { + return ( +
+ {children} +
+ ) +} + +interface CommandEmptyProps { + children: ReactNode + className?: string +} + +export function CommandEmpty({ children, className }: CommandEmptyProps) { + return ( +
+ {children} +
+ ) +} + +interface CommandItemProps { + children: ReactNode + value?: string + onSelect?: () => void + disabled?: boolean + className?: string +} + +export function CommandItem({ children, value, onSelect, disabled, className }: CommandItemProps) { + const { search, filter } = useCommand() + if (search) { + const match = filter ? filter(value ?? "", search) : value?.toLowerCase().includes(search.toLowerCase()) + if (!match) return null + } + + return ( + + ) +} + +interface CommandGroupProps { + children: ReactNode + heading?: string + className?: string +} + +export function CommandGroup({ children, heading, className }: CommandGroupProps) { + return ( +
+ {heading && ( +
{heading}
+ )} + {children} +
+ ) +} diff --git a/apps/dashboard/src/components/ui/dialog.tsx b/apps/dashboard/src/components/ui/dialog.tsx new file mode 100644 index 0000000..c00e9e0 --- /dev/null +++ b/apps/dashboard/src/components/ui/dialog.tsx @@ -0,0 +1,131 @@ +import { cn } from "@/lib/utils" +import { createContext, useContext, useEffect, useRef, useId, useCallback, type ReactNode } from "react" + +interface DialogContextValue { + titleId: string + descriptionId: string +} + +const DialogContext = createContext(undefined) + +export function useDialog() { + const ctx = useContext(DialogContext) + if (!ctx) throw new Error("Dialog components must be used within Dialog") + return ctx +} + +interface DialogProps { + open: boolean + onClose: () => void + children: ReactNode +} + +export function Dialog({ open, onClose, children }: DialogProps) { + const overlayRef = useRef(null) + const contentRef = useRef(null) + const previousFocusRef = useRef(null) + const titleId = useId() + const descriptionId = useId() + + const getFocusableElements = useCallback(() => { + if (!contentRef.current) return [] + return Array.from( + contentRef.current.querySelectorAll( + 'a[href], button:not([disabled]), textarea:not([disabled]), input:not([disabled]), select:not([disabled]), [tabindex]:not([tabindex="-1"])' + ) + ) + }, []) + + useEffect(() => { + if (!open) return + previousFocusRef.current = document.activeElement as HTMLElement + const timer = setTimeout(() => { + const focusable = getFocusableElements() + if (focusable.length > 0) focusable[0].focus() + }, 50) + return () => clearTimeout(timer) + }, [open, getFocusableElements]) + + useEffect(() => { + if (!open) { + const timer = setTimeout(() => previousFocusRef.current?.focus(), 50) + return () => clearTimeout(timer) + } + }, [open]) + + useEffect(() => { + if (!open) return + const handler = (e: KeyboardEvent) => { + if (e.key === "Escape") { + onClose() + return + } + if (e.key === "Tab") { + const focusable = getFocusableElements() + if (focusable.length === 0) return + const first = focusable[0] + const last = focusable[focusable.length - 1] + if (e.shiftKey) { + if (document.activeElement === first) { + e.preventDefault() + last.focus() + } + } else { + if (document.activeElement === last) { + e.preventDefault() + first.focus() + } + } + } + } + document.addEventListener("keydown", handler) + document.body.style.overflow = "hidden" + return () => { + document.removeEventListener("keydown", handler) + document.body.style.overflow = "" + } + }, [open, onClose, getFocusableElements]) + + if (!open) return null + + return ( + +
{ + if (e.target === overlayRef.current) onClose() + }} + > +
+ {children} +
+
+
+ ) +} + +export function DialogHeader({ children, className }: { children: ReactNode; className?: string }) { + return
{children}
+} + +export function DialogTitle({ children, className }: { children: ReactNode; className?: string }) { + const { titleId } = useDialog() + return

{children}

+} + +export function DialogDescription({ children, className }: { children: ReactNode; className?: string }) { + const { descriptionId } = useDialog() + return

{children}

+} + +export function DialogFooter({ children, className }: { children: ReactNode; className?: string }) { + return
{children}
+} diff --git a/apps/dashboard/src/components/ui/dropdown-menu.tsx b/apps/dashboard/src/components/ui/dropdown-menu.tsx index ae7c855..a9f10d5 100644 --- a/apps/dashboard/src/components/ui/dropdown-menu.tsx +++ b/apps/dashboard/src/components/ui/dropdown-menu.tsx @@ -5,6 +5,8 @@ import { useState, useRef, useEffect, + cloneElement, + isValidElement, type ReactNode, } from "react" @@ -32,11 +34,14 @@ export function DropdownMenu({ children }: { children: ReactNode }) { export function DropdownMenuTrigger({ children, asChild }: { children: ReactNode; asChild?: boolean }) { const { open, setOpen } = useDropdown() + if (asChild && isValidElement(children)) { + return cloneElement(children, { onClick: () => setOpen(!open) } as Partial) + } return ( diff --git a/apps/dashboard/src/components/ui/label.tsx b/apps/dashboard/src/components/ui/label.tsx new file mode 100644 index 0000000..16292c3 --- /dev/null +++ b/apps/dashboard/src/components/ui/label.tsx @@ -0,0 +1,18 @@ +import { cn } from "@/lib/utils" +import { forwardRef, type LabelHTMLAttributes } from "react" + +const Label = forwardRef>( + ({ className, ...props }, ref) => ( +