Alle UI-Strings der Feature-/App-Schicht ziehen aus mana/i18n (Listen/Detail/Marker/Feed/Rückblick/Zusammenfassen/Settings/Account + Archiv/Bulk/Note-Sheet/Palette); Interpolation als generierte Funktionen. Verwaisten-Check grün (0 hartkodierte UI-Strings). Onboarding bleibt seine eigene SOT (mana/content/onboarding). Co-Authored-By: Claude Opus 4.8 (1M context) <noreply@anthropic.com>
195 lines
5 KiB
Swift
195 lines
5 KiB
Swift
import ManaAuthUI
|
|
import ManaComponentsUI
|
|
import ManaCore
|
|
import SwiftUI
|
|
|
|
struct FeedTabView: View {
|
|
@Environment(AuthClient.self) private var auth
|
|
@Environment(\.openURL) private var openURL
|
|
private var api: PagetaAPI {
|
|
PagetaAPI(auth: auth)
|
|
}
|
|
|
|
@State private var items: [FeedItem] = []
|
|
@State private var loadState: LoadState = .idle
|
|
|
|
enum LoadState: Equatable {
|
|
case idle
|
|
case loading
|
|
case loaded
|
|
case error(String)
|
|
}
|
|
|
|
var body: some View {
|
|
NavigationStack {
|
|
Group {
|
|
if items.isEmpty {
|
|
switch loadState {
|
|
case .idle, .loading:
|
|
ProgressView()
|
|
.accessibilityLabel(PagetaStrings.stateLoading)
|
|
.frame(maxWidth: .infinity, maxHeight: .infinity)
|
|
case let .error(message):
|
|
ContentUnavailableView(
|
|
PagetaStrings.feedLoadError,
|
|
systemImage: "exclamationmark.triangle",
|
|
description: Text(message)
|
|
)
|
|
case .loaded:
|
|
ContentUnavailableView(
|
|
PagetaStrings.feedEmptyTitle,
|
|
systemImage: "newspaper",
|
|
description: Text(PagetaStrings.feedEmptyBody)
|
|
)
|
|
}
|
|
} else {
|
|
List(items) { item in
|
|
NavigationLink {
|
|
FeedItemReaderView(item: item)
|
|
} label: {
|
|
FeedItemRow(item: item)
|
|
}
|
|
.listRowInsets(EdgeInsets(top: 12, leading: 16, bottom: 12, trailing: 16))
|
|
.swipeActions(edge: .trailing) {
|
|
Button {
|
|
Task { await saveToReadingList(item) }
|
|
} label: {
|
|
Label(PagetaStrings.feedSaveToStackShort, systemImage: "books.vertical")
|
|
}
|
|
.tint(PagetaTheme.primary)
|
|
}
|
|
.contextMenu {
|
|
Button {
|
|
Task { await saveToReadingList(item) }
|
|
} label: {
|
|
Label(PagetaStrings.feedSaveToStack, systemImage: "books.vertical")
|
|
}
|
|
Divider()
|
|
if let url = URL(string: item.originalUrl) {
|
|
Button {
|
|
openURL(url)
|
|
} label: {
|
|
Label(PagetaStrings.actionOpenOriginal, systemImage: "safari")
|
|
}
|
|
ShareLink(item: url) {
|
|
Label(PagetaStrings.actionShare, systemImage: "square.and.arrow.up")
|
|
}
|
|
}
|
|
Button {
|
|
PlatformClipboard.copy(item.originalUrl)
|
|
} label: {
|
|
Label(PagetaStrings.actionCopyLink, systemImage: "link")
|
|
}
|
|
}
|
|
}
|
|
.listStyle(.plain)
|
|
.macReadingWidth()
|
|
.refreshable {
|
|
await loadFeed()
|
|
}
|
|
}
|
|
}
|
|
.navigationTitle("Feed")
|
|
.toolbar {
|
|
#if os(macOS)
|
|
ToolbarItem(placement: .primaryAction) {
|
|
Button {
|
|
Task { await loadFeed() }
|
|
} label: {
|
|
Label(PagetaStrings.actionRefresh, systemImage: "arrow.clockwise")
|
|
}
|
|
.keyboardShortcut("r", modifiers: .command)
|
|
.disabled(loadState == .loading)
|
|
}
|
|
#endif
|
|
}
|
|
.paperBackground()
|
|
.task {
|
|
if items.isEmpty {
|
|
await loadFeed()
|
|
}
|
|
}
|
|
}
|
|
}
|
|
|
|
private func loadFeed() async {
|
|
loadState = .loading
|
|
do {
|
|
let fetched = try await api.fetchFeed(limit: 50)
|
|
items = fetched
|
|
loadState = .loaded
|
|
} catch {
|
|
loadState = .error(error.localizedDescription)
|
|
}
|
|
}
|
|
|
|
private func saveToReadingList(_ item: FeedItem) async {
|
|
guard let url = URL(string: item.originalUrl) else { return }
|
|
// Best-effort — Fehler werden geloggt, UI bleibt unverändert
|
|
// (Item bleibt im Feed; Lese-Liste-Reload zieht's dann rüber).
|
|
do {
|
|
try await api.saveArticle(url: url)
|
|
Log.api.info("Saved feed item \(item.id, privacy: .public) to reading list")
|
|
} catch {
|
|
Log.api.error("Save feed item failed: \(error.localizedDescription, privacy: .public)")
|
|
}
|
|
}
|
|
}
|
|
|
|
struct FeedItemRow: View {
|
|
let item: FeedItem
|
|
|
|
private var accessibilityDescription: String {
|
|
var parts: [String] = []
|
|
parts.append(item.title)
|
|
parts.append(item.siteName)
|
|
if !item.topic.label.isEmpty { parts.append(item.topic.label) }
|
|
return parts.joined(separator: ", ")
|
|
}
|
|
|
|
var body: some View {
|
|
HStack(alignment: .top, spacing: 12) {
|
|
if let imageUrl = item.imageUrl, let url = URL(string: imageUrl) {
|
|
AsyncImage(url: url) { phase in
|
|
if let image = phase.image {
|
|
image.resizable().aspectRatio(contentMode: .fill)
|
|
} else {
|
|
// Adaptiver Placeholder — paper-cream wäre im
|
|
// Dark-Mode ein helles Quadrat.
|
|
Color.secondary.opacity(0.15)
|
|
}
|
|
}
|
|
.frame(width: 64, height: 64)
|
|
.clipShape(RoundedRectangle(cornerRadius: 6))
|
|
.accessibilityHidden(true)
|
|
}
|
|
VStack(alignment: .leading, spacing: 4) {
|
|
HStack(spacing: 6) {
|
|
Text(item.topic.emoji)
|
|
.accessibilityHidden(true)
|
|
Text(item.topic.label)
|
|
.font(.caption)
|
|
.foregroundStyle(.secondary)
|
|
Spacer()
|
|
Text(item.siteName)
|
|
.font(.caption)
|
|
.foregroundStyle(.secondary)
|
|
.lineLimit(1)
|
|
}
|
|
Text(item.title)
|
|
.font(.body.weight(.medium))
|
|
.foregroundStyle(.primary)
|
|
.lineLimit(3)
|
|
if let excerpt = item.excerpt, !excerpt.isEmpty {
|
|
Text(excerpt)
|
|
.font(.callout)
|
|
.foregroundStyle(.secondary)
|
|
.lineLimit(2)
|
|
}
|
|
}
|
|
}
|
|
.padding(.vertical, 4)
|
|
.accessibilityElement(children: .ignore)
|
|
.accessibilityLabel(accessibilityDescription)
|
|
}
|
|
}
|