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 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}