feat: card/list view toggle for Reader with thumbnails
All checks were successful
Security Checks / dependency-audit (push) Successful in 13s
Security Checks / secret-scanning (push) Successful in 4s
Security Checks / dockerfile-lint (push) Successful in 3s

- 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:
Yusuf Suleman
2026-04-03 18:24:33 -05:00
parent 426adb3442
commit 4d4e96c327
3 changed files with 244 additions and 53 deletions

View File

@@ -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]

View File

@@ -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")
}
}
}
}
}

View File

@@ -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 {