refactor: simplify article toolbar to Save to Brain only + tappable title
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:
@@ -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 {
|
||||
if currentEntry.url != nil {
|
||||
Button {
|
||||
Task { await saveToBrain() }
|
||||
} label: {
|
||||
Label(
|
||||
savedToBrain ? "Saved!" : "Save to Brain",
|
||||
systemImage: savedToBrain ? "checkmark.circle" : "brain"
|
||||
)
|
||||
}
|
||||
// Save to Brain
|
||||
if currentEntry.url != nil {
|
||||
Button {
|
||||
Task { await saveToBrain() }
|
||||
} label: {
|
||||
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: " · ")
|
||||
|
||||
// Title is tappable link to original article (if URL exists)
|
||||
let titleHTML: String
|
||||
if let url, !url.isEmpty {
|
||||
let safeURL = url.replacingOccurrences(of: "\"", with: """)
|
||||
titleHTML = """
|
||||
<a href="\(safeURL)">\(safeTitle)<span class="external-icon">↗</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>
|
||||
|
||||
Reference in New Issue
Block a user