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.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() }
|
|
||||||
} 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 {
|
if currentEntry.url != nil {
|
||||||
Button {
|
Button {
|
||||||
Task { await saveToBrain() }
|
Task { await saveToBrain() }
|
||||||
} label: {
|
} label: {
|
||||||
Label(
|
Image(systemName: savedToBrain ? "brain.filled.head.profile" : "brain.head.profile")
|
||||||
savedToBrain ? "Saved!" : "Save to Brain",
|
.foregroundStyle(savedToBrain ? Color.emerald : Color.textTertiary)
|
||||||
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: " · ")
|
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 """
|
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>
|
||||||
|
|||||||
Reference in New Issue
Block a user