Spiegel von @mana/shared-schemas/pageta@0.1.21 (Web = SOT): - ArticleMediaArchivedPayload + Reducer-Case (non-destruktiv, if-let wie warning); ArticleEnriched clobbert imageUrl nicht mehr - ArticleState.htmlContent; stateToArticle mappt es (war nil) - ArticleExtractPreview dekodiert imageUrl (og:image-Hotlink) - archiveMedia(): POST /articles/archive-media, fire-and-forget nach Save/Enrich; NUR die archivierte media.mana.how-URL wird emittiert, nie der Hotlink; Delete-Race-Guard wie im Web-Client - MediaURL.variant (thumb/medium/large, Spiegel von media-url.ts) - Lese-Liste: 56pt-Thumbnail; Reader-Header: Lead-Image (medium, 200pt) - 5 neue Reducer-/MediaURL-Tests (54/54 grün, iPhone-17-Simulator) Co-Authored-By: Claude Fable 5 <noreply@anthropic.com>
1448 lines
47 KiB
Swift
1448 lines
47 KiB
Swift
import ManaCore
|
||
import Observation
|
||
import SwiftUI
|
||
|
||
#if canImport(UIKit)
|
||
import UIKit
|
||
#endif
|
||
|
||
/// Reader-Detail-View. π-3 Plain-Text-Spike + π-4 Highlights/Tags.
|
||
///
|
||
/// **HTML→AttributedString-Spike** (π-3): zugunsten Plain-Text aus
|
||
/// Mozilla-Readability (`article.content` mit `\n\n`-Paragraphen).
|
||
/// `htmlContent` wäre Round-Trip über NSAttributedString — langsam +
|
||
/// Crash-Risiko bei komplexem CSS. Headings/Bilder/Quotes/Code/Links
|
||
/// werden flach gerendert; Iteration 2 via Server-Markdown-Convert
|
||
/// oder Block-JSON.
|
||
///
|
||
/// **Markieren** (π-4, überarbeitet 2026-05-25): Text auswählen → eine
|
||
/// schwebende Farb-Palette (`HighlightSelectionPalette`) erscheint direkt
|
||
/// an der Auswahl (Books-Stil), ein Tipp markiert. Kein Offset-Tracking —
|
||
/// Highlight matcht auf Text-Identität; web-erstellte Highlights mit
|
||
/// echten Offsets bleiben les- und löschbar. Schreibfehler (z.B. Session
|
||
/// abgelaufen) werden jetzt als Alert sichtbar statt still verschluckt.
|
||
struct ArticleDetailView: View {
|
||
@State private var articleId: String
|
||
|
||
init(articleId: String) {
|
||
_articleId = State(initialValue: articleId)
|
||
}
|
||
|
||
@Environment(AuthClient.self) private var auth
|
||
@Environment(\.dismiss) private var dismiss
|
||
private var api: PagetaAPI {
|
||
PagetaAPI(auth: auth)
|
||
}
|
||
|
||
@State private var article: Article?
|
||
@State private var highlights: [Highlight] = []
|
||
@State private var loadState: LoadState = .loading
|
||
@State private var textSelection: TextSelection?
|
||
@State private var paletteSize: CGSize = .zero
|
||
@State private var writeError: String?
|
||
@State private var editingNoteFor: Highlight?
|
||
@State private var showTagSheet = false
|
||
@State private var tagInput = ""
|
||
@State private var siblings: [Article] = []
|
||
|
||
// MARK: - In-Artikel-Suche
|
||
|
||
// Eigene Find-in-page statt System-UIFindInteraction/NSTextFinder (die
|
||
// eine selbst-scrollende TextView voraussetzen — hier bewusst nicht).
|
||
// Treffer als NSRange über ArticleBodyTextView.cleanedBody(content); die
|
||
// Hervorhebung + Rect-Brücke fürs Scrollen lebt in ArticleBodyTextView.
|
||
/// Bündig zum Web-Reader (Cmd/Ctrl+F-App-Suche statt Browser-Suche).
|
||
@State private var searchActive = false
|
||
@State private var searchQuery = ""
|
||
@State private var searchMatches: [NSRange] = []
|
||
@State private var activeMatchIndex: Int?
|
||
@State private var searchDebounce: Task<Void, Never>?
|
||
@FocusState private var searchFieldFocused: Bool
|
||
/// Brücke, über die der Body das textview-lokale Rect eines Treffers liefert.
|
||
@State private var bodyController = ArticleBodyController()
|
||
/// Hält Scroll-Offset + Body-Position für die Scroll-zu-Treffer-Mathematik.
|
||
/// Bewusst eine **plain class** (kein @Observable): so triggern die häufigen
|
||
/// Scroll-Updates KEINE SwiftUI-Invalidierung des teuren Reader-Bodys.
|
||
@State private var scrollTracker = ReaderScrollTracker()
|
||
|
||
/// Named-Coordinate-Space, in dem die Body-Position relativ zum Reader
|
||
/// gemessen wird (für Scroll-zu-Treffer).
|
||
private static let readerSpace = "pagetaReaderSpace"
|
||
|
||
// MARK: - Inhalts-Extraktion (geteilte Links)
|
||
|
||
/// Läuft gerade ein Extraktions-Versuch für diesen Artikel?
|
||
@State private var isExtracting = false
|
||
/// Letzte Extraktions-Fehlermeldung (nil = noch keiner / erfolgreich).
|
||
@State private var extractionError: String?
|
||
/// Artikel-IDs, für die der automatische Einmal-Versuch in `load()` schon
|
||
/// lief — verhindert eine Schleife, falls die Extraktion leer/fehlerhaft
|
||
/// bleibt. Der manuelle Retry-Button umgeht das bewusst.
|
||
@State private var autoExtractTried: Set<String> = []
|
||
|
||
// MARK: - Lese-Fortschritt
|
||
|
||
/// Spiegelt die Web-Mechanik (apps/web/.../article/[id]/+page.svelte):
|
||
/// Scroll-Position 0..1 messen, debounced (1,5 s) als
|
||
/// ArticleProgressUpdated-Event syncen, gespeicherte Position beim
|
||
/// Öffnen einmalig wiederherstellen.
|
||
@State private var scrollPosition = ScrollPosition(edge: .top)
|
||
/// Live-Fortschritt für Balken + Button-Füllung. **Bewusst ein
|
||
/// `@Observable`-Model statt `@State Double`:** so invalidiert eine
|
||
/// Scroll-Aktualisierung nur die beiden Mini-Views, die `fraction` lesen
|
||
/// (ReadingProgressBar + ReadProgressButton) — NICHT den ganzen Reader-Body
|
||
/// mit der teuren ArticleBodyTextView-Brücke. Sonst ruckelt jedes 2%-Tick
|
||
/// das Scrollen (UITextView-Neuaufbau pro Body-Re-Eval).
|
||
@State private var progress = ReadingProgressModel()
|
||
/// One-shot-Guard: gespeicherte Scroll-Position erst springen, sobald
|
||
/// das Layout eine echte Scroll-Range kennt — danach Tracking scharf.
|
||
@State private var didRestoreScroll = false
|
||
@State private var progressFlushTask: Task<Void, Never>?
|
||
|
||
enum LoadState: Equatable {
|
||
case loading
|
||
case loaded
|
||
case error(String)
|
||
}
|
||
|
||
/// Messpunkt aus `ScrollGeometry`: normierter Fortschritt + scrollbare
|
||
/// Range. Equatable, damit `onScrollGeometryChange` nur bei echter
|
||
/// Änderung feuert.
|
||
private struct ScrollProgressSample: Equatable {
|
||
var value: Double
|
||
var range: CGFloat
|
||
var offsetY: CGFloat
|
||
}
|
||
|
||
var body: some View {
|
||
Group {
|
||
if let article {
|
||
ScrollView {
|
||
VStack(alignment: .leading, spacing: 20) {
|
||
header(for: article)
|
||
if article.warning == "probable_consent_wall" {
|
||
consentWallBanner(for: article)
|
||
}
|
||
ArticleSummarySection(article: article)
|
||
if !article.tags.isEmpty {
|
||
tagsSection(for: article)
|
||
}
|
||
if !highlights.isEmpty {
|
||
highlightsSection
|
||
}
|
||
if article.content.isEmpty {
|
||
pendingExtractionNotice(for: article)
|
||
} else {
|
||
ArticleBodyTextView(
|
||
text: article.content,
|
||
highlights: highlights,
|
||
onSelectionChange: { selection in
|
||
textSelection = selection
|
||
},
|
||
selectionActive: textSelection != nil,
|
||
searchMatches: searchMatches,
|
||
activeMatchIndex: activeMatchIndex,
|
||
controller: bodyController
|
||
)
|
||
.background(
|
||
// Body-Position relativ zum Reader messen — zusammen
|
||
// mit dem Scroll-Offset ergibt das die absolute
|
||
// Scroll-Zielposition eines Treffers.
|
||
GeometryReader { geo in
|
||
Color.clear
|
||
.onChange(
|
||
of: geo.frame(in: .named(Self.readerSpace)).minY,
|
||
initial: true
|
||
) { _, newY in
|
||
scrollTracker.bodyMinY = newY
|
||
}
|
||
}
|
||
)
|
||
}
|
||
footerActions
|
||
}
|
||
.padding(.horizontal, 20)
|
||
.padding(.vertical, 16)
|
||
.frame(maxWidth: 720, alignment: .leading)
|
||
.frame(maxWidth: .infinity, alignment: .leading)
|
||
}
|
||
.background(PagetaTheme.background)
|
||
// Etwas Luft am Artikelende, damit die letzte Zeile nicht am
|
||
// 2pt-Fortschrittsbalken/Bildschirmrand klebt. (Früher 60 für
|
||
// die floating Tab-Bar — die ist im Reader jetzt ausgeblendet.)
|
||
.contentMargins(.bottom, 24, for: .scrollContent)
|
||
.scrollPosition($scrollPosition)
|
||
.onScrollGeometryChange(for: ScrollProgressSample.self) { geo in
|
||
let range = geo.contentSize.height - geo.containerSize.height
|
||
let value = range > 0
|
||
? min(1, max(0, geo.contentOffset.y / range))
|
||
: 0
|
||
return ScrollProgressSample(
|
||
value: value,
|
||
range: range,
|
||
offsetY: geo.contentOffset.y
|
||
)
|
||
} action: { _, new in
|
||
// Roher Offset für die Scroll-zu-Treffer-Mathematik (kein
|
||
// SwiftUI-Invalidate, da plain class).
|
||
scrollTracker.scrollY = new.offsetY
|
||
handleScroll(fraction: new.value, range: new.range)
|
||
}
|
||
.overlay(alignment: .bottom) {
|
||
readingProgressBar
|
||
.ignoresSafeArea(edges: .bottom)
|
||
}
|
||
.overlay { selectionPaletteOverlay }
|
||
.overlay(alignment: .top) { searchBarOverlay }
|
||
.coordinateSpace(.named(Self.readerSpace))
|
||
.onDisappear { flushProgressNow() }
|
||
} else {
|
||
switch loadState {
|
||
case .loading:
|
||
ProgressView()
|
||
.frame(maxWidth: .infinity, maxHeight: .infinity)
|
||
case let .error(message):
|
||
ContentUnavailableView(
|
||
PagetaStrings.detailLoadError,
|
||
systemImage: "exclamationmark.triangle",
|
||
description: Text(message)
|
||
)
|
||
case .loaded:
|
||
ContentUnavailableView(
|
||
PagetaStrings.detailEmpty,
|
||
systemImage: "doc.text",
|
||
description: Text(PagetaStrings.detailNoContent)
|
||
)
|
||
}
|
||
}
|
||
}
|
||
.navigationTitle("")
|
||
#if os(iOS)
|
||
.navigationBarTitleDisplayMode(.inline)
|
||
// TabBar im Reader ausblenden: sonst sitzt der Lese-Fortschritts-
|
||
// Balken (.overlay unten) über dem Tab-Wechsler statt am unteren
|
||
// Bildschirmrand. Beim Zurück-Poppen kommt die TabBar wieder.
|
||
.toolbar(.hidden, for: .tabBar)
|
||
#endif
|
||
.toolbar { toolbarContent }
|
||
.background { searchHotkey }
|
||
.sheet(isPresented: $showTagSheet) {
|
||
tagSheet
|
||
}
|
||
.sheet(item: $editingNoteFor) { highlight in
|
||
HighlightNoteSheet(
|
||
highlightText: highlight.text,
|
||
initialNote: highlight.note,
|
||
color: highlight.color,
|
||
onSave: { newNote in
|
||
await saveNote(for: highlight, newNote: newNote)
|
||
}
|
||
)
|
||
}
|
||
.task(id: articleId) {
|
||
await load()
|
||
}
|
||
.alert(
|
||
PagetaStrings.detailSaveFailed,
|
||
isPresented: writeErrorBinding,
|
||
presenting: writeError
|
||
) { _ in
|
||
Button(PagetaStrings.actionOk, role: .cancel) { writeError = nil }
|
||
} message: { message in
|
||
Text(message)
|
||
}
|
||
}
|
||
|
||
private var writeErrorBinding: Binding<Bool> {
|
||
Binding(
|
||
get: { writeError != nil },
|
||
set: { newValue in if !newValue { writeError = nil } }
|
||
)
|
||
}
|
||
|
||
/// Schwebende Auswahl-Palette (Books-Stil). Auf iOS an das Auswahl-
|
||
/// Rechteck verankert (oben, klappt bei Platzmangel unter die Auswahl);
|
||
/// auf macOS oben-zentriert.
|
||
@ViewBuilder
|
||
private var selectionPaletteOverlay: some View {
|
||
if let selection = textSelection {
|
||
#if os(iOS)
|
||
GeometryReader { proxy in
|
||
let container = proxy.frame(in: .global)
|
||
let position = palettePosition(
|
||
anchor: selection.anchorRect,
|
||
container: container,
|
||
size: paletteSize
|
||
)
|
||
HighlightSelectionPalette(
|
||
onPick: { color in
|
||
let text = selection.text
|
||
textSelection = nil
|
||
requestHighlight(text: text, color: color)
|
||
},
|
||
onCopy: {
|
||
UIPasteboard.general.string = selection.text
|
||
textSelection = nil
|
||
}
|
||
)
|
||
.fixedSize()
|
||
.background(
|
||
GeometryReader { geo in
|
||
Color.clear.preference(key: PaletteSizeKey.self, value: geo.size)
|
||
}
|
||
)
|
||
.offset(x: position.x, y: position.y)
|
||
// Erst nach der Größenmessung einblenden, sonst blitzt
|
||
// die Palette eine Frame lang oben-links auf.
|
||
.opacity(paletteSize == .zero ? 0 : 1)
|
||
}
|
||
.onPreferenceChange(PaletteSizeKey.self) { paletteSize = $0 }
|
||
#else
|
||
VStack {
|
||
HighlightSelectionPalette(
|
||
onPick: { color in
|
||
let text = selection.text
|
||
textSelection = nil
|
||
requestHighlight(text: text, color: color)
|
||
},
|
||
onCopy: {
|
||
NSPasteboard.general.clearContents()
|
||
NSPasteboard.general.setString(selection.text, forType: .string)
|
||
textSelection = nil
|
||
}
|
||
)
|
||
.padding(.top, 12)
|
||
Spacer()
|
||
}
|
||
.frame(maxWidth: .infinity)
|
||
#endif
|
||
}
|
||
}
|
||
|
||
#if os(iOS)
|
||
/// Positioniert die Palette mittig über der Auswahl (lokale Koordinaten
|
||
/// der GeometryReader-Fläche). Bei zu wenig Platz oben klappt sie unter
|
||
/// die Auswahl; horizontal + vertikal auf den sichtbaren Bereich geklemmt.
|
||
private func palettePosition(anchor: CGRect, container: CGRect, size: CGSize) -> CGPoint {
|
||
let gap: CGFloat = 10
|
||
let width = size.width
|
||
let height = size.height
|
||
let anchorMidX = anchor.midX - container.minX
|
||
let anchorTop = anchor.minY - container.minY
|
||
let anchorBottom = anchor.maxY - container.minY
|
||
|
||
var x = anchorMidX - width / 2
|
||
x = min(max(8, x), max(8, container.width - width - 8))
|
||
|
||
var y = anchorTop - height - gap
|
||
if y < 8 {
|
||
y = anchorBottom + gap
|
||
}
|
||
y = min(max(8, y), max(8, container.height - height - 8))
|
||
|
||
return CGPoint(x: x, y: y)
|
||
}
|
||
#endif
|
||
|
||
@ToolbarContentBuilder
|
||
private var toolbarContent: some ToolbarContent {
|
||
ToolbarItemGroup(placement: .primaryAction) {
|
||
if let article {
|
||
Menu {
|
||
Section("Status") {
|
||
ForEach(ArticleStatus.allCases) { status in
|
||
Button {
|
||
Task { await setStatus(status, for: article) }
|
||
} label: {
|
||
Label(status.label, systemImage: icon(for: status))
|
||
}
|
||
}
|
||
}
|
||
Section {
|
||
Button {
|
||
openSearch()
|
||
} label: {
|
||
Label(PagetaStrings.detailSearchInArticle, systemImage: "magnifyingglass")
|
||
}
|
||
Button {
|
||
tagInput = ""
|
||
showTagSheet = true
|
||
} label: {
|
||
Label(PagetaStrings.detailAddTag, systemImage: "tag")
|
||
}
|
||
if let url = URL(string: article.originalUrl) {
|
||
Link(destination: url) {
|
||
Label(PagetaStrings.actionOpenInBrowser, systemImage: "safari")
|
||
}
|
||
ShareLink(item: url) {
|
||
Label(PagetaStrings.actionShareShort, systemImage: "square.and.arrow.up")
|
||
}
|
||
}
|
||
}
|
||
} label: {
|
||
// iOS 18+ rendert Toolbar-Items eh in Glass-Capsules;
|
||
// `.circle` würde einen doppelten Kreis ergeben.
|
||
Image(systemName: "ellipsis")
|
||
}
|
||
}
|
||
}
|
||
}
|
||
|
||
// MARK: - In-Artikel-Suche (UI + Logik)
|
||
|
||
/// Schwebende Such-Leiste oben über dem Reader (Find-in-page-Stil).
|
||
@ViewBuilder
|
||
private var searchBarOverlay: some View {
|
||
if searchActive {
|
||
HStack(spacing: 8) {
|
||
Image(systemName: "magnifyingglass")
|
||
.font(.callout)
|
||
.foregroundStyle(.secondary)
|
||
TextField(PagetaStrings.detailSearchInArticlePlaceholder, text: $searchQuery)
|
||
.textFieldStyle(.plain)
|
||
.autocorrectionDisabled()
|
||
.focused($searchFieldFocused)
|
||
.submitLabel(.search)
|
||
.onSubmit { nextMatch() }
|
||
.onChange(of: searchQuery) { _, query in scheduleSearch(query) }
|
||
#if os(iOS)
|
||
.textInputAutocapitalization(.never)
|
||
#endif
|
||
if !matchCountLabel.isEmpty {
|
||
Text(matchCountLabel)
|
||
.font(.caption)
|
||
.monospacedDigit()
|
||
.foregroundStyle(.secondary)
|
||
}
|
||
Button { prevMatch() } label: {
|
||
Image(systemName: "chevron.up")
|
||
}
|
||
.buttonStyle(.plain)
|
||
.disabled(searchMatches.isEmpty)
|
||
.accessibilityLabel(PagetaStrings.detailPrevHit)
|
||
Button { nextMatch() } label: {
|
||
Image(systemName: "chevron.down")
|
||
}
|
||
.buttonStyle(.plain)
|
||
.disabled(searchMatches.isEmpty)
|
||
.accessibilityLabel(PagetaStrings.detailNextHit)
|
||
Button { closeSearch() } label: {
|
||
Image(systemName: "xmark")
|
||
}
|
||
.buttonStyle(.plain)
|
||
.accessibilityLabel(PagetaStrings.searchClose)
|
||
}
|
||
.font(.callout)
|
||
.foregroundStyle(PagetaTheme.foreground)
|
||
.padding(.horizontal, 14)
|
||
.padding(.vertical, 10)
|
||
.background(.regularMaterial, in: RoundedRectangle(cornerRadius: 10))
|
||
.overlay(
|
||
RoundedRectangle(cornerRadius: 10)
|
||
.stroke(PagetaTheme.foreground.opacity(0.12), lineWidth: 0.5)
|
||
)
|
||
.shadow(color: .black.opacity(0.12), radius: 6, y: 2)
|
||
.padding(.horizontal, 12)
|
||
.padding(.top, 8)
|
||
}
|
||
}
|
||
|
||
/// Unsichtbarer Cmd/Ctrl+F-Hotkey — übernimmt die App-Suche (bündig zum
|
||
/// Web-Reader). Als 0×0-Button im Hintergrund, ohne sichtbare Affordance.
|
||
private var searchHotkey: some View {
|
||
Button(PagetaStrings.detailSearchInArticle) { openSearch() }
|
||
.keyboardShortcut("f", modifiers: .command)
|
||
.opacity(0)
|
||
.frame(width: 0, height: 0)
|
||
.accessibilityHidden(true)
|
||
}
|
||
|
||
private var matchCountLabel: String {
|
||
guard searchQuery.trimmingCharacters(in: .whitespaces).count >= 2 else { return "" }
|
||
guard !searchMatches.isEmpty else { return "0" }
|
||
return "\((activeMatchIndex ?? 0) + 1)/\(searchMatches.count)"
|
||
}
|
||
|
||
private func openSearch() {
|
||
searchActive = true
|
||
searchFieldFocused = true
|
||
}
|
||
|
||
private func closeSearch() {
|
||
searchDebounce?.cancel()
|
||
searchActive = false
|
||
searchFieldFocused = false
|
||
searchQuery = ""
|
||
searchMatches = []
|
||
activeMatchIndex = nil
|
||
}
|
||
|
||
/// Debounced Treffer-Berechnung (≈180 ms), damit ein langer Artikel nicht
|
||
/// bei jedem Tastendruck den ganzen AttributedString neu aufbaut.
|
||
private func scheduleSearch(_ query: String) {
|
||
searchDebounce?.cancel()
|
||
searchDebounce = Task {
|
||
try? await Task.sleep(for: .milliseconds(180))
|
||
if Task.isCancelled { return }
|
||
computeMatches(query)
|
||
}
|
||
}
|
||
|
||
/// Berechnet Treffer als NSRanges über **genau** den gerenderten Body-
|
||
/// String (`ArticleBodyTextView.cleanedBody`), damit die Offsets zu den
|
||
/// Attributen der TextView passen. Springt danach zum ersten Treffer.
|
||
private func computeMatches(_ query: String) {
|
||
let trimmed = query.trimmingCharacters(in: .whitespaces)
|
||
guard trimmed.count >= 2, let content = article?.content else {
|
||
searchMatches = []
|
||
activeMatchIndex = nil
|
||
return
|
||
}
|
||
let body = ArticleBodyTextView.cleanedBody(content) as NSString
|
||
var ranges: [NSRange] = []
|
||
var start = 0
|
||
while start < body.length {
|
||
let found = body.range(
|
||
of: trimmed,
|
||
options: .caseInsensitive,
|
||
range: NSRange(location: start, length: body.length - start)
|
||
)
|
||
if found.location == NSNotFound { break }
|
||
ranges.append(found)
|
||
start = found.location + max(1, found.length)
|
||
}
|
||
searchMatches = ranges
|
||
if ranges.isEmpty {
|
||
activeMatchIndex = nil
|
||
} else {
|
||
focusMatch(0)
|
||
}
|
||
}
|
||
|
||
private func nextMatch() {
|
||
guard !searchMatches.isEmpty else { return }
|
||
let current = activeMatchIndex ?? -1
|
||
focusMatch((current + 1) % searchMatches.count)
|
||
}
|
||
|
||
private func prevMatch() {
|
||
guard !searchMatches.isEmpty else { return }
|
||
let current = activeMatchIndex ?? 0
|
||
focusMatch((current - 1 + searchMatches.count) % searchMatches.count)
|
||
}
|
||
|
||
/// Setzt den aktiven Treffer und scrollt ihn unter die Such-Leiste.
|
||
///
|
||
/// Mathematik: das textview-LOKALE Treffer-Rect (`bodyController.localRect`)
|
||
/// plus die im `readerSpace` gemessene Body-Position plus der Scroll-Offset
|
||
/// ergeben die absolute Zielposition im Scroll-Content. `bodyMinY` ist
|
||
/// `bodyAbsoluteTop − scrollY`, also `bodyMinY + scrollY = bodyAbsoluteTop`.
|
||
private func focusMatch(_ index: Int) {
|
||
guard searchMatches.indices.contains(index) else { return }
|
||
activeMatchIndex = index
|
||
guard let localRect = bodyController.localRect?(searchMatches[index]) else { return }
|
||
// Zielabstand von der Oberkante: unter die schwebende Such-Leiste.
|
||
let targetTop: CGFloat = 96
|
||
let absoluteTop = scrollTracker.bodyMinY + scrollTracker.scrollY
|
||
let target = max(0, absoluteTop + localRect.minY - targetTop)
|
||
withAnimation(.easeInOut(duration: 0.25)) {
|
||
scrollPosition.scrollTo(y: target)
|
||
}
|
||
}
|
||
|
||
// MARK: - Rendering
|
||
|
||
/// Dünner Lese-Fortschritts-Balken oben über dem Reader (Web-Pendant:
|
||
/// fixierter Balken auf der Artikel-Seite). Liest `fraction` in einer
|
||
/// eigenen View, damit Scroll-Updates nur diesen Balken neu rendern.
|
||
private var readingProgressBar: some View {
|
||
ReadingProgressBar(progress: progress)
|
||
}
|
||
|
||
private func header(for article: Article) -> some View {
|
||
VStack(alignment: .leading, spacing: 8) {
|
||
// Lead-Image aus dem Archiv (ArticleMediaArchived) — nur wenn
|
||
// eins da ist; ohne Bild bleibt der Header wie bisher.
|
||
if let imageUrl = article.imageUrl,
|
||
let url = URL(string: MediaURL.variant(imageUrl, .medium))
|
||
{
|
||
AsyncImage(url: url) { phase in
|
||
if let image = phase.image {
|
||
image
|
||
.resizable()
|
||
.aspectRatio(contentMode: .fill)
|
||
} else {
|
||
// Adaptiver Placeholder statt paper-cream (Dark-Mode).
|
||
Color.secondary.opacity(0.1)
|
||
}
|
||
}
|
||
// Feste Höhe statt maxHeight: AsyncImage hätte sonst die
|
||
// intrinsische Bildgröße und springt beim Laden.
|
||
.frame(maxWidth: .infinity)
|
||
.frame(height: 200)
|
||
.clipShape(RoundedRectangle(cornerRadius: 10))
|
||
.accessibilityHidden(true)
|
||
.padding(.bottom, 4)
|
||
}
|
||
HStack(spacing: 6) {
|
||
Text(article.siteName ?? "")
|
||
.font(.caption.weight(.medium))
|
||
.foregroundStyle(.secondary)
|
||
if let mins = article.readingTimeMinutes {
|
||
Text("•").foregroundStyle(.secondary)
|
||
Text(PagetaStrings.metaMinRead(mins))
|
||
.font(.caption)
|
||
.foregroundStyle(.secondary)
|
||
}
|
||
Spacer()
|
||
Text(article.status.label)
|
||
.font(.caption2.weight(.medium))
|
||
.padding(.horizontal, 8)
|
||
.padding(.vertical, 3)
|
||
.background(PagetaTheme.surfaceHover, in: Capsule())
|
||
}
|
||
Text(article.displayTitle)
|
||
.font(.system(.title2, design: .serif, weight: .semibold))
|
||
.foregroundStyle(PagetaTheme.foreground)
|
||
if let author = article.author, !author.isEmpty {
|
||
Text("von \(author)")
|
||
.font(.subheadline)
|
||
.foregroundStyle(.secondary)
|
||
}
|
||
}
|
||
}
|
||
|
||
/// Warnt, wenn der Server-Extract auf eine Cookie-Wand statt einen
|
||
/// Artikel gefallen ist (`looksLikeConsentWall`). Bietet
|
||
/// Original-Link zum Browser-Open an, da der gespeicherte Text
|
||
/// wertlos ist.
|
||
private func consentWallBanner(for article: Article) -> some View {
|
||
VStack(alignment: .leading, spacing: 8) {
|
||
Label("Wahrscheinlich Cookie-Wand", systemImage: "exclamationmark.triangle.fill")
|
||
.font(.caption.weight(.semibold))
|
||
.foregroundStyle(.orange)
|
||
Text(
|
||
PagetaStrings.detailConsentWallBody
|
||
)
|
||
.font(.callout)
|
||
.foregroundStyle(PagetaTheme.foreground)
|
||
.fixedSize(horizontal: false, vertical: true)
|
||
HStack(spacing: 8) {
|
||
if let url = URL(string: article.originalUrl) {
|
||
Link(destination: url) {
|
||
Label(PagetaStrings.detailConsentWallOpen, systemImage: "safari")
|
||
.font(.callout.weight(.medium))
|
||
}
|
||
}
|
||
Spacer(minLength: 8)
|
||
Button {
|
||
Task { await dismissWarning(for: article) }
|
||
} label: {
|
||
Text(PagetaStrings.detailDismissHint)
|
||
.font(.callout)
|
||
.foregroundStyle(.secondary)
|
||
}
|
||
.buttonStyle(.plain)
|
||
}
|
||
}
|
||
.padding(12)
|
||
.frame(maxWidth: .infinity, alignment: .leading)
|
||
.background(.orange.opacity(0.12), in: RoundedRectangle(cornerRadius: 10))
|
||
.overlay(
|
||
RoundedRectangle(cornerRadius: 10)
|
||
.stroke(.orange.opacity(0.4), lineWidth: 1)
|
||
)
|
||
}
|
||
|
||
/// Platzhalter für local-first geteilte Links ohne extrahierten Inhalt.
|
||
/// Drei ehrliche Zustände statt des alten pauschalen „lädt automatisch
|
||
/// nach": läuft gerade / fehlgeschlagen (mit Grund) / noch nicht versucht.
|
||
/// In allen Fällen: manueller „Erneut verarbeiten"-Button + Original-Link.
|
||
private func pendingExtractionNotice(for article: Article) -> some View {
|
||
VStack(alignment: .leading, spacing: 10) {
|
||
if isExtracting {
|
||
Label(PagetaStrings.detailLoading, systemImage: "arrow.triangle.2.circlepath")
|
||
.font(.callout.weight(.semibold))
|
||
.foregroundStyle(.secondary)
|
||
} else if let extractionError {
|
||
Label(PagetaStrings.detailContentLoadError, systemImage: "exclamationmark.triangle")
|
||
.font(.callout.weight(.semibold))
|
||
.foregroundStyle(.orange)
|
||
Text(extractionError)
|
||
.font(.callout)
|
||
.foregroundStyle(PagetaTheme.foreground)
|
||
.fixedSize(horizontal: false, vertical: true)
|
||
Text(
|
||
PagetaStrings.detailConsentTip
|
||
)
|
||
.font(.footnote)
|
||
.foregroundStyle(.secondary)
|
||
.fixedSize(horizontal: false, vertical: true)
|
||
} else {
|
||
Label(PagetaStrings.detailContentNotLoaded, systemImage: "doc.text.magnifyingglass")
|
||
.font(.callout.weight(.semibold))
|
||
.foregroundStyle(.secondary)
|
||
Text(
|
||
PagetaStrings.detailContentNotLoadedBody
|
||
)
|
||
.font(.callout)
|
||
.foregroundStyle(PagetaTheme.foreground)
|
||
.fixedSize(horizontal: false, vertical: true)
|
||
}
|
||
|
||
HStack(spacing: 12) {
|
||
Button {
|
||
Task { await extractContent(force: true) }
|
||
} label: {
|
||
Label(PagetaStrings.detailReprocess, systemImage: "arrow.clockwise")
|
||
.font(.callout.weight(.medium))
|
||
}
|
||
.buttonStyle(.borderedProminent)
|
||
.tint(PagetaTheme.primary)
|
||
.disabled(isExtracting)
|
||
|
||
if let url = URL(string: article.originalUrl) {
|
||
Link(destination: url) {
|
||
Label(PagetaStrings.actionOpenInBrowser, systemImage: "safari")
|
||
.font(.callout.weight(.medium))
|
||
}
|
||
}
|
||
}
|
||
}
|
||
.padding(12)
|
||
.frame(maxWidth: .infinity, alignment: .leading)
|
||
.background(PagetaTheme.surfaceHover, in: RoundedRectangle(cornerRadius: 10))
|
||
}
|
||
|
||
/// Tag-Pills mit Remove-Action. Read-back kam mit der Article-DTO-
|
||
/// Erweiterung 2026-05-22 — vorher konnte Native Tags nur schreiben,
|
||
/// nicht anzeigen.
|
||
private func tagsSection(for article: Article) -> some View {
|
||
VStack(alignment: .leading, spacing: 8) {
|
||
Label(PagetaStrings.tagsLabel, systemImage: "tag")
|
||
.font(.caption.weight(.semibold))
|
||
.foregroundStyle(.secondary)
|
||
// Horizontal-Scroll statt Wrap, weil iOS 16 keinen built-in
|
||
// FlowLayout hat — bei vielen Tags scrollt der User sideways.
|
||
// Bei 1-5 Tags (typischer Fall) passt eh alles in eine Zeile.
|
||
ScrollView(.horizontal, showsIndicators: false) {
|
||
HStack(spacing: 6) {
|
||
ForEach(article.tags, id: \.self) { tag in
|
||
HStack(spacing: 4) {
|
||
Text(tag)
|
||
.font(.caption)
|
||
.foregroundStyle(PagetaTheme.foreground)
|
||
Button {
|
||
Task { await removeTag(tag, from: article) }
|
||
} label: {
|
||
Image(systemName: "xmark.circle.fill")
|
||
.font(.caption2)
|
||
.foregroundStyle(.tertiary)
|
||
}
|
||
.buttonStyle(.plain)
|
||
.accessibilityLabel("Tag entfernen")
|
||
}
|
||
.padding(.horizontal, 10)
|
||
.padding(.vertical, 4)
|
||
.background(
|
||
Capsule().fill(PagetaTheme.surfaceHover)
|
||
)
|
||
.overlay(
|
||
Capsule().stroke(PagetaTheme.foreground.opacity(0.12), lineWidth: 0.5)
|
||
)
|
||
}
|
||
}
|
||
}
|
||
}
|
||
.padding(12)
|
||
.frame(maxWidth: .infinity, alignment: .leading)
|
||
.background(PagetaTheme.surfaceHover.opacity(0.4), in: RoundedRectangle(cornerRadius: 8))
|
||
}
|
||
|
||
private var highlightsSection: some View {
|
||
VStack(alignment: .leading, spacing: 8) {
|
||
Label(PagetaStrings.detailMarkerCount(highlights.count), systemImage: "highlighter")
|
||
.font(.caption.weight(.semibold))
|
||
.foregroundStyle(.secondary)
|
||
ForEach(highlights) { highlight in
|
||
highlightRow(highlight)
|
||
}
|
||
}
|
||
.padding(12)
|
||
.background(PagetaTheme.surfaceHover, in: RoundedRectangle(cornerRadius: 8))
|
||
}
|
||
|
||
private func highlightRow(_ highlight: Highlight) -> some View {
|
||
VStack(alignment: .leading, spacing: 6) {
|
||
HStack(alignment: .top, spacing: 8) {
|
||
colorMenu(for: highlight) {
|
||
RoundedRectangle(cornerRadius: 2)
|
||
.fill(highlight.color.swiftUIColor)
|
||
.frame(width: 4)
|
||
}
|
||
.buttonStyle(.plain)
|
||
Text(highlight.text)
|
||
.font(.callout)
|
||
.foregroundStyle(PagetaTheme.foreground)
|
||
.lineLimit(3)
|
||
Spacer()
|
||
Button {
|
||
editingNoteFor = highlight
|
||
} label: {
|
||
Image(systemName: highlight.note == nil ? "note.text.badge.plus" : "note.text")
|
||
.foregroundStyle(highlight.note == nil ? Color.secondary : PagetaTheme
|
||
.primary)
|
||
}
|
||
.buttonStyle(.plain)
|
||
.accessibilityLabel(highlight.note == nil ? PagetaStrings.noteAdd : PagetaStrings.noteEdit)
|
||
Button {
|
||
Task { await deleteHighlight(highlight) }
|
||
} label: {
|
||
Image(systemName: "xmark.circle.fill")
|
||
.foregroundStyle(.tertiary)
|
||
}
|
||
.buttonStyle(.plain)
|
||
.accessibilityLabel(PagetaStrings.markerDelete)
|
||
}
|
||
if let note = highlight.note, !note.isEmpty {
|
||
HStack(alignment: .top, spacing: 6) {
|
||
Image(systemName: "note.text")
|
||
.font(.caption2)
|
||
.foregroundStyle(.secondary)
|
||
Text(note)
|
||
.font(.footnote)
|
||
.foregroundStyle(PagetaTheme.foreground.opacity(0.85))
|
||
}
|
||
.padding(.leading, 12)
|
||
}
|
||
}
|
||
.padding(8)
|
||
.background(
|
||
highlight.color.swiftUIColor.opacity(0.15),
|
||
in: RoundedRectangle(cornerRadius: 6)
|
||
)
|
||
}
|
||
|
||
private var footerActions: some View {
|
||
VStack(spacing: 12) {
|
||
Divider()
|
||
.padding(.vertical, 4)
|
||
|
||
HStack(spacing: 10) {
|
||
readToggleButton
|
||
|
||
Button {
|
||
Task { await archiveAndDismiss() }
|
||
} label: {
|
||
Label(PagetaStrings.actionArchive, systemImage: "archivebox")
|
||
.font(.callout.weight(.medium))
|
||
.frame(maxWidth: .infinity)
|
||
}
|
||
.buttonStyle(.bordered)
|
||
.tint(PagetaTheme.primary)
|
||
}
|
||
|
||
HStack(spacing: 10) {
|
||
Button {
|
||
if let prev = previousSibling { jumpTo(prev) }
|
||
} label: {
|
||
HStack(spacing: 6) {
|
||
Image(systemName: "chevron.left")
|
||
VStack(alignment: .leading, spacing: 2) {
|
||
Text("Vorheriger")
|
||
.font(.caption2)
|
||
.foregroundStyle(.secondary)
|
||
Text(previousSibling?.displayTitle ?? "—")
|
||
.font(.footnote.weight(.medium))
|
||
.foregroundStyle(PagetaTheme.foreground)
|
||
.lineLimit(1)
|
||
.truncationMode(.tail)
|
||
}
|
||
Spacer(minLength: 0)
|
||
}
|
||
.padding(10)
|
||
.frame(maxWidth: .infinity, alignment: .leading)
|
||
.background(PagetaTheme.surfaceHover, in: RoundedRectangle(cornerRadius: 8))
|
||
}
|
||
.buttonStyle(.plain)
|
||
.disabled(previousSibling == nil)
|
||
.opacity(previousSibling == nil ? 0.4 : 1)
|
||
|
||
Button {
|
||
if let next = nextSibling { jumpTo(next) }
|
||
} label: {
|
||
HStack(spacing: 6) {
|
||
Spacer(minLength: 0)
|
||
VStack(alignment: .trailing, spacing: 2) {
|
||
Text(PagetaStrings.detailNext)
|
||
.font(.caption2)
|
||
.foregroundStyle(.secondary)
|
||
Text(nextSibling?.displayTitle ?? "—")
|
||
.font(.footnote.weight(.medium))
|
||
.foregroundStyle(PagetaTheme.foreground)
|
||
.lineLimit(1)
|
||
.truncationMode(.tail)
|
||
}
|
||
Image(systemName: "chevron.right")
|
||
}
|
||
.padding(10)
|
||
.frame(maxWidth: .infinity, alignment: .trailing)
|
||
.background(PagetaTheme.surfaceHover, in: RoundedRectangle(cornerRadius: 8))
|
||
}
|
||
.buttonStyle(.plain)
|
||
.disabled(nextSibling == nil)
|
||
.opacity(nextSibling == nil ? 0.4 : 1)
|
||
}
|
||
}
|
||
.padding(.top, 12)
|
||
}
|
||
|
||
private func icon(for status: ArticleStatus) -> String {
|
||
switch status {
|
||
case .unread: "circle"
|
||
case .reading: "book"
|
||
case .finished: "checkmark.circle"
|
||
case .archived: "archivebox"
|
||
}
|
||
}
|
||
|
||
private var tagSheet: some View {
|
||
NavigationStack {
|
||
Form {
|
||
Section("Tag") {
|
||
TextField(PagetaStrings.detailTagPlaceholder, text: $tagInput)
|
||
.autocorrectionDisabled()
|
||
#if os(iOS)
|
||
.textInputAutocapitalization(.never)
|
||
#endif
|
||
}
|
||
Section {
|
||
Text(
|
||
PagetaStrings.detailTagsHint
|
||
)
|
||
.font(.caption)
|
||
.foregroundStyle(.secondary)
|
||
}
|
||
}
|
||
.navigationTitle(PagetaStrings.detailAddTag)
|
||
#if os(iOS)
|
||
.navigationBarTitleDisplayMode(.inline)
|
||
#endif
|
||
.toolbar {
|
||
ToolbarItem(placement: .cancellationAction) {
|
||
Button(PagetaStrings.actionCancel) { showTagSheet = false }
|
||
}
|
||
ToolbarItem(placement: .confirmationAction) {
|
||
Button(PagetaStrings.actionSave) {
|
||
Task { await addTag(tagInput) }
|
||
}
|
||
.disabled(tagInput.trimmingCharacters(in: .whitespaces).isEmpty)
|
||
}
|
||
}
|
||
}
|
||
}
|
||
|
||
// MARK: - Actions
|
||
|
||
private func load() async {
|
||
loadState = .loading
|
||
// Extraktions-Status für den (evtl. neuen) Artikel zurücksetzen.
|
||
extractionError = nil
|
||
isExtracting = false
|
||
do {
|
||
let fetched = try await api.fetchArticle(id: articleId)
|
||
article = fetched
|
||
// Fortschritts-Tracking für diesen Artikel scharf machen:
|
||
// Balken auf gespeicherten Stand, Restore neu freischalten.
|
||
progress.fraction = fetched.readingProgress
|
||
didRestoreScroll = false
|
||
loadState = .loaded
|
||
if fetched.status == .unread {
|
||
try? await api.updateArticle(id: fetched.id, status: .reading)
|
||
article?.status = .reading
|
||
}
|
||
await loadHighlights()
|
||
await loadSiblings()
|
||
// Geteilter Link ohne Inhalt: einmal aktiv nachladen, statt nur
|
||
// passiv den Hinweis zu zeigen. Genau hier steht der Nutzer.
|
||
if fetched.content.isEmpty {
|
||
await extractContent(force: false)
|
||
}
|
||
} catch {
|
||
loadState = .error(error.localizedDescription)
|
||
}
|
||
}
|
||
|
||
/// Lädt alle nicht-archivierten Artikel für Vorheriger/Nächster-
|
||
/// Navigation. Sortier-Reihenfolge passt zu LeseListeTabView:
|
||
/// Lesend → Ungelesen → Gelesen, innerhalb jeweils savedAt desc.
|
||
/// Reine Local-Replay-Operation, kein Netzwerk.
|
||
private func loadSiblings() async {
|
||
do {
|
||
let all = try await api.fetchArticles(limit: 500)
|
||
let visible = all.filter { $0.status != .archived }
|
||
let reading = visible.filter { $0.status == .reading }
|
||
let unread = visible.filter { $0.status == .unread }
|
||
let finished = visible.filter { $0.status == .finished }
|
||
siblings = reading + unread + finished
|
||
} catch {
|
||
siblings = []
|
||
}
|
||
}
|
||
|
||
private var currentSiblingIndex: Int? {
|
||
siblings.firstIndex(where: { $0.id == articleId })
|
||
}
|
||
|
||
private var previousSibling: Article? {
|
||
guard let idx = currentSiblingIndex, idx > 0 else { return nil }
|
||
return siblings[idx - 1]
|
||
}
|
||
|
||
private var nextSibling: Article? {
|
||
guard let idx = currentSiblingIndex, idx + 1 < siblings.count else { return nil }
|
||
return siblings[idx + 1]
|
||
}
|
||
|
||
/// Wechselt zu einem Geschwister-Artikel ohne den Navigation-Stack
|
||
/// zu erweitern — wir ersetzen einfach den articleId-State und das
|
||
/// `.task(id:)` lädt neu.
|
||
private func jumpTo(_ sibling: Article) {
|
||
// Ausstehenden Fortschritt des aktuellen Artikels noch wegschreiben,
|
||
// bevor wir die Identität wechseln.
|
||
flushProgressNow()
|
||
// Offene Suche zeigt sonst auf Ranges des alten Artikels.
|
||
closeSearch()
|
||
articleId = sibling.id
|
||
article = nil
|
||
highlights = []
|
||
progress.fraction = 0
|
||
didRestoreScroll = false
|
||
}
|
||
|
||
/// Toggle-Button "Gelesen" ↔ "Wieder lesen" mit Fortschritts-Füllung.
|
||
/// In eigene View ausgelagert (liest `fraction` selbst), damit Scroll-
|
||
/// Updates nicht den ganzen Reader-Body neu auswerten.
|
||
private var readToggleButton: some View {
|
||
ReadProgressButton(
|
||
progress: progress,
|
||
isFinished: article?.status == .finished,
|
||
onToggle: { Task { await toggleFinished() } }
|
||
)
|
||
}
|
||
|
||
/// Toggle zwischen .finished und .reading. Lokal optimistic, kein
|
||
/// Dismiss — User bleibt im Reader und kann weiterscrollen, im
|
||
/// Vorheriger/Nächster navigieren oder per System-Back zur Liste.
|
||
private func toggleFinished() async {
|
||
guard let current = article else { return }
|
||
let newStatus: ArticleStatus = (current.status == .finished) ? .reading : .finished
|
||
do {
|
||
try await api.updateArticle(id: current.id, status: newStatus)
|
||
article?.status = newStatus
|
||
// Siblings-Cache mitziehen, damit Prev/Next die Liste in der
|
||
// gleichen Reihenfolge zeigt, falls sich die Sektion ändert.
|
||
if let idx = siblings.firstIndex(where: { $0.id == current.id }) {
|
||
siblings[idx].status = newStatus
|
||
}
|
||
} catch {
|
||
writeError = writeErrorMessage(error)
|
||
}
|
||
}
|
||
|
||
/// Archiviert und navigiert zurück zur Liste.
|
||
private func archiveAndDismiss() async {
|
||
guard let article else { return }
|
||
do {
|
||
try await api.updateArticle(id: article.id, status: .archived)
|
||
dismiss()
|
||
} catch {
|
||
writeError = writeErrorMessage(error)
|
||
}
|
||
}
|
||
|
||
private func loadHighlights() async {
|
||
do {
|
||
highlights = try await api.fetchHighlights(articleId: articleId)
|
||
} catch {
|
||
// Highlights sind sekundär — beim Fehler nichts zeigen,
|
||
// nicht den ganzen Reader verbarrikadieren.
|
||
highlights = []
|
||
}
|
||
}
|
||
|
||
private func setStatus(_ status: ArticleStatus, for article: Article) async {
|
||
do {
|
||
try await api.updateArticle(id: article.id, status: status)
|
||
self.article?.status = status
|
||
if status == .archived {
|
||
dismiss()
|
||
}
|
||
} catch {
|
||
writeError = writeErrorMessage(error)
|
||
}
|
||
}
|
||
|
||
/// Local-First-Markieren (E-4.5): schreibt sofort lokal — ohne Login.
|
||
/// Die `EventSyncEngine` stempelt eine anonyme Identität; beim späteren
|
||
/// Login liftet der stille Claim (`PagetaNativeApp` → `claimOnSignIn`)
|
||
/// die Markierung transparent in den Account. Kein Login-Gate mehr.
|
||
private func requestHighlight(text: String, color: HighlightColor) {
|
||
Task { await addHighlight(text: text, color: color) }
|
||
}
|
||
|
||
private func addHighlight(text: String, color: HighlightColor) async {
|
||
do {
|
||
try await api.addHighlight(articleId: articleId, text: text, color: color)
|
||
await loadHighlights()
|
||
} catch {
|
||
// Fallback-Sichtbarkeit: Schreibfehler, die nicht schon vom
|
||
// Login-Gate abgefangen wurden (z.B. abgelaufene Session, die
|
||
// noch `.signedIn` meldet, oder Server-/Netzfehler). Vorher
|
||
// landete das in `loadState`, dessen UI aber nur bei `article
|
||
// == nil` rendert → stiller Fehlschlag.
|
||
writeError = writeErrorMessage(error)
|
||
}
|
||
}
|
||
|
||
/// Übersetzt einen Schreibfehler in eine nutzersichtbare Meldung für den
|
||
/// `writeError`-Alert. Neutral formuliert, weil derselbe Helfer alle
|
||
/// mutierenden Pfade bedient (Markierung, Notiz, Status, Tags).
|
||
private func writeErrorMessage(_ error: Error) -> String {
|
||
if let syncError = error as? PagetaSyncError {
|
||
switch syncError {
|
||
case .notAuthenticated:
|
||
return PagetaStrings.detailErrorSessionExpired
|
||
case .eventSyncUnavailable:
|
||
return PagetaStrings.detailErrorSyncNotReady
|
||
default:
|
||
return syncError.localizedDescription
|
||
}
|
||
}
|
||
if let authError = error as? AuthError, case .notSignedIn = authError {
|
||
return PagetaStrings.detailErrorNotSignedIn
|
||
}
|
||
return error.localizedDescription
|
||
}
|
||
|
||
private func deleteHighlight(_ highlight: Highlight) async {
|
||
highlights.removeAll { $0.id == highlight.id }
|
||
do {
|
||
try await api.deleteHighlight(id: highlight.id)
|
||
} catch {
|
||
// Re-Fetch nach Fehler (Optimistic-Update-Rollback).
|
||
await loadHighlights()
|
||
}
|
||
}
|
||
|
||
private func saveNote(for highlight: Highlight, newNote: String?) async {
|
||
do {
|
||
try await api.setHighlightNote(id: highlight.id, note: newNote)
|
||
if let idx = highlights.firstIndex(where: { $0.id == highlight.id }) {
|
||
highlights[idx].note = newNote
|
||
}
|
||
} catch {
|
||
// `loadState` rendert nur bei `article == nil` → wäre hier stiller
|
||
// Fehlschlag. Über `writeError` (gleicher Alert wie addHighlight)
|
||
// sieht der User, dass die Notiz nicht gespeichert wurde.
|
||
writeError = writeErrorMessage(error)
|
||
}
|
||
}
|
||
|
||
private func setColor(for highlight: Highlight, to color: HighlightColor) async {
|
||
do {
|
||
try await api.setHighlightColor(id: highlight.id, color: color)
|
||
if let idx = highlights.firstIndex(where: { $0.id == highlight.id }) {
|
||
highlights[idx].color = color
|
||
}
|
||
} catch {
|
||
// Siehe saveNote: sichtbarer Alert statt stummem loadState-Fehler.
|
||
writeError = writeErrorMessage(error)
|
||
}
|
||
}
|
||
|
||
/// Wiederverwendbares Tap-Target rund um den Farb-Strich — Tap öffnet
|
||
/// ein Menu mit den 4 Farben. Tap auf einen Eintrag setzt die Farbe.
|
||
private func colorMenu(
|
||
for highlight: Highlight,
|
||
@ViewBuilder label: () -> some View
|
||
) -> some View {
|
||
Menu {
|
||
ForEach(HighlightColor.allCases) { color in
|
||
Button {
|
||
Task { await setColor(for: highlight, to: color) }
|
||
} label: {
|
||
Label(color.label, systemImage: color == highlight.color ? "checkmark" : "")
|
||
}
|
||
}
|
||
} label: {
|
||
label()
|
||
}
|
||
}
|
||
|
||
private func addTag(_ tag: String) async {
|
||
let trimmed = tag.trimmingCharacters(in: .whitespaces)
|
||
guard !trimmed.isEmpty else { return }
|
||
showTagSheet = false
|
||
do {
|
||
try await api.addTag(articleId: articleId, tag: trimmed)
|
||
// Optimistic — Article-State refresh aus dem event-sync
|
||
// (Reducer hat schon gefeuert beim emit, daher synchron lesbar).
|
||
await refreshArticleState()
|
||
} catch {
|
||
writeError = writeErrorMessage(error)
|
||
}
|
||
}
|
||
|
||
private func dismissWarning(for article: Article) async {
|
||
do {
|
||
try await api.dismissArticleWarning(articleId: article.id)
|
||
await refreshArticleState()
|
||
} catch {
|
||
writeError = writeErrorMessage(error)
|
||
}
|
||
}
|
||
|
||
private func removeTag(_ tag: String, from article: Article) async {
|
||
let normalized = tag.trimmingCharacters(in: .whitespacesAndNewlines).lowercased()
|
||
let remaining = article.tags.filter { $0 != normalized }
|
||
do {
|
||
try await api.setArticleTags(articleId: article.id, tags: remaining)
|
||
await refreshArticleState()
|
||
} catch {
|
||
writeError = writeErrorMessage(error)
|
||
}
|
||
}
|
||
|
||
/// Liest die Article-Projection nach einem mutierenden Event neu
|
||
/// aus dem event-sync und ersetzt `self.article`. Optimistic-Path
|
||
/// fürs Tag-UI — Reducer hat schon synchron im emit gefeuert.
|
||
private func refreshArticleState() async {
|
||
do {
|
||
let updated = try await api.fetchArticle(id: articleId)
|
||
article = updated
|
||
} catch {
|
||
// Stale-Article ist tolerierbar — nächster Load holt's nach.
|
||
}
|
||
}
|
||
|
||
// MARK: - Inhalts-Extraktion
|
||
|
||
/// Versucht, den Artikel-Text nachzuladen (`enrichArticle`).
|
||
/// - `force == true` (Retry-Button): hebt die Session-Sperre auf und
|
||
/// ignoriert den Einmal-Guard.
|
||
/// - `force == false` (Auto-Versuch aus `load()`): nur eingeloggt und nur
|
||
/// einmal pro Artikel, damit eine dauerhaft leere/blockierte Quelle
|
||
/// keine Schleife auslöst.
|
||
private func extractContent(force: Bool) async {
|
||
guard let article, article.content.isEmpty,
|
||
let url = URL(string: article.originalUrl)
|
||
else { return }
|
||
if !force {
|
||
guard case .signedIn = auth.status else { return }
|
||
guard !autoExtractTried.contains(article.id) else { return }
|
||
}
|
||
autoExtractTried.insert(article.id)
|
||
if force { await api.clearEnrichmentFailure(articleId: article.id) }
|
||
isExtracting = true
|
||
extractionError = nil
|
||
do {
|
||
try await api.enrichArticle(articleId: article.id, url: url)
|
||
await refreshArticleState()
|
||
} catch {
|
||
extractionError = extractionErrorMessage(error)
|
||
}
|
||
isExtracting = false
|
||
}
|
||
|
||
/// Übersetzt einen Extraktions-Fehler in eine nutzersichtbare Meldung.
|
||
private func extractionErrorMessage(_ error: Error) -> String {
|
||
if let syncError = error as? PagetaSyncError {
|
||
switch syncError {
|
||
case .extractionEmpty:
|
||
return syncError.errorDescription ?? PagetaStrings.detailErrorNoText
|
||
case .notAuthenticated:
|
||
return PagetaStrings.detailErrorSignInToFetch
|
||
default:
|
||
return syncError.localizedDescription
|
||
}
|
||
}
|
||
if let authError = error as? AuthError, case .notSignedIn = authError {
|
||
return PagetaStrings.detailErrorSignInToFetch
|
||
}
|
||
// HTTP/Netz (z. B. 502 extraction-failed vom Server, Timeout).
|
||
return PagetaStrings.detailErrorFetchFailed
|
||
}
|
||
|
||
// MARK: - Lese-Fortschritt
|
||
|
||
/// Reagiert auf jede Scroll-Geometrie-Änderung. Zwei Phasen:
|
||
/// 1. **Restore** (einmalig): sobald das Layout eine echte `range` kennt,
|
||
/// auf die gespeicherte Position springen — danach nie wieder, sonst
|
||
/// kämpfen Restore und Tracking gegeneinander (gleiche Logik wie der
|
||
/// `restoredFor`-Guard im Web).
|
||
/// 2. **Tracking**: signifikante Änderungen (≥2 % oder am Ende) live in den
|
||
/// Balken spiegeln und debounced syncen.
|
||
private func handleScroll(fraction: Double, range: CGFloat) {
|
||
guard let article else { return }
|
||
|
||
if !didRestoreScroll {
|
||
// Erst springen, wenn die Range steht — sonst landet der Restore
|
||
// bei y=0 und überschreibt den gespeicherten Fortschritt mit ~0.
|
||
guard range > 0 else { return }
|
||
didRestoreScroll = true
|
||
let saved = article.readingProgress
|
||
if saved > 0.02 {
|
||
progress.fraction = saved
|
||
scrollPosition.scrollTo(y: saved * range)
|
||
return
|
||
}
|
||
}
|
||
|
||
// Bis ans Ende gescrollt → automatisch als gelesen markieren. Der
|
||
// „Gelesen"-Button läuft über die Fortschritts-Fraction sichtbar voll.
|
||
if fraction >= 0.98, article.status != .finished {
|
||
markFinishedFromScroll()
|
||
}
|
||
|
||
let significant = abs(fraction - progress.fraction) >= 0.02 || fraction >= 0.98
|
||
guard significant else { return }
|
||
progress.fraction = fraction
|
||
scheduleProgressFlush(fraction)
|
||
}
|
||
|
||
/// Setzt den Status optimistic auf `.finished`, wenn ans Ende gescrollt
|
||
/// wurde. Der synchrone Status-Set vor dem await blockt Re-Entry, falls
|
||
/// `handleScroll` im 0,98..1-Bereich mehrfach feuert (gleiche Logik wie
|
||
/// `autoFinish` im Web).
|
||
private func markFinishedFromScroll() {
|
||
guard article?.status != .finished else { return }
|
||
article?.status = .finished
|
||
if let idx = siblings.firstIndex(where: { $0.id == articleId }) {
|
||
siblings[idx].status = .finished
|
||
}
|
||
Task {
|
||
do {
|
||
try await api.updateArticle(id: articleId, status: .finished)
|
||
} catch {
|
||
// Sekundär — kein Alert; nächstes Scroll-Ende versucht es erneut.
|
||
}
|
||
}
|
||
}
|
||
|
||
/// Debounced Flush — 1,5 s nach der letzten Scroll-Bewegung, wie im Web.
|
||
/// Verhindert, dass jedes Scroll-Tick ein Event nach sync2.mana.how schickt.
|
||
private func scheduleProgressFlush(_ fraction: Double) {
|
||
progressFlushTask?.cancel()
|
||
progressFlushTask = Task {
|
||
try? await Task.sleep(for: .milliseconds(1500))
|
||
if Task.isCancelled { return }
|
||
await flushProgress(fraction)
|
||
}
|
||
}
|
||
|
||
/// Schreibt beim Verlassen den letzten Stand sofort (kein Debounce) —
|
||
/// Pendant zum `beforeunload`-Flush im Web.
|
||
private func flushProgressNow() {
|
||
progressFlushTask?.cancel()
|
||
let value = progress.fraction
|
||
guard let current = article, value != current.readingProgress else { return }
|
||
Task { await flushProgress(value) }
|
||
}
|
||
|
||
private func flushProgress(_ fraction: Double) async {
|
||
guard let id = article?.id else { return }
|
||
do {
|
||
try await api.updateArticle(id: id, readingProgress: fraction)
|
||
// Lokale Kopie nachziehen, damit flushProgressNow nicht doppelt sendet.
|
||
article?.readingProgress = fraction
|
||
} catch {
|
||
// Fortschritt ist sekundär — kein Alert; der nächste Scroll
|
||
// (oder das Verlassen) versucht es erneut.
|
||
}
|
||
}
|
||
}
|
||
|
||
/// Beobachtbarer Live-Lesefortschritt (0..1). Eigenes `@Observable`-Model
|
||
/// statt `@State Double` auf der Reader-View: so invalidiert eine Scroll-
|
||
/// Aktualisierung NUR die Views, die `fraction` lesen (Balken + Button) —
|
||
/// nicht den ganzen Reader-Body mit der teuren ArticleBodyTextView-Brücke.
|
||
/// Das war die Ursache des Scroll-Ruckelns.
|
||
@Observable final class ReadingProgressModel {
|
||
var fraction: Double = 0
|
||
}
|
||
|
||
/// Hält Scroll-Offset + Body-Position für die Scroll-zu-Treffer-Mathematik
|
||
/// der In-Artikel-Suche. Bewusst KEINE `@Observable`-Klasse: die häufigen
|
||
/// Scroll-Updates (jeder Tick setzt `scrollY`) sollen den teuren Reader-Body
|
||
/// NICHT invalidieren — gelesen wird nur imperativ in `focusMatch`.
|
||
final class ReaderScrollTracker {
|
||
/// Aktueller vertikaler Scroll-Offset (`contentOffset.y`).
|
||
var scrollY: CGFloat = 0
|
||
/// Body-Oberkante im `readerSpace` (= `bodyAbsoluteTop − scrollY`).
|
||
var bodyMinY: CGFloat = 0
|
||
}
|
||
|
||
/// Dünner Lese-Fortschritts-Balken oben über dem Reader. Liest `fraction`
|
||
/// selbst → beim Scrollen rendert nur diese kleine View neu.
|
||
private struct ReadingProgressBar: View {
|
||
let progress: ReadingProgressModel
|
||
|
||
var body: some View {
|
||
GeometryReader { geo in
|
||
Rectangle()
|
||
.fill(PagetaTheme.primary)
|
||
.frame(width: geo.size.width * progress.fraction, height: 2)
|
||
}
|
||
.frame(height: 2)
|
||
.allowsHitTesting(false)
|
||
}
|
||
}
|
||
|
||
/// „Gelesen" ↔ „Wieder lesen"-Button mit Fortschritts-Füllung. Liest
|
||
/// `fraction` selbst → Scroll-Updates rendern nur diesen Button neu, nicht
|
||
/// den Reader-Body. `isFinished`/`onToggle` reicht die Parent-View durch.
|
||
private struct ReadProgressButton: View {
|
||
let progress: ReadingProgressModel
|
||
let isFinished: Bool
|
||
let onToggle: () -> Void
|
||
|
||
var body: some View {
|
||
let fill = isFinished ? 1 : progress.fraction
|
||
Button(action: onToggle) {
|
||
Label(
|
||
isFinished ? PagetaStrings.detailReadAgain : PagetaStrings.statusFinished,
|
||
systemImage: isFinished ? "arrow.uturn.left.circle" : "checkmark.circle.fill"
|
||
)
|
||
.font(.callout.weight(.medium))
|
||
.foregroundStyle(isFinished ? Color.white : PagetaTheme.foreground)
|
||
.frame(maxWidth: .infinity)
|
||
.padding(.vertical, 8)
|
||
.background(alignment: .leading) {
|
||
GeometryReader { geo in
|
||
Rectangle()
|
||
.fill(PagetaTheme.primary.opacity(isFinished ? 1 : 0.28))
|
||
.frame(width: geo.size.width * fill)
|
||
.animation(.easeOut(duration: 0.3), value: fill)
|
||
}
|
||
}
|
||
.background(PagetaTheme.surfaceHover)
|
||
.clipShape(RoundedRectangle(cornerRadius: 8))
|
||
.overlay(
|
||
RoundedRectangle(cornerRadius: 8)
|
||
.stroke(PagetaTheme.primary.opacity(0.5), lineWidth: 1)
|
||
)
|
||
}
|
||
.buttonStyle(.plain)
|
||
}
|
||
}
|
||
|
||
/// Misst die intrinsische Größe der Inline-Palette, damit der Parent sie
|
||
/// mittig über der Auswahl platzieren kann.
|
||
private struct PaletteSizeKey: PreferenceKey {
|
||
static let defaultValue: CGSize = .zero
|
||
static func reduce(value: inout CGSize, nextValue: () -> CGSize) {
|
||
let next = nextValue()
|
||
if next != .zero { value = next }
|
||
}
|
||
}
|