import { CodeIcon, LinkIcon } from 'lucide-react'; import { useEffect, useMemo, useState, type KeyboardEvent as ReactKeyboardEvent, type SyntheticEvent } from 'react'; import type { Link } from '../lib/api'; import { safeLinkTargetUrl } from '../lib/url'; type SortField = 'alias' | 'linkType' | 'targetUrl' | 'clickCount' | 'updatedAt'; type SortDir = 'asc' | 'desc'; type ViewStyle = 'list' | 'cards'; const PAGE_SIZE = 100; const VIEW_QUERY_PARAM = 'view'; const SORT_LABELS: Record = { alias: 'Alias', linkType: 'Type', targetUrl: 'Target', clickCount: 'Clicks', updatedAt: 'Updated', }; interface LinkTableProps { readonly links: Link[]; readonly loading?: boolean; readonly error?: string | null; readonly emptyMessage?: string; readonly selectable?: boolean; readonly selectedIds?: ReadonlySet; readonly onToggleSelect?: (id: string) => void; readonly onSelectAll?: (ids: string[]) => void; readonly onEdit?: (link: Link) => void; readonly onDelete?: (link: Link) => void; /** Controls Edit/Delete actions. View is always shown. Defaults to true. */ readonly actions?: boolean; } function readViewStyleFromHash(): ViewStyle { const hash = window.location.hash.startsWith('#') ? window.location.hash.slice(1) : window.location.hash; const [, query = ''] = hash.split('?'); return new URLSearchParams(query).get(VIEW_QUERY_PARAM) === 'cards' ? 'cards' : 'list'; } function writeViewStyleToHash(viewStyle: ViewStyle) { const hash = window.location.hash || '#/'; const hashBody = hash.startsWith('#') ? hash.slice(1) : hash; const [pathPart, query = ''] = hashBody.split('?'); const params = new URLSearchParams(query); if (viewStyle === 'cards') { params.set(VIEW_QUERY_PARAM, viewStyle); } else { params.delete(VIEW_QUERY_PARAM); } const path = pathPart || '/'; const nextQuery = params.toString(); const nextHash = `#${path}${nextQuery ? `?${nextQuery}` : ''}`; if (window.location.hash !== nextHash) { window.history.replaceState(null, '', `${window.location.pathname}${window.location.search}${nextHash}`); } } function SortIndicator({ field, sortField, sortDir }: { field: SortField; sortField: SortField; sortDir: SortDir }) { if (field !== sortField) return ; return {sortDir === 'asc' ? '↑' : '↓'}; } function navigateToLinkDetail(link: Pick) { window.location.hash = `/links/${link.scope}/${encodeURIComponent(link.id)}`; } function stopRowNavigation(event: SyntheticEvent) { event.stopPropagation(); } function handleRowKeyDown(event: ReactKeyboardEvent, link: Pick) { if (event.target !== event.currentTarget) { return; } if (event.key === 'Enter' || event.key === ' ') { event.preventDefault(); navigateToLinkDetail(link); } } function LinkTypeBadge({ linkType, className }: { linkType: Link['linkType']; className?: string }) { const isCustom = linkType === 'custom'; const Icon = isCustom ? CodeIcon : LinkIcon; return ( ); } export default function LinkTable({ links, loading = false, error = null, emptyMessage = 'No links yet.', selectable = false, selectedIds, onToggleSelect, onSelectAll, onEdit, onDelete, actions = true, }: LinkTableProps) { const [sortField, setSortField] = useState('updatedAt'); const [sortDir, setSortDir] = useState('desc'); const [viewStyle, setViewStyle] = useState(() => readViewStyleFromHash()); const [visibleCount, setVisibleCount] = useState(PAGE_SIZE); useEffect(() => { const syncViewStyleFromHash = () => setViewStyle(readViewStyleFromHash()); window.addEventListener('hashchange', syncViewStyleFromHash); return () => window.removeEventListener('hashchange', syncViewStyleFromHash); }, []); useEffect(() => { writeViewStyleToHash(viewStyle); }, [viewStyle]); const sorted = useMemo(() => { return [...links].sort((a, b) => { let cmp = 0; switch (sortField) { case 'alias': cmp = a.alias.localeCompare(b.alias); break; case 'linkType': cmp = a.linkType.localeCompare(b.linkType); break; case 'targetUrl': cmp = (a.targetUrl ?? '').localeCompare(b.targetUrl ?? ''); break; case 'clickCount': cmp = a.clickCount - b.clickCount; break; case 'updatedAt': cmp = a.updatedAt.localeCompare(b.updatedAt); break; } return sortDir === 'asc' ? cmp : -cmp; }); }, [links, sortField, sortDir]); function handleSort(field: SortField) { if (field === sortField) { setSortDir((d) => (d === 'asc' ? 'desc' : 'asc')); } else { setSortField(field); setSortDir('asc'); } setVisibleCount(PAGE_SIZE); } if (loading) return

Loading links…

; if (error) return

{error}

; if (links.length === 0) return

{emptyMessage}

; const allIds = links.map((l) => l.id); const allSelected = selectable && selectedIds != null && allIds.length > 0 && allIds.every((id) => selectedIds.has(id)); const visible = sorted.slice(0, visibleCount); const hasMore = sorted.length > visibleCount; const remaining = sorted.length - visibleCount; const showActionColumn = actions && (onEdit != null || onDelete != null); return (
{/* Toolbar */}
{links.length} link{links.length !== 1 ? 's' : ''} {visibleCount < links.length ? ` · showing ${visibleCount}` : ''}
{viewStyle === 'cards' && ( <>
)}
{/* ── List view ── */} {viewStyle === 'list' && ( {selectable ? ( ) : null} {(['alias', 'linkType', 'targetUrl', 'clickCount', 'updatedAt'] as SortField[]).map((field) => ( ))} {showActionColumn ? : null} {visible.map((link) => { const selected = selectable && selectedIds?.has(link.id) === true; const shortLinkHref = `/links/${encodeURIComponent(link.id)}/go`; const safeUrl = link.linkType === 'redirect' ? safeLinkTargetUrl(link.targetUrl) : null; return ( navigateToLinkDetail(link)} onKeyDown={(event) => handleRowKeyDown(event, link)} tabIndex={0} role="link" aria-label={`Open details for ${link.alias}`} > {selectable ? ( ) : null} {showActionColumn ? ( ) : null} ); })}
onSelectAll?.(allSelected ? [] : allIds)} /> handleSort(field)}> {field === 'alias' ? 'Alias' : field === 'linkType' ? 'Type' : field === 'targetUrl' ? 'Target / content' : field === 'clickCount' ? 'Clicks' : 'Updated'} Actions
onToggleSelect?.(link.id)} /> /{link.alias} {link.description ? {link.description} : null} {link.linkType === 'redirect' ? ( link.targetUrl ? ( safeUrl ? {link.targetUrl} : Invalid target ) : ) : markdown} {link.clickCount.toLocaleString()} {actions && onEdit ? ( ) : null} {actions && onDelete ? ( ) : null}
)} {/* ── Cards view ── */} {viewStyle === 'cards' && (
{visible.map((link) => { const selected = selectable && selectedIds?.has(link.id) === true; const shortLinkHref = `/links/${encodeURIComponent(link.id)}/go`; const safeUrl = link.linkType === 'redirect' ? safeLinkTargetUrl(link.targetUrl) : null; return (
navigateToLinkDetail(link)} onKeyDown={(event) => handleRowKeyDown(event, link)} tabIndex={0} role="link" aria-label={`Open details for ${link.alias}`} >
{selectable ? ( onToggleSelect?.(link.id)} /> ) : null} /{link.alias}
{link.description ?

{link.description}

: null} {link.linkType === 'redirect' && link.targetUrl ? (

{safeUrl ? {link.targetUrl} : {link.targetUrl}}

) : null}
{link.clickCount.toLocaleString()} clicks
{showActionColumn ? (
{actions && onEdit ? ( ) : null} {actions && onDelete ? ( ) : null}
) : null}
); })}
)} {/* ── Load more ── */} {hasMore && (
)}
); }