mirror of
https://github.com/wahyd4/heygo.git
synced 2026-08-08 21:05:48 +10:00
434 lines
17 KiB
TypeScript
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>
|
|
);
|
|
}
|