diff --git a/frontend/src/components/EntriesView.jsx b/frontend/src/components/EntriesView.jsx index ac189af..01c86b3 100644 --- a/frontend/src/components/EntriesView.jsx +++ b/frontend/src/components/EntriesView.jsx @@ -16,7 +16,7 @@ export default function EntriesView({ entries, selectedUids, onRowClick, archive
{pendingCaptures.filter(c => c.archiveId === archiveId).reverse().map(cap => ( - + ))} {entries.map((entry, idx) => ( - - ) +const COLLECTION_MARKERS = [ + 'list=', + '/playlist/', + '/channel/', + 'yt:playlist:', + 'yt:channel:', + 'ytm:playlist:', + 'spotify:playlist:', + 'spotify:album:', +]; + +function isCollectionLocator(locator) { + const normalized = locator.toLowerCase(); + return COLLECTION_MARKERS.some(marker => normalized.includes(marker)); +} + +function truncateLocator(locator) { + return locator.length > 80 ? `${locator.slice(0, 79)}…` : locator; +} + +export default function SkeletonEntryRow({ locator = '' }) { + const locatorText = String(locator); + const isCollection = isCollectionLocator(locatorText); + + return ( +
+
+ ); } diff --git a/frontend/src/components/SkeletonEntryRow.stories.jsx b/frontend/src/components/SkeletonEntryRow.stories.jsx new file mode 100644 index 0000000..8d8e4cb --- /dev/null +++ b/frontend/src/components/SkeletonEntryRow.stories.jsx @@ -0,0 +1,31 @@ +import SkeletonEntryRow from './SkeletonEntryRow'; + +export default { + component: SkeletonEntryRow, + tags: ['autodocs'], + parameters: { layout: 'padded' }, +}; + +function PendingRows({ locators }) { + return ( +
+
+ {locators.map(locator => ( + + ))} +
+
+ ); +} + +export const Examples = { + render: () => ( + + ), +}; diff --git a/frontend/src/components/SkeletonEntryRow.test.jsx b/frontend/src/components/SkeletonEntryRow.test.jsx new file mode 100644 index 0000000..b4a9189 --- /dev/null +++ b/frontend/src/components/SkeletonEntryRow.test.jsx @@ -0,0 +1,43 @@ +import { describe, expect, test } from 'bun:test'; +import { renderToStaticMarkup } from 'react-dom/server'; + +import SkeletonEntryRow from './SkeletonEntryRow'; + +describe('SkeletonEntryRow', () => { + test('renders a compact archiving status row for a locator', () => { + const markup = renderToStaticMarkup( + , + ); + + expect(markup).toContain('in-progress-entry-row__spinner'); + expect(markup).toContain('tweet:1891234567890123456'); + expect(markup).toContain('Archiving…'); + expect(markup).not.toContain('(playlist)'); + }); + + test('labels playlist and channel locators', () => { + const collectionLocators = [ + 'https://www.youtube.com/watch?v=abc123&list=PL123', + 'https://www.youtube.com/playlist/PL123', + 'https://www.youtube.com/channel/UC123', + 'yt:playlist:PL123', + 'yt:channel:UC123', + 'ytm:playlist:PL123', + 'spotify:playlist:123', + 'spotify:album:123', + ]; + + for (const locator of collectionLocators) { + const markup = renderToStaticMarkup(); + expect(markup).toContain('(playlist)'); + } + }); + + test('truncates long locators to 80 displayed characters', () => { + const locator = 'x'.repeat(100); + const markup = renderToStaticMarkup(); + const visibleLocator = markup.match(/in-progress-entry-row__locator"[^>]*>(.*?)<\/span>/)?.[1]; + + expect(visibleLocator).toBe(`${'x'.repeat(79)}…`); + }); +}); diff --git a/frontend/src/styles.css b/frontend/src/styles.css index cd5a3d3..b0cea03 100644 --- a/frontend/src/styles.css +++ b/frontend/src/styles.css @@ -302,10 +302,10 @@ select { border-bottom: 1px solid var(--line-soft); } #entries-body > div > div { padding: 7px 10px; flex-shrink: 0; overflow: hidden; } -/* Skeleton rows (no index class) fall back to nth-child; real rows use explicit classes. */ +/* Pending capture rows (no index class) fall back to nth-child; real rows use explicit classes. */ #entries-body > div:not(.entry-row-outer):nth-child(even) { background: #f2ede5; } #entries-body > div:not(.entry-row-outer):nth-child(odd) { background: var(--paper-3); } -/* Index-based stripes for real entry rows — immune to skeleton sibling count. */ +/* Index-based stripes for real entry rows — immune to pending-capture sibling count. */ #entries-body > .entry-row-outer--light { background: var(--paper-3); } #entries-body > .entry-row-outer--dark { background: #f2ede5; } #entries-body > div.is-selected { @@ -2836,36 +2836,44 @@ google-cast-launcher.video-tv-btn { /* Push content above the fixed AudioBar when it is visible */ body.has-audio-bar { padding-bottom: 56px; } -/* ── Skeleton entry row ─────────────────────────────────────────────────── */ -@keyframes skeleton-shimmer { - 0% { background-position: 200% center; } - 100% { background-position: -200% center; } +/* ── In-progress entry row ──────────────────────────────────────────────── */ +.in-progress-entry-row { + box-sizing: border-box; + min-width: 0; + padding: 7px 22px; + gap: 9px; + color: var(--muted); + white-space: nowrap; } - -.skeleton-cell { - display: inline-block; - border-radius: 3px; - background: linear-gradient(90deg, var(--paper-2) 25%, var(--line-soft) 50%, var(--paper-2) 75%); - background-size: 200% 100%; - animation: skeleton-shimmer 1.8s ease-in-out infinite; -} - -/* Row container — flex row matching #entries-body > div layout */ -.skeleton-row { - display: flex; - align-items: center; - background: var(--paper-3); -} -.skeleton-row > div { - padding: 7px 10px; +.in-progress-entry-row__spinner { + width: 16px; + height: 16px; + border-width: 2px; + color: var(--accent); flex-shrink: 0; - overflow: hidden; } -.skeleton-row .col-added { padding-left: 22px; } -.skeleton-row > div:last-child { padding-right: 22px; } +.in-progress-entry-row__locator { + min-width: 0; + overflow: hidden; + text-overflow: ellipsis; + font-family: ui-monospace, "SF Mono", Menlo, monospace; + font-size: 11.5px; +} +.in-progress-entry-row__status { + display: inline-flex; + align-items: center; + gap: 5px; + flex-shrink: 0; + color: var(--muted-2); + font-size: 11.5px; +} +.in-progress-entry-row__kind { + font-size: 10.5px; + opacity: 0.85; +} @media (pointer: coarse) { - .skeleton-row .col-added { padding-left: 10px; } + .in-progress-entry-row { padding-inline: 10px; } } /* ── Child entry expansion ───────────────────────────────────────────────── */