mirror of
https://github.com/wahyd4/heygo.git
synced 2026-08-08 21:05:48 +10:00
Update view.
This commit is contained in:
+288
-99
@@ -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
@@ -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;
|
||||
|
||||
Reference in New Issue
Block a user