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