Skip to content
Merged
Show file tree
Hide file tree
Changes from all commits
Commits
File filter

Filter by extension

Filter by extension

Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
141 changes: 138 additions & 3 deletions src/lib/components/ArticleCard.svelte
Original file line number Diff line number Diff line change
Expand Up @@ -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(() => {
Expand Down Expand Up @@ -514,10 +523,10 @@
</div>
{/if}
<button class="article-header" onclick={handleHeaderClick}>
{#if faviconUrl}
<img src={faviconUrl} alt="" class="favicon" />
{/if}
<span class="article-title">
{#if faviconUrl}
<img src={faviconUrl} alt="" class="favicon" />
{/if}
{#if isOpen}
<a
href={itemUrl}
Expand All @@ -539,6 +548,9 @@
<span class="feed-title-label">{displayFeedTitle}</span>
{/if}
{/if}
{#if readTimeMinutes > 0}
<span class="article-read-time"><Icon name="clock" size={12} /> {readTimeMinutes} min</span>
{/if}
<span class="article-date">{formatRelativeDate(itemPublishedAt)}</span>
</button>
</div>
Expand All @@ -562,6 +574,22 @@
{/if}
</div>

<!-- Mobile: meta line below content -->
<div class="article-meta-mobile">
{#if faviconUrl}
<img src={faviconUrl} alt="" class="favicon" />
{/if}
{#if feedTitle}
<a href="/?feed={feedId}" class="feed-title-link" onclick={(e) => e.stopPropagation()}
>{feedTitle}</a
>
{/if}
{#if readTimeMinutes > 0}
<span class="article-read-time"><Icon name="clock" size={12} /> {readTimeMinutes} min</span>
{/if}
<span class="article-date">{formatRelativeDate(itemPublishedAt)}</span>
</div>

<div class="article-actions-container">
<div class="article-actions">
{#if isShareMode}
Expand Down Expand Up @@ -860,6 +888,7 @@
.favicon {
width: 16px;
height: 16px;
flex-shrink: 0;
vertical-align: baseline;
margin-right: 0.75rem;
}
Expand Down Expand Up @@ -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;
Expand Down Expand Up @@ -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 {
Expand Down
46 changes: 30 additions & 16 deletions src/lib/components/PopoverMenu.svelte
Original file line number Diff line number Diff line change
Expand Up @@ -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<{
Expand All @@ -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
Expand All @@ -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();
}
}
Expand All @@ -110,12 +124,12 @@
class="menu-trigger"
onclick={toggle}
aria-haspopup="true"
aria-expanded={isOpen}
aria-expanded={open}
>
<span class="dots">⋯</span>
</button>

{#if isOpen}
{#if open}
<div
bind:this={menuRef}
class="menu-dropdown"
Expand Down
Loading