feat: card/list view toggle for Reader with thumbnails
- Card view: large thumbnail from article content, feed name, title, author, reading time - List view: compact rows with mini thumbnail on right - Toggle button in toolbar (grid/list icon) - Thumbnail extracted from first <img> in article HTML - Card view is default, warm Atelier styling preserved Co-Authored-By: Claude Opus 4.6 (1M context) <noreply@anthropic.com>
This commit is contained in:
@@ -48,6 +48,24 @@ struct ReaderEntry: Codable, Identifiable, Hashable {
|
||||
fullContent ?? content ?? ""
|
||||
}
|
||||
|
||||
var thumbnailURL: URL? {
|
||||
// Extract first <img src="..."> from content
|
||||
let html = content ?? fullContent ?? ""
|
||||
guard let range = html.range(of: #"<img[^>]+src=["\']([^"\']+)["\']"#, options: .regularExpression) else {
|
||||
return nil
|
||||
}
|
||||
let match = String(html[range])
|
||||
guard let srcRange = match.range(of: #"src=["\']([^"\']+)["\']"#, options: .regularExpression) else {
|
||||
return nil
|
||||
}
|
||||
var src = String(match[srcRange])
|
||||
src = src.replacingOccurrences(of: "src=\"", with: "")
|
||||
.replacingOccurrences(of: "src='", with: "")
|
||||
.replacingOccurrences(of: "\"", with: "")
|
||||
.replacingOccurrences(of: "'", with: "")
|
||||
return URL(string: src)
|
||||
}
|
||||
|
||||
private static let isoFormatter: ISO8601DateFormatter = {
|
||||
let f = ISO8601DateFormatter()
|
||||
f.formatOptions = [.withInternetDateTime, .withFractionalSeconds]
|
||||
|
||||
@@ -2,6 +2,7 @@ import SwiftUI
|
||||
|
||||
struct EntryListView: View {
|
||||
@Bindable var vm: ReaderViewModel
|
||||
var isCardView: Bool = true
|
||||
|
||||
var body: some View {
|
||||
if vm.isLoading && vm.entries.isEmpty {
|
||||
@@ -16,30 +17,10 @@ struct EntryListView: View {
|
||||
)
|
||||
} else {
|
||||
ScrollView {
|
||||
LazyVStack(spacing: 0) {
|
||||
ForEach(vm.entries) { entry in
|
||||
NavigationLink(value: entry) {
|
||||
EntryRowView(entry: entry, vm: vm)
|
||||
}
|
||||
.buttonStyle(.plain)
|
||||
|
||||
Divider()
|
||||
.padding(.leading, 16)
|
||||
}
|
||||
|
||||
// Infinite scroll trigger
|
||||
if vm.isLoadingMore {
|
||||
ProgressView()
|
||||
.padding()
|
||||
if isCardView {
|
||||
cardLayout
|
||||
} else {
|
||||
Color.clear
|
||||
.frame(height: 1)
|
||||
.onAppear {
|
||||
Task { await vm.loadMore() }
|
||||
}
|
||||
}
|
||||
|
||||
Spacer(minLength: 80)
|
||||
listLayout
|
||||
}
|
||||
}
|
||||
.refreshable {
|
||||
@@ -50,9 +31,170 @@ struct EntryListView: View {
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
// MARK: - Card Layout
|
||||
|
||||
private var cardLayout: some View {
|
||||
LazyVStack(spacing: 12) {
|
||||
ForEach(vm.entries) { entry in
|
||||
NavigationLink(value: entry) {
|
||||
EntryCardView(entry: entry, vm: vm)
|
||||
}
|
||||
.buttonStyle(.plain)
|
||||
}
|
||||
|
||||
loadMoreTrigger
|
||||
|
||||
Spacer(minLength: 80)
|
||||
}
|
||||
.padding(.horizontal)
|
||||
.padding(.top, 4)
|
||||
}
|
||||
|
||||
// MARK: - List Layout
|
||||
|
||||
private var listLayout: some View {
|
||||
LazyVStack(spacing: 0) {
|
||||
ForEach(vm.entries) { entry in
|
||||
NavigationLink(value: entry) {
|
||||
EntryRowView(entry: entry, vm: vm)
|
||||
}
|
||||
.buttonStyle(.plain)
|
||||
|
||||
Divider()
|
||||
.padding(.leading, 36)
|
||||
}
|
||||
|
||||
loadMoreTrigger
|
||||
|
||||
Spacer(minLength: 80)
|
||||
}
|
||||
}
|
||||
|
||||
private var loadMoreTrigger: some View {
|
||||
Group {
|
||||
if vm.isLoadingMore {
|
||||
ProgressView()
|
||||
.padding()
|
||||
} else {
|
||||
Color.clear
|
||||
.frame(height: 1)
|
||||
.onAppear {
|
||||
Task { await vm.loadMore() }
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
// MARK: - Entry Row
|
||||
// MARK: - Card View
|
||||
|
||||
struct EntryCardView: View {
|
||||
let entry: ReaderEntry
|
||||
let vm: ReaderViewModel
|
||||
|
||||
var body: some View {
|
||||
VStack(alignment: .leading, spacing: 0) {
|
||||
// Thumbnail
|
||||
if let thumbURL = entry.thumbnailURL {
|
||||
AsyncImage(url: thumbURL) { phase in
|
||||
switch phase {
|
||||
case .success(let image):
|
||||
image
|
||||
.resizable()
|
||||
.aspectRatio(contentMode: .fill)
|
||||
.frame(height: 180)
|
||||
.clipped()
|
||||
case .failure:
|
||||
cardPlaceholder
|
||||
case .empty:
|
||||
ProgressView()
|
||||
.frame(maxWidth: .infinity)
|
||||
.frame(height: 180)
|
||||
.background(Color.accentWarm.opacity(0.06))
|
||||
@unknown default:
|
||||
cardPlaceholder
|
||||
}
|
||||
}
|
||||
} else {
|
||||
cardPlaceholder
|
||||
}
|
||||
|
||||
// Content
|
||||
VStack(alignment: .leading, spacing: 8) {
|
||||
// Feed + time
|
||||
HStack(spacing: 6) {
|
||||
if !entry.isRead {
|
||||
Circle()
|
||||
.fill(Color.accentWarm)
|
||||
.frame(width: 6, height: 6)
|
||||
}
|
||||
|
||||
Text(entry.feedName)
|
||||
.font(.caption.weight(.semibold))
|
||||
.foregroundStyle(Color.accentWarm)
|
||||
.lineLimit(1)
|
||||
|
||||
Spacer()
|
||||
|
||||
if !entry.timeAgo.isEmpty {
|
||||
Text(entry.timeAgo)
|
||||
.font(.caption)
|
||||
.foregroundStyle(Color.textTertiary)
|
||||
}
|
||||
}
|
||||
|
||||
// Title
|
||||
Text(entry.displayTitle)
|
||||
.font(.subheadline.weight(entry.isRead ? .medium : .bold))
|
||||
.foregroundStyle(entry.isRead ? Color.textSecondary : Color.textPrimary)
|
||||
.lineLimit(3)
|
||||
|
||||
// Bottom row
|
||||
HStack(spacing: 8) {
|
||||
if let author = entry.author, !author.isEmpty {
|
||||
Text(author)
|
||||
.font(.caption)
|
||||
.foregroundStyle(Color.textTertiary)
|
||||
.lineLimit(1)
|
||||
}
|
||||
|
||||
Spacer()
|
||||
|
||||
Label(entry.readingTimeText, systemImage: "clock")
|
||||
.font(.caption)
|
||||
.foregroundStyle(Color.textTertiary)
|
||||
|
||||
if entry.starred {
|
||||
Image(systemName: "star.fill")
|
||||
.font(.caption)
|
||||
.foregroundStyle(.orange)
|
||||
}
|
||||
}
|
||||
}
|
||||
.padding(14)
|
||||
}
|
||||
.background(Color.surfaceCard)
|
||||
.clipShape(RoundedRectangle(cornerRadius: 14))
|
||||
.shadow(color: .black.opacity(0.04), radius: 6, y: 2)
|
||||
.contextMenu {
|
||||
entryContextMenu(entry: entry, vm: vm)
|
||||
}
|
||||
}
|
||||
|
||||
private var cardPlaceholder: some View {
|
||||
HStack(spacing: 8) {
|
||||
Image(systemName: "newspaper.fill")
|
||||
.font(.title2)
|
||||
.foregroundStyle(Color.accentWarm.opacity(0.3))
|
||||
}
|
||||
.frame(maxWidth: .infinity)
|
||||
.frame(height: 80)
|
||||
.background(Color.accentWarm.opacity(0.06))
|
||||
}
|
||||
}
|
||||
|
||||
// MARK: - List Row View
|
||||
|
||||
struct EntryRowView: View {
|
||||
let entry: ReaderEntry
|
||||
@@ -60,7 +202,6 @@ struct EntryRowView: View {
|
||||
|
||||
var body: some View {
|
||||
HStack(alignment: .top, spacing: 12) {
|
||||
// Unread indicator
|
||||
Circle()
|
||||
.fill(entry.isRead ? Color.clear : Color.accentWarm)
|
||||
.frame(width: 8, height: 8)
|
||||
@@ -105,7 +246,21 @@ struct EntryRowView: View {
|
||||
|
||||
Spacer()
|
||||
|
||||
if entry.starred {
|
||||
// Thumbnail mini
|
||||
if let thumbURL = entry.thumbnailURL {
|
||||
AsyncImage(url: thumbURL) { phase in
|
||||
switch phase {
|
||||
case .success(let image):
|
||||
image
|
||||
.resizable()
|
||||
.aspectRatio(contentMode: .fill)
|
||||
.frame(width: 56, height: 56)
|
||||
.clipShape(RoundedRectangle(cornerRadius: 8))
|
||||
default:
|
||||
EmptyView()
|
||||
}
|
||||
}
|
||||
} else if entry.starred {
|
||||
Image(systemName: "star.fill")
|
||||
.font(.caption)
|
||||
.foregroundStyle(.orange)
|
||||
@@ -116,6 +271,15 @@ struct EntryRowView: View {
|
||||
.padding(.vertical, 12)
|
||||
.background(Color.canvas)
|
||||
.contextMenu {
|
||||
entryContextMenu(entry: entry, vm: vm)
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
// MARK: - Shared Context Menu
|
||||
|
||||
@ViewBuilder
|
||||
private func entryContextMenu(entry: ReaderEntry, vm: ReaderViewModel) -> some View {
|
||||
Button {
|
||||
Task { await vm.toggleRead(entry) }
|
||||
} label: {
|
||||
@@ -141,6 +305,4 @@ struct EntryRowView: View {
|
||||
Label("Save to Brain", systemImage: "brain")
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
@@ -5,6 +5,7 @@ struct ReaderTabView: View {
|
||||
@State private var selectedSubTab = 0
|
||||
@State private var showFeedSheet = false
|
||||
@State private var showFeedManagement = false
|
||||
@State private var isCardView = true
|
||||
|
||||
var body: some View {
|
||||
NavigationStack {
|
||||
@@ -81,11 +82,21 @@ struct ReaderTabView: View {
|
||||
}
|
||||
|
||||
// Entry list
|
||||
EntryListView(vm: vm)
|
||||
EntryListView(vm: vm, isCardView: isCardView)
|
||||
}
|
||||
.background(Color.canvas)
|
||||
.navigationBarHidden(true)
|
||||
.toolbar {
|
||||
ToolbarItem(placement: .topBarTrailing) {
|
||||
Button {
|
||||
withAnimation(.easeInOut(duration: 0.2)) {
|
||||
isCardView.toggle()
|
||||
}
|
||||
} label: {
|
||||
Image(systemName: isCardView ? "list.bullet" : "square.grid.2x2")
|
||||
.foregroundStyle(Color.accentWarm)
|
||||
}
|
||||
}
|
||||
ToolbarItem(placement: .topBarTrailing) {
|
||||
Menu {
|
||||
Button {
|
||||
|
||||
Reference in New Issue
Block a user