Update view.

This commit is contained in:
2026-06-20 16:28:59 +10:00
parent cb73e17261
commit 0ab8e4667f
2 changed files with 400 additions and 103 deletions
+288 -99
View File
@@ -1,22 +1,69 @@
import { useEffect, useMemo, useState } 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;
/** When true (private links), render selection checkboxes. */
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;
/** Render action buttons (edit/delete). Defaults to true. */
/** 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>;
}
export default function LinkTable({
links,
loading = false,
@@ -30,114 +77,256 @@ export default function LinkTable({
onDelete,
actions = true,
}: LinkTableProps) {
if (loading) {
return <p className="table-status" aria-busy="true">Loading links</p>;
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 (error) {
return <p className="table-error" role="alert">{error}</p>;
}
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>;
if (links.length === 0) {
return <p className="table-empty">{emptyMessage}</p>;
}
const allIds = links.map((link) => link.id);
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;
return (
<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}
<th scope="col">Alias</th>
<th scope="col">Type</th>
<th scope="col">Target / content</th>
<th scope="col">Clicks</th>
<th scope="col">Updated</th>
<th scope="col" className="col-actions">Actions</th>
</tr>
</thead>
<tbody>
{links.map((link) => {
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}>
<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 ? (
<td className="col-select">
<th scope="col" className="col-select">
<input
type="checkbox"
aria-label={`Select ${link.alias}`}
checked={selected}
onChange={() => onToggleSelect?.(link.id)}
aria-label={allSelected ? 'Deselect all links' : 'Select all links'}
checked={allSelected}
onChange={() => onSelectAll?.(allSelected ? [] : allIds)}
/>
</td>
</th>
) : null}
<td className="col-alias">
<a
href={link.linkType === 'redirect' ? (safeUrl ?? `/${link.alias}`) : `/${link.alias}`}
target="_blank"
rel="noreferrer noopener"
className="alias-pill"
style={{ textDecoration: 'none' }}
>
#{link.alias}
</a>
{link.description ? <small className="row-description">{link.description}</small> : null}
</td>
<td>{link.linkType === 'custom' ? 'Custom' : 'Redirect'}</td>
<td className="col-target">
{link.linkType === 'redirect' ? (
link.targetUrl ? (
safeUrl ? (
<a href={safeUrl} target="_blank" rel="noreferrer noopener" className="truncate">
{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>
)}
</td>
<td className="col-clicks">{link.clickCount}</td>
<td className="col-updated">
<time dateTime={link.updatedAt}>{new Date(link.updatedAt).toLocaleString()}</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>
{(['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>
))}
<th scope="col" className="col-actions">Actions</th>
</tr>
);
})}
</tbody>
</table>
</thead>
<tbody>
{visible.map((link) => {
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}>
{selectable ? (
<td className="col-select">
<input
type="checkbox"
aria-label={`Select ${link.alias}`}
checked={selected}
onChange={() => onToggleSelect?.(link.id)}
/>
</td>
) : null}
<td className="col-alias">
<a
href={link.linkType === 'redirect' ? (safeUrl ?? `/${link.alias}`) : `/${link.alias}`}
target="_blank"
rel="noreferrer noopener"
className="alias-pill"
style={{ textDecoration: 'none' }}
>
#{link.alias}
</a>
{link.description ? <small className="row-description">{link.description}</small> : null}
</td>
<td>{link.linkType === 'custom' ? 'Custom' : 'Redirect'}</td>
<td className="col-target">
{link.linkType === 'redirect' ? (
link.targetUrl ? (
safeUrl
? <a href={safeUrl} target="_blank" rel="noreferrer noopener" className="truncate">{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>}
</td>
<td className="col-clicks">{link.clickCount.toLocaleString()}</td>
<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>
</tr>
);
})}
</tbody>
</table>
)}
{/* ── Cards view ── */}
{viewStyle === 'cards' && (
<div className="link-cards-grid">
{visible.map((link) => {
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 className="link-card-header">
{selectable ? (
<input
type="checkbox"
aria-label={`Select ${link.alias}`}
checked={selected}
onChange={() => onToggleSelect?.(link.id)}
/>
) : null}
<a
href={link.linkType === 'redirect' ? (safeUrl ?? `/${link.alias}`) : `/${link.alias}`}
target="_blank"
rel="noreferrer noopener"
className="alias-pill"
style={{ textDecoration: 'none', fontSize: '0.8125rem' }}
>
#{link.alias}
</a>
<span className="link-card-type-badge">{link.linkType === 'custom' ? 'Custom' : 'Redirect'}</span>
</div>
{link.description ? <p className="link-card-desc">{link.description}</p> : null}
{link.linkType === 'redirect' && link.targetUrl ? (
<p className="link-card-url">
{safeUrl
? <a href={safeUrl} target="_blank" rel="noreferrer noopener" className="truncate">{link.targetUrl}</a>
: <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>
<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>
</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>
);
}
+112 -4
View File
@@ -175,7 +175,7 @@ code {
border: 1px solid var(--border);
border-radius: var(--radius);
box-shadow: var(--shadow-card);
max-width: 960px;
max-width: 1100px;
padding: 1.75rem 2rem;
width: 100%;
}
@@ -450,7 +450,28 @@ button.link-action--confirm:hover { background: var(--accent-soft); }
/* ═══════════════════════════════════════════
Tables
═══════════════════════════════════════════ */
═══════════════════════════════════════════ */
/* Toolbar above table/cards */
.link-table-toolbar {
display: flex;
align-items: center;
justify-content: space-between;
margin-bottom: 0.75rem;
flex-wrap: wrap;
gap: 0.5rem;
}
/* Sortable column headers */
.sortable-th {
cursor: pointer;
user-select: none;
transition: color 0.1s;
}
.sortable-th:hover { color: var(--text); background: var(--background); }
.sort-icon { margin-left: 0.2rem; font-size: 0.8em; }
.sort-icon--idle { opacity: 0.3; }
.link-table {
border-collapse: separate;
@@ -544,8 +565,95 @@ button.link-action--confirm:hover { background: var(--accent-soft); }
.selection-bar > span { font-weight: 600; }
/* ═══════════════════════════════════════════
Dialog
═══════════════════════════════════════════ */
Cards view
═══════════════════════════════════════════ */
.link-cards-grid {
display: grid;
grid-template-columns: repeat(4, minmax(0, 1fr));
gap: 0.75rem;
}
@media (max-width: 1200px) { .link-cards-grid { grid-template-columns: repeat(3, minmax(0, 1fr)); } }
@media (max-width: 800px) { .link-cards-grid { grid-template-columns: repeat(2, minmax(0, 1fr)); } }
@media (max-width: 480px) { .link-cards-grid { grid-template-columns: 1fr; } }
.link-card {
background: var(--surface);
border: 1px solid var(--border);
border-radius: var(--radius-sm);
display: flex;
flex-direction: column;
gap: 0.375rem;
min-width: 0;
padding: 0.75rem;
transition: border-color 0.15s, box-shadow 0.15s;
}
.link-card:hover {
border-color: #C7D2FE;
box-shadow: 0 4px 16px rgba(99,102,241,0.08);
}
.link-card.is-selected { background: var(--accent-soft); border-color: #A5B4FC; }
.link-card-header {
align-items: center;
display: flex;
flex-wrap: wrap;
gap: 0.4rem;
min-width: 0;
}
.link-card-type-badge {
color: var(--muted);
font-size: 0.7rem;
font-weight: 600;
letter-spacing: 0.04em;
margin-left: auto;
text-transform: uppercase;
}
.link-card-desc {
-webkit-box-orient: vertical;
-webkit-line-clamp: 2;
color: var(--muted);
display: -webkit-box;
font-size: 0.75rem;
line-height: 1.4;
margin: 0;
overflow: hidden;
}
.link-card-url {
font-size: 0.72rem;
margin: 0;
max-width: 100%;
min-width: 0;
overflow: hidden;
text-overflow: ellipsis;
white-space: nowrap;
}
.link-card-url .truncate {
display: block;
max-width: 100%;
}
.link-card-meta {
align-items: center;
border-top: 1px solid var(--border);
display: flex;
font-size: 0.72rem;
justify-content: space-between;
margin-top: auto;
padding-top: 0.375rem;
}
.link-card-actions {
display: flex;
flex-wrap: wrap;
gap: 0.35rem;
}
.dialog-backdrop {
align-items: center;