diff --git a/src/lib/components/ArticleCard.svelte b/src/lib/components/ArticleCard.svelte index dec4543..832d608 100644 --- a/src/lib/components/ArticleCard.svelte +++ b/src/lib/components/ArticleCard.svelte @@ -343,6 +343,15 @@ let hasContent = $derived(Boolean(displayContent)); let sanitizedContent = $derived(sanitizeHtml(displayContent, itemUrl)); + // Estimate read time from content (~200 words/min) + let readTimeMinutes = $derived.by(() => { + const content = displayContent; + if (!content) return 0; + const text = content.replace(/<[^>]*>/g, ''); + const wordCount = text.split(/\s+/).filter(Boolean).length; + return Math.max(1, Math.round(wordCount / 200)); + }); + // Compute favicon URL - for shares of documents, feedUrl may be an AT Protocol URI // which getFaviconUrl can't handle, so fall back to itemUrl let faviconUrl = $derived.by(() => { @@ -514,10 +523,10 @@ {/if} @@ -562,6 +574,22 @@ {/if} + +
+ {#if faviconUrl} + + {/if} + {#if feedTitle} + e.stopPropagation()} + >{feedTitle} + {/if} + {#if readTimeMinutes > 0} + {readTimeMinutes} min + {/if} + {formatRelativeDate(itemPublishedAt)} +
+
{#if isShareMode} @@ -860,6 +888,7 @@ .favicon { width: 16px; height: 16px; + flex-shrink: 0; vertical-align: baseline; margin-right: 0.75rem; } @@ -896,6 +925,17 @@ color: var(--color-text-secondary); } + .article-read-time { + flex-shrink: 0; + font-size: 0.8rem; + color: var(--color-text-secondary); + } + + .article-read-time :global(.icon) { + vertical-align: -2px; + margin-right: 0.15rem; + } + .feed-title-link, .feed-title-label { flex-shrink: 0; @@ -1234,6 +1274,101 @@ } } + /* Mobile meta bar — hidden by default, shown on mobile when article is open */ + .article-meta-mobile { + display: none; + } + + /* Mobile: two-line header — [title] on top, [icon] [feed] [date] below */ + @media (max-width: 600px) { + .article-header { + flex-wrap: wrap; + gap: 0.25rem 0.5rem; + } + + .article-title { + order: 0; + flex-basis: 100%; + } + + .favicon { + order: 1; + margin-right: 0; + align-self: center; + } + + .feed-title-link { + order: 2; + flex: 0 1 auto; + min-width: 0; + font-size: 0.75rem; + max-width: none; + } + + .article-read-time { + order: 3; + display: inline; + font-size: 0.75rem; + } + + .article-read-time::before, + .article-date::before { + content: '·'; + margin-right: 0.35rem; + color: var(--color-text-secondary); + } + + .article-date { + order: 4; + font-size: 0.75rem; + } + + /* When article is open, hide header meta and show mobile meta bar below content */ + .article-item.open .article-header .favicon, + .article-item.open .article-header .feed-title-link, + .article-item.open .article-header .article-date, + .article-item.open .article-header .article-read-time { + display: none; + } + + .article-item.open .article-meta-mobile { + display: flex; + align-items: center; + gap: 0.5rem; + padding: 0.25rem 0; + font-size: 0.75rem; + color: var(--color-text-secondary); + } + + .article-meta-mobile .favicon { + order: unset; + width: 16px; + height: 16px; + flex-shrink: 0; + } + + .article-meta-mobile .feed-title-link { + order: unset; + flex: 0 1 auto; + min-width: 0; + font-size: 0.75rem; + max-width: none; + color: var(--color-text-secondary); + text-decoration: none; + } + + .article-meta-mobile .article-date { + order: unset; + font-size: 0.75rem; + } + + .article-meta-mobile .article-read-time { + order: unset; + display: inline; + font-size: 0.75rem; + } + } + /* Mobile: bigger touch targets */ @media (max-width: 480px) { .article-actions { diff --git a/src/lib/components/PopoverMenu.svelte b/src/lib/components/PopoverMenu.svelte index 6d5b68c..2d25388 100644 --- a/src/lib/components/PopoverMenu.svelte +++ b/src/lib/components/PopoverMenu.svelte @@ -12,11 +12,11 @@ interface Props { items: MenuItem[]; + open?: boolean; } - let { items }: Props = $props(); + let { items, open = $bindable(false) }: Props = $props(); - let isOpen = $state(false); let menuRef: HTMLDivElement | null = $state(null); let buttonRef: HTMLButtonElement | null = $state(null); let menuPosition = $state<{ @@ -32,11 +32,22 @@ const buttonRect = buttonRef.getBoundingClientRect(); const menuRect = menuRef.getBoundingClientRect(); const viewportWidth = window.innerWidth; + const viewportHeight = window.innerHeight; const position: typeof menuPosition = {}; - // Vertical positioning: always below the button (absolute positioning) - position.top = buttonRef.offsetHeight + 4; + // Vertical positioning: prefer below the button, but flip above if it would overflow + const menuHeight = menuRect.height; + const spaceBelow = viewportHeight - buttonRect.bottom - 4; + const spaceAbove = buttonRect.top - 4; + + if (menuHeight > spaceBelow && spaceAbove > spaceBelow) { + // Position above the button + position.bottom = buttonRef.offsetHeight + 4; + } else { + // Position below the button (default) + position.top = buttonRef.offsetHeight + 4; + } // Horizontal positioning: left-align menu with button by default // The menu is absolutely positioned relative to .popover-menu @@ -54,40 +65,43 @@ menuPosition = position; } - function toggle(e: MouseEvent) { - e.stopPropagation(); - isOpen = !isOpen; - if (isOpen) { - // Position after the menu is rendered + // Reposition whenever the menu opens (handles both toggle clicks and external open) + $effect(() => { + if (open && menuRef) { requestAnimationFrame(() => { updateMenuPosition(); }); } + }); + + function toggle(e: MouseEvent) { + e.stopPropagation(); + open = !open; } function handleItemClick(item: MenuItem, e: MouseEvent) { e.stopPropagation(); if (!item.keepOpen) { - isOpen = false; + open = false; } item.onclick(); } function handleClickOutside(e: MouseEvent) { if ( - isOpen && + open && menuRef && buttonRef && !menuRef.contains(e.target as Node) && !buttonRef.contains(e.target as Node) ) { - isOpen = false; + open = false; } } function handleKeydown(e: KeyboardEvent) { - if (isOpen && e.key === 'Escape') { - isOpen = false; + if (open && e.key === 'Escape') { + open = false; buttonRef?.focus(); } } @@ -110,12 +124,12 @@ class="menu-trigger" onclick={toggle} aria-haspopup="true" - aria-expanded={isOpen} + aria-expanded={open} > - {#if isOpen} + {#if open}