diff --git a/src/components/LinkTable.tsx b/src/components/LinkTable.tsx index 19a0c8d..c657587 100644 --- a/src/components/LinkTable.tsx +++ b/src/components/LinkTable.tsx @@ -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 = { + 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; 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 ; + return {sortDir === 'asc' ? '↑' : '↓'}; +} + export default function LinkTable({ links, loading = false, @@ -30,114 +77,256 @@ export default function LinkTable({ onDelete, actions = true, }: LinkTableProps) { - if (loading) { - return

Loading links…

; + 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 (error) { - return

{error}

; - } + if (loading) return

Loading links…

; + if (error) return

{error}

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

{emptyMessage}

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

{emptyMessage}

; - } - - 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 ( - - - - {selectable ? ( - - ) : null} - - - - - - - - - - {links.map((link) => { - const selected = selectable && selectedIds?.has(link.id) === true; - const safeUrl = link.linkType === 'redirect' ? safeLinkTargetUrl(link.targetUrl) : null; - return ( - +
+ {/* Toolbar */} +
+ + {links.length} link{links.length !== 1 ? 's' : ''} + {visibleCount < links.length ? ` · showing ${visibleCount}` : ''} + +
+ {viewStyle === 'cards' && ( + <> + + + +
+ + )} + + +
+
+ + {/* ── List view ── */} + {viewStyle === 'list' && ( +
- onSelectAll?.(allSelected ? [] : allIds)} - /> - AliasTypeTarget / contentClicksUpdatedActions
+ + {selectable ? ( - ) : null} - - - - - - + {(['alias', 'linkType', 'targetUrl', 'clickCount', 'updatedAt'] as SortField[]).map((field) => ( + + ))} + - ); - })} - -
+ onToggleSelect?.(link.id)} + aria-label={allSelected ? 'Deselect all links' : 'Select all links'} + checked={allSelected} + onChange={() => onSelectAll?.(allSelected ? [] : allIds)} /> - + - - #{link.alias} - - {link.description ? {link.description} : null} - {link.linkType === 'custom' ? 'Custom' : 'Redirect'} - {link.linkType === 'redirect' ? ( - link.targetUrl ? ( - safeUrl ? ( - - {link.targetUrl} - - ) : ( - Invalid target - ) - ) : ( - - ) - ) : ( - markdown - )} - {link.clickCount} - - - {actions && onEdit ? ( - - ) : null} - - {actions && onDelete ? ( - - ) : null} - handleSort(field)}> + {field === 'alias' ? 'Alias' + : field === 'linkType' ? 'Type' + : field === 'targetUrl' ? 'Target / content' + : field === 'clickCount' ? 'Clicks' + : 'Updated'} + + Actions
+ + + {visible.map((link) => { + const selected = selectable && selectedIds?.has(link.id) === true; + const safeUrl = link.linkType === 'redirect' ? safeLinkTargetUrl(link.targetUrl) : null; + return ( + + {selectable ? ( + + onToggleSelect?.(link.id)} + /> + + ) : null} + + + #{link.alias} + + {link.description ? {link.description} : null} + + {link.linkType === 'custom' ? 'Custom' : 'Redirect'} + + {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 safeUrl = link.linkType === 'redirect' ? safeLinkTargetUrl(link.targetUrl) : null; + return ( +
+
+ {selectable ? ( + onToggleSelect?.(link.id)} + /> + ) : null} + + #{link.alias} + + {link.linkType === 'custom' ? 'Custom' : 'Redirect'} +
+ {link.description ?

{link.description}

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

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

+ ) : null} +
+ {link.clickCount.toLocaleString()} clicks + +
+
+ {actions && onEdit ? : null} + + {actions && onDelete ? : null} +
+
+ ); + })} +
+ )} + + {/* ── Load more ── */} + {hasMore && ( +
+ +
+ )} + ); } diff --git a/src/styles.css b/src/styles.css index ad110c6..4f77b10 100644 --- a/src/styles.css +++ b/src/styles.css @@ -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;