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 ?? ""
|
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 = {
|
private static let isoFormatter: ISO8601DateFormatter = {
|
||||||
let f = ISO8601DateFormatter()
|
let f = ISO8601DateFormatter()
|
||||||
f.formatOptions = [.withInternetDateTime, .withFractionalSeconds]
|
f.formatOptions = [.withInternetDateTime, .withFractionalSeconds]
|
||||||
|
|||||||
@@ -2,6 +2,7 @@ import SwiftUI
|
|||||||
|
|
||||||
struct EntryListView: View {
|
struct EntryListView: View {
|
||||||
@Bindable var vm: ReaderViewModel
|
@Bindable var vm: ReaderViewModel
|
||||||
|
var isCardView: Bool = true
|
||||||
|
|
||||||
var body: some View {
|
var body: some View {
|
||||||
if vm.isLoading && vm.entries.isEmpty {
|
if vm.isLoading && vm.entries.isEmpty {
|
||||||
@@ -16,30 +17,10 @@ struct EntryListView: View {
|
|||||||
)
|
)
|
||||||
} else {
|
} else {
|
||||||
ScrollView {
|
ScrollView {
|
||||||
LazyVStack(spacing: 0) {
|
if isCardView {
|
||||||
ForEach(vm.entries) { entry in
|
cardLayout
|
||||||
NavigationLink(value: entry) {
|
|
||||||
EntryRowView(entry: entry, vm: vm)
|
|
||||||
}
|
|
||||||
.buttonStyle(.plain)
|
|
||||||
|
|
||||||
Divider()
|
|
||||||
.padding(.leading, 16)
|
|
||||||
}
|
|
||||||
|
|
||||||
// Infinite scroll trigger
|
|
||||||
if vm.isLoadingMore {
|
|
||||||
ProgressView()
|
|
||||||
.padding()
|
|
||||||
} else {
|
} else {
|
||||||
Color.clear
|
listLayout
|
||||||
.frame(height: 1)
|
|
||||||
.onAppear {
|
|
||||||
Task { await vm.loadMore() }
|
|
||||||
}
|
|
||||||
}
|
|
||||||
|
|
||||||
Spacer(minLength: 80)
|
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
.refreshable {
|
.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 {
|
struct EntryRowView: View {
|
||||||
let entry: ReaderEntry
|
let entry: ReaderEntry
|
||||||
@@ -60,7 +202,6 @@ struct EntryRowView: View {
|
|||||||
|
|
||||||
var body: some View {
|
var body: some View {
|
||||||
HStack(alignment: .top, spacing: 12) {
|
HStack(alignment: .top, spacing: 12) {
|
||||||
// Unread indicator
|
|
||||||
Circle()
|
Circle()
|
||||||
.fill(entry.isRead ? Color.clear : Color.accentWarm)
|
.fill(entry.isRead ? Color.clear : Color.accentWarm)
|
||||||
.frame(width: 8, height: 8)
|
.frame(width: 8, height: 8)
|
||||||
@@ -105,7 +246,21 @@ struct EntryRowView: View {
|
|||||||
|
|
||||||
Spacer()
|
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")
|
Image(systemName: "star.fill")
|
||||||
.font(.caption)
|
.font(.caption)
|
||||||
.foregroundStyle(.orange)
|
.foregroundStyle(.orange)
|
||||||
@@ -116,6 +271,15 @@ struct EntryRowView: View {
|
|||||||
.padding(.vertical, 12)
|
.padding(.vertical, 12)
|
||||||
.background(Color.canvas)
|
.background(Color.canvas)
|
||||||
.contextMenu {
|
.contextMenu {
|
||||||
|
entryContextMenu(entry: entry, vm: vm)
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
// MARK: - Shared Context Menu
|
||||||
|
|
||||||
|
@ViewBuilder
|
||||||
|
private func entryContextMenu(entry: ReaderEntry, vm: ReaderViewModel) -> some View {
|
||||||
Button {
|
Button {
|
||||||
Task { await vm.toggleRead(entry) }
|
Task { await vm.toggleRead(entry) }
|
||||||
} label: {
|
} label: {
|
||||||
@@ -141,6 +305,4 @@ struct EntryRowView: View {
|
|||||||
Label("Save to Brain", systemImage: "brain")
|
Label("Save to Brain", systemImage: "brain")
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
}
|
|
||||||
}
|
|
||||||
}
|
}
|
||||||
|
|||||||
@@ -5,6 +5,7 @@ struct ReaderTabView: View {
|
|||||||
@State private var selectedSubTab = 0
|
@State private var selectedSubTab = 0
|
||||||
@State private var showFeedSheet = false
|
@State private var showFeedSheet = false
|
||||||
@State private var showFeedManagement = false
|
@State private var showFeedManagement = false
|
||||||
|
@State private var isCardView = true
|
||||||
|
|
||||||
var body: some View {
|
var body: some View {
|
||||||
NavigationStack {
|
NavigationStack {
|
||||||
@@ -81,11 +82,21 @@ struct ReaderTabView: View {
|
|||||||
}
|
}
|
||||||
|
|
||||||
// Entry list
|
// Entry list
|
||||||
EntryListView(vm: vm)
|
EntryListView(vm: vm, isCardView: isCardView)
|
||||||
}
|
}
|
||||||
.background(Color.canvas)
|
.background(Color.canvas)
|
||||||
.navigationBarHidden(true)
|
.navigationBarHidden(true)
|
||||||
.toolbar {
|
.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) {
|
ToolbarItem(placement: .topBarTrailing) {
|
||||||
Menu {
|
Menu {
|
||||||
Button {
|
Button {
|
||||||
|
|||||||
Reference in New Issue
Block a user