Files
heygo/src/components/LinkTable.tsx
T
2026-06-20 17:14:51 +10:00

434 lines
17 KiB
TypeScript

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<SortField, string> = {
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<string>;
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 <span className="sort-icon sort-icon--idle"></span>;
return <span className="sort-icon">{sortDir === 'asc' ? '↑' : '↓'}</span>;
}
function navigateToLinkDetail(link: Pick<Link, 'id' | 'scope'>) {
window.location.hash = `/links/${link.scope}/${encodeURIComponent(link.id)}`;
}
function stopRowNavigation(event: SyntheticEvent<HTMLElement>) {
event.stopPropagation();
}
function handleRowKeyDown(event: ReactKeyboardEvent<HTMLElement>, link: Pick<Link, 'id' | 'scope'>) {
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 (
<span className={`link-type-badge ${isCustom ? 'is-custom' : 'is-redirect'}${className ? ` ${className}` : ''}`}>
<Icon aria-hidden="true" size={14} strokeWidth={2.2} />
<span>{isCustom ? 'Custom' : 'Redirect'}</span>
</span>
);
}
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<SortField>('updatedAt');
const [sortDir, setSortDir] = useState<SortDir>('desc');
const [viewStyle, setViewStyle] = useState<ViewStyle>(() => 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 <p className="table-status" aria-busy="true">Loading links</p>;
if (error) return <p className="table-error" role="alert">{error}</p>;
if (links.length === 0) return <p className="table-empty">{emptyMessage}</p>;
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 (
<div>
{/* Toolbar */}
<div className="link-table-toolbar">
<span className="muted" style={{ fontSize: '0.8125rem' }}>
{links.length} link{links.length !== 1 ? 's' : ''}
{visibleCount < links.length ? ` · showing ${visibleCount}` : ''}
</span>
<div style={{ display: 'flex', alignItems: 'center', gap: '0.35rem' }}>
{viewStyle === 'cards' && (
<>
<label style={{ fontSize: '0.8rem', color: 'var(--muted)', fontWeight: 500, whiteSpace: 'nowrap' }}>Sort</label>
<select
value={sortField}
onChange={(e) => { setSortField(e.target.value as SortField); setVisibleCount(PAGE_SIZE); }}
style={{ fontSize: '0.8125rem', padding: '0.2rem 0.5rem', border: '1px solid var(--border-strong)', borderRadius: '6px', background: 'var(--surface)', color: 'var(--text)' }}
>
{(Object.keys(SORT_LABELS) as SortField[]).map((f) => (
<option key={f} value={f}>{SORT_LABELS[f]}</option>
))}
</select>
<button
type="button"
className="link-action"
onClick={() => setSortDir((d) => (d === 'asc' ? 'desc' : 'asc'))}
title={sortDir === 'asc' ? 'Sort ascending' : 'Sort descending'}
style={{ padding: '0.2rem 0.5rem', fontFamily: 'inherit' }}
>
{sortDir === 'asc' ? '↑ Asc' : '↓ Desc'}
</button>
<div style={{ width: '1px', height: '1.25rem', background: 'var(--border)', margin: '0 0.15rem' }} />
</>
)}
<button
type="button"
className={`tab-button${viewStyle === 'list' ? ' is-active' : ''}`}
onClick={() => setViewStyle('list')}
title="List view"
>
List
</button>
<button
type="button"
className={`tab-button${viewStyle === 'cards' ? ' is-active' : ''}`}
onClick={() => setViewStyle('cards')}
title="Card view"
>
Cards
</button>
</div>
</div>
{/* ── List view ── */}
{viewStyle === 'list' && (
<table className="link-table">
<thead>
<tr>
{selectable ? (
<th scope="col" className="col-select">
<input
type="checkbox"
aria-label={allSelected ? 'Deselect all links' : 'Select all links'}
checked={allSelected}
onChange={() => onSelectAll?.(allSelected ? [] : allIds)}
/>
</th>
) : null}
{(['alias', 'linkType', 'targetUrl', 'clickCount', 'updatedAt'] as SortField[]).map((field) => (
<th key={field} scope="col" className="sortable-th" onClick={() => handleSort(field)}>
{field === 'alias' ? 'Alias'
: field === 'linkType' ? 'Type'
: field === 'targetUrl' ? 'Target / content'
: field === 'clickCount' ? 'Clicks'
: 'Updated'}
<SortIndicator field={field} sortField={sortField} sortDir={sortDir} />
</th>
))}
{showActionColumn ? <th scope="col" className="col-actions">Actions</th> : null}
</tr>
</thead>
<tbody>
{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 (
<tr
key={link.id}
className={`link-table-row--clickable${selected ? ' is-selected' : ''}`}
onClick={() => navigateToLinkDetail(link)}
onKeyDown={(event) => handleRowKeyDown(event, link)}
tabIndex={0}
role="link"
aria-label={`Open details for ${link.alias}`}
>
{selectable ? (
<td className="col-select">
<input
type="checkbox"
aria-label={`Select ${link.alias}`}
checked={selected}
onClick={stopRowNavigation}
onChange={() => onToggleSelect?.(link.id)}
/>
</td>
) : null}
<td className="col-alias">
<a
href={shortLinkHref}
target="_blank"
rel="noreferrer noopener"
className="alias-pill"
style={{ textDecoration: 'none' }}
onClick={stopRowNavigation}
>
/{link.alias}
</a>
{link.description ? <small className="row-description">{link.description}</small> : null}
</td>
<td><LinkTypeBadge linkType={link.linkType} /></td>
<td className="col-target">
{link.linkType === 'redirect' ? (
link.targetUrl ? (
safeUrl
? <span className="truncate" title={link.targetUrl}>{link.targetUrl}</span>
: <span className="muted" title="Target URL is not a valid http(s) link">Invalid target</span>
) : <span className="muted"></span>
) : <span className="muted">markdown</span>}
</td>
<td className="col-clicks">{link.clickCount.toLocaleString()}</td>
<td className="col-updated">
<time dateTime={link.updatedAt}>{new Date(link.updatedAt).toLocaleDateString()}</time>
</td>
{showActionColumn ? (
<td className="col-actions">
{actions && onEdit ? (
<button
type="button"
className="link-action"
onClick={(event) => {
event.stopPropagation();
onEdit(link);
}}
>
Edit
</button>
) : null}
{actions && onDelete ? (
<button
type="button"
className="link-action link-action--danger"
onClick={(event) => {
event.stopPropagation();
onDelete(link);
}}
>
Delete
</button>
) : null}
</td>
) : null}
</tr>
);
})}
</tbody>
</table>
)}
{/* ── Cards view ── */}
{viewStyle === 'cards' && (
<div className="link-cards-grid">
{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 (
<div
key={link.id}
className={`link-card link-card--clickable${selected ? ' is-selected' : ''}`}
onClick={() => navigateToLinkDetail(link)}
onKeyDown={(event) => handleRowKeyDown(event, link)}
tabIndex={0}
role="link"
aria-label={`Open details for ${link.alias}`}
>
<div className="link-card-header">
{selectable ? (
<input
type="checkbox"
aria-label={`Select ${link.alias}`}
checked={selected}
onClick={stopRowNavigation}
onChange={() => onToggleSelect?.(link.id)}
/>
) : null}
<a
href={shortLinkHref}
target="_blank"
rel="noreferrer noopener"
className="alias-pill"
style={{ textDecoration: 'none', fontSize: '0.8125rem' }}
onClick={stopRowNavigation}
>
/{link.alias}
</a>
<LinkTypeBadge linkType={link.linkType} className="link-card-type-badge" />
</div>
{link.description ? <p className="link-card-desc">{link.description}</p> : null}
{link.linkType === 'redirect' && link.targetUrl ? (
<p className="link-card-url">
{safeUrl
? <span className="truncate" title={link.targetUrl}>{link.targetUrl}</span>
: <span className="muted truncate">{link.targetUrl}</span>}
</p>
) : null}
<div className="link-card-meta">
<span style={{ fontWeight: 600, color: 'var(--accent-text)' }}>{link.clickCount.toLocaleString()} clicks</span>
<time className="muted" dateTime={link.updatedAt} style={{ fontSize: '0.75rem' }}>
{new Date(link.updatedAt).toLocaleDateString()}
</time>
</div>
{showActionColumn ? (
<div className="link-card-actions">
{actions && onEdit ? (
<button
type="button"
className="link-action"
onClick={(event) => {
event.stopPropagation();
onEdit(link);
}}
>
Edit
</button>
) : null}
{actions && onDelete ? (
<button
type="button"
className="link-action link-action--danger"
onClick={(event) => {
event.stopPropagation();
onDelete(link);
}}
>
Delete
</button>
) : null}
</div>
) : null}
</div>
);
})}
</div>
)}
{/* ── Load more ── */}
{hasMore && (
<div style={{ textAlign: 'center', padding: '1.25rem 0 0.25rem' }}>
<button
type="button"
className="link-action"
onClick={() => setVisibleCount((c) => c + PAGE_SIZE)}
style={{ padding: '0.5rem 2rem', fontSize: '0.875rem' }}
>
Load {Math.min(PAGE_SIZE, remaining)} more
<span className="muted" style={{ marginLeft: '0.4rem', fontWeight: 400 }}>({remaining} remaining)</span>
</button>
</div>
)}
</div>
);
}