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
5 changes: 3 additions & 2 deletions README.md
Original file line number Diff line number Diff line change
Expand Up @@ -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) |
Expand Down Expand Up @@ -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 |
Expand Down
31 changes: 20 additions & 11 deletions apps/api/src/__tests__/index.test.ts
Original file line number Diff line number Diff line change
Expand Up @@ -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',
Expand All @@ -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()
})
})
})
51 changes: 51 additions & 0 deletions apps/dashboard/src/components/copy-button.tsx
Original file line number Diff line number Diff line change
@@ -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 (
<Button
variant={variant}
size={size}
onClick={handleCopy}
className={cn("gap-1.5", className)}
title="Copy to clipboard"
>
{copied ? <Check className="h-3.5 w-3.5 text-emerald-500" /> : <Copy className="h-3.5 w-3.5" />}
{copied ? "Copied" : "Copy"}
</Button>
)
}
44 changes: 44 additions & 0 deletions apps/dashboard/src/components/status-badge.tsx
Original file line number Diff line number Diff line change
@@ -0,0 +1,44 @@
import { Badge } from "@/components/ui/badge"
import { cn } from "@/lib/utils"

interface StatusBadgeProps {
status: string
className?: string
}

const statusConfig: Record<string, { label: string; variant: "default" | "secondary" | "success" | "destructive" | "outline" }> = {
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 (
<Badge variant={config.variant} className={cn("capitalize", className)}>
{config.label}
</Badge>
)
}

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"
}
136 changes: 136 additions & 0 deletions apps/dashboard/src/components/ui/command.tsx
Original file line number Diff line number Diff line change
@@ -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<HTMLInputElement | null>
}

const CommandContext = createContext<CommandContextValue | undefined>(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<HTMLInputElement>(null)

useEffect(() => {
setTimeout(() => inputRef.current?.focus(), 50)
}, [])

return (
<CommandContext.Provider value={{ search, setSearch, filter, inputRef }}>
<div className={cn("", className)}>
{children}
</div>
</CommandContext.Provider>
)
}
Comment thread
coderabbitai[bot] marked this conversation as resolved.

interface CommandInputProps {
placeholder?: string
className?: string
}

export function CommandInput({ placeholder = "Search...", className }: CommandInputProps) {
const { search, setSearch, inputRef } = useCommand()
return (
<div className="flex items-center border-b border-border px-3">
<input
ref={inputRef}
value={search}
onChange={(e) => 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
)}
/>
</div>
)
}

interface CommandListProps {
children: ReactNode
className?: string
}

export function CommandList({ children, className }: CommandListProps) {
return (
<div className={cn("max-h-60 overflow-y-auto py-1", className)}>
{children}
</div>
)
}

interface CommandEmptyProps {
children: ReactNode
className?: string
}

export function CommandEmpty({ children, className }: CommandEmptyProps) {
return (
<div className={cn("py-6 text-center text-sm text-muted-foreground", className)}>
{children}
</div>
)
}

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 (
<button
type="button"
disabled={disabled}
onClick={onSelect}
className={cn(
"relative flex w-full cursor-default select-none items-center rounded-md px-2 py-1.5 text-sm text-foreground outline-none transition-colors hover:bg-muted aria-selected:bg-muted data-[disabled]:pointer-events-none data-[disabled]:opacity-50",
className
)}
>
{children}
</button>
)
}

interface CommandGroupProps {
children: ReactNode
heading?: string
className?: string
}

export function CommandGroup({ children, heading, className }: CommandGroupProps) {
return (
<div className={cn("", className)}>
{heading && (
<div className="px-2 py-1.5 text-xs font-medium text-muted-foreground">{heading}</div>
)}
{children}
</div>
)
}
Loading
Loading