refactor: simplify article toolbar to Save to Brain only + tappable title
All checks were successful
Security Checks / dependency-audit (push) Successful in 14s
Security Checks / secret-scanning (push) Successful in 5s
Security Checks / dockerfile-lint (push) Successful in 4s

Toolbar:
- Removed star, read/unread, and ellipsis menu
- Single "Save to Brain" button (brain icon, turns green when saved)

Open original article:
- Title in HTML header is now a tappable link (when URL exists)
- Subtle ↗ icon after title indicates external link
- Tap opens in Safari via existing WKNavigationDelegate link handler
- No accidental triggers: styled as text link, not a button
- Active state dims to 0.6 opacity for tap feedback
- Dark mode: title link inherits text color (not accent)

No floating buttons added. No architecture changes.

Co-Authored-By: Claude Opus 4.6 (1M context) <noreply@anthropic.com>
This commit is contained in:
Yusuf Suleman
2026-04-03 22:01:16 -05:00
parent 5e13f92a00
commit 415b125fb7

View File

@@ -12,14 +12,12 @@ struct ArticleView: View {
self.entry = entry
self.vm = vm
_currentEntry = State(initialValue: entry)
// Initialize with whatever content we already have (from slim list or cache)
_articleContent = State(initialValue: entry.articleHTML)
}
var body: some View {
Group {
if articleContent.isEmpty {
// Only show spinner if we truly have no content at all
VStack {
Spacer()
ProgressView()
@@ -34,6 +32,7 @@ struct ArticleView: View {
} else {
ArticleWebView(html: ArticleHTMLBuilder.build(
title: currentEntry.displayTitle,
url: currentEntry.url,
feedName: currentEntry.feedName,
author: currentEntry.author,
timeAgo: currentEntry.timeAgo,
@@ -48,71 +47,32 @@ struct ArticleView: View {
.navigationBarTitleDisplayMode(.inline)
.toolbar {
ToolbarItemGroup(placement: .topBarTrailing) {
Button {
Task { await toggleStar() }
} label: {
Image(systemName: currentEntry.starred ? "star.fill" : "star")
.foregroundStyle(currentEntry.starred ? .orange : Color.textTertiary)
}
Button {
Task { await toggleRead() }
} label: {
Image(systemName: currentEntry.isRead ? "envelope.open" : "envelope.badge")
.foregroundStyle(Color.textTertiary)
}
Menu {
// Save to Brain
if currentEntry.url != nil {
Button {
Task { await saveToBrain() }
} label: {
Label(
savedToBrain ? "Saved!" : "Save to Brain",
systemImage: savedToBrain ? "checkmark.circle" : "brain"
)
Image(systemName: savedToBrain ? "brain.filled.head.profile" : "brain.head.profile")
.foregroundStyle(savedToBrain ? Color.emerald : Color.textTertiary)
}
}
if currentEntry.fullContent == nil {
Button {
Task { await fetchFull() }
} label: {
Label("Fetch Full Article", systemImage: "arrow.down.doc")
}
}
if let url = currentEntry.url, let link = URL(string: url) {
ShareLink(item: link) {
Label("Share", systemImage: "square.and.arrow.up")
}
}
} label: {
Image(systemName: "ellipsis.circle")
.foregroundStyle(Color.textTertiary)
}
}
}
.task {
// 1. Mark as read fire-and-forget, don't block UI
Task { await vm.markAsRead(entry) }
// 2. Sync toolbar state
if let updated = vm.entries.first(where: { $0.id == entry.id }) {
currentEntry = updated
}
// 3. Fetch full content in background update when ready
do {
let fullEntry = try await ReaderAPI().getEntry(id: entry.id)
let fullHTML = fullEntry.articleHTML
// Only update if we got better content
if !fullHTML.isEmpty && fullHTML.count > articleContent.count {
articleContent = fullHTML
}
// Preserve local status/starred
var merged = fullEntry
if let local = vm.entries.first(where: { $0.id == entry.id }) {
merged.status = local.status
@@ -125,35 +85,6 @@ struct ArticleView: View {
}
}
private func toggleStar() async {
await vm.toggleStar(currentEntry)
if let updated = vm.entries.first(where: { $0.id == currentEntry.id }) {
currentEntry = updated
}
}
private func toggleRead() async {
await vm.toggleRead(currentEntry)
if let updated = vm.entries.first(where: { $0.id == currentEntry.id }) {
currentEntry = updated
}
}
private func fetchFull() async {
isFetchingFull = true
do {
let updated = try await ReaderAPI().fetchFullContent(entryId: currentEntry.id)
articleContent = updated.articleHTML
var merged = updated
if let local = vm.entries.first(where: { $0.id == updated.id }) {
merged.status = local.status
merged.starred = local.starred
}
currentEntry = merged
} catch {}
isFetchingFull = false
}
private func saveToBrain() async {
let success = await vm.saveToBrain(currentEntry)
if success {
@@ -162,10 +93,9 @@ struct ArticleView: View {
}
}
// MARK: - HTML Builder (stateless, reusable CSS template)
// MARK: - HTML Builder
enum ArticleHTMLBuilder {
// Pre-built CSS avoids rebuilding the same string every article open
private static let css = """
* { box-sizing: border-box; }
body {
@@ -185,6 +115,7 @@ enum ArticleHTMLBuilder {
blockquote { border-left-color: #c79e40; color: #9a9590; }
td, th { border-color: #333; }
.article-meta { color: #8a8580; }
.article-title a { color: #ede8e1 !important; }
}
.article-header {
padding: 16px 0 12px;
@@ -195,6 +126,20 @@ enum ArticleHTMLBuilder {
font-size: 24px; font-weight: 700;
line-height: 1.25; margin: 0 0 8px;
}
.article-title a {
color: inherit;
text-decoration: none;
-webkit-tap-highlight-color: rgba(139,105,20,0.15);
}
.article-title a:active {
opacity: 0.6;
}
.external-icon {
font-size: 16px;
opacity: 0.4;
vertical-align: super;
margin-left: 4px;
}
.article-meta {
font-size: 13px; color: #8B6914; line-height: 1.4;
}
@@ -213,6 +158,7 @@ enum ArticleHTMLBuilder {
static func build(
title: String,
url: String?,
feedName: String,
author: String?,
timeAgo: String,
@@ -231,6 +177,17 @@ enum ArticleHTMLBuilder {
metaParts.append("\(readingTime) read")
let meta = metaParts.joined(separator: " &middot; ")
// Title is tappable link to original article (if URL exists)
let titleHTML: String
if let url, !url.isEmpty {
let safeURL = url.replacingOccurrences(of: "\"", with: "&quot;")
titleHTML = """
<a href="\(safeURL)">\(safeTitle)<span class="external-icon">&nearr;</span></a>
"""
} else {
titleHTML = safeTitle
}
return """
<!DOCTYPE html>
<html>
@@ -241,7 +198,7 @@ enum ArticleHTMLBuilder {
</head>
<body>
<div class="article-header">
<h1 class="article-title">\(safeTitle)</h1>
<h1 class="article-title">\(titleHTML)</h1>
<div class="article-meta">\(meta)</div>
</div>
<div id="article-body">\(body)</div>