Update UI

This commit is contained in:
2026-06-20 16:55:29 +10:00
parent 0ab8e4667f
commit 87357d5297
2 changed files with 114 additions and 16 deletions
+102 -16
View File
@@ -1,4 +1,4 @@
import { useEffect, useMemo, useState } from 'react';
import { useEffect, useMemo, useState, type KeyboardEvent as ReactKeyboardEvent, type SyntheticEvent } from 'react';
import type { Link } from '../lib/api';
import { safeLinkTargetUrl } from '../lib/url';
@@ -64,6 +64,25 @@ function SortIndicator({ field, sortField, sortDir }: { field: SortField; sortFi
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);
}
}
export default function LinkTable({
links,
loading = false,
@@ -125,6 +144,7 @@ export default function LinkTable({
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>
@@ -203,7 +223,7 @@ export default function LinkTable({
<SortIndicator field={field} sortField={sortField} sortDir={sortDir} />
</th>
))}
<th scope="col" className="col-actions">Actions</th>
{showActionColumn ? <th scope="col" className="col-actions">Actions</th> : null}
</tr>
</thead>
<tbody>
@@ -211,13 +231,22 @@ export default function LinkTable({
const selected = selectable && selectedIds?.has(link.id) === true;
const safeUrl = link.linkType === 'redirect' ? safeLinkTargetUrl(link.targetUrl) : null;
return (
<tr key={link.id} className={selected ? 'is-selected' : undefined}>
<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>
@@ -229,6 +258,7 @@ export default function LinkTable({
rel="noreferrer noopener"
className="alias-pill"
style={{ textDecoration: 'none' }}
onClick={stopRowNavigation}
>
#{link.alias}
</a>
@@ -239,7 +269,7 @@ export default function LinkTable({
{link.linkType === 'redirect' ? (
link.targetUrl ? (
safeUrl
? <a href={safeUrl} target="_blank" rel="noreferrer noopener" className="truncate">{link.targetUrl}</a>
? <a href={safeUrl} target="_blank" rel="noreferrer noopener" className="truncate" onClick={stopRowNavigation}>{link.targetUrl}</a>
: <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>}
@@ -248,11 +278,34 @@ export default function LinkTable({
<td className="col-updated">
<time dateTime={link.updatedAt}>{new Date(link.updatedAt).toLocaleDateString()}</time>
</td>
<td className="col-actions">
{actions && onEdit ? <button type="button" className="link-action" onClick={() => onEdit(link)}>Edit</button> : null}
<button type="button" className="link-action" onClick={() => { window.location.hash = `/links/${link.scope}/${encodeURIComponent(link.id)}`; }}>View</button>
{actions && onDelete ? <button type="button" className="link-action link-action--danger" onClick={() => onDelete(link)}>Delete</button> : null}
</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>
);
})}
@@ -267,13 +320,22 @@ export default function LinkTable({
const selected = selectable && selectedIds?.has(link.id) === true;
const safeUrl = link.linkType === 'redirect' ? safeLinkTargetUrl(link.targetUrl) : null;
return (
<div key={link.id} className={`link-card${selected ? ' is-selected' : ''}`}>
<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}
@@ -283,6 +345,7 @@ export default function LinkTable({
rel="noreferrer noopener"
className="alias-pill"
style={{ textDecoration: 'none', fontSize: '0.8125rem' }}
onClick={stopRowNavigation}
>
#{link.alias}
</a>
@@ -292,7 +355,7 @@ export default function LinkTable({
{link.linkType === 'redirect' && link.targetUrl ? (
<p className="link-card-url">
{safeUrl
? <a href={safeUrl} target="_blank" rel="noreferrer noopener" className="truncate">{link.targetUrl}</a>
? <a href={safeUrl} target="_blank" rel="noreferrer noopener" className="truncate" onClick={stopRowNavigation}>{link.targetUrl}</a>
: <span className="muted truncate">{link.targetUrl}</span>}
</p>
) : null}
@@ -302,11 +365,34 @@ export default function LinkTable({
{new Date(link.updatedAt).toLocaleDateString()}
</time>
</div>
<div className="link-card-actions">
{actions && onEdit ? <button type="button" className="link-action" onClick={() => onEdit(link)}>Edit</button> : null}
<button type="button" className="link-action" onClick={() => { window.location.hash = `/links/${link.scope}/${encodeURIComponent(link.id)}`; }}>View</button>
{actions && onDelete ? <button type="button" className="link-action link-action--danger" onClick={() => onDelete(link)}>Delete</button> : null}
</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>
);
})}
+12
View File
@@ -509,6 +509,12 @@ button.link-action--confirm:hover { background: var(--accent-soft); }
transition: background 0.1s;
}
.link-table tbody tr:hover { background: #F8FAFF; }
.link-table-row--clickable { cursor: pointer; }
.link-table-row--clickable:focus-visible {
background: #F8FAFF;
outline: 2px solid #818CF8;
outline-offset: -2px;
}
.link-table tbody tr:last-child td { border-bottom: none; }
.link-table tr.is-selected { background: var(--accent-soft); }
.link-table tr.is-selected:hover { background: #E0E7FF; }
@@ -588,10 +594,16 @@ button.link-action--confirm:hover { background: var(--accent-soft); }
padding: 0.75rem;
transition: border-color 0.15s, box-shadow 0.15s;
}
.link-card--clickable { cursor: pointer; }
.link-card:hover {
border-color: #C7D2FE;
box-shadow: 0 4px 16px rgba(99,102,241,0.08);
}
.link-card--clickable:focus-visible {
border-color: #818CF8;
box-shadow: 0 0 0 3px rgba(99,102,241,0.18), 0 4px 16px rgba(99,102,241,0.08);
outline: none;
}
.link-card.is-selected { background: var(--accent-soft); border-color: #A5B4FC; }
.link-card-header {