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>
346 lines
9.5 KiB
Swift
346 lines
9.5 KiB
Swift
import ManaAuthUI
|
|
import ManaComponentsUI
|
|
import ManaCore
|
|
import SwiftUI
|
|
|
|
/// Globale Sammlung aller Highlights über alle Artikel hinweg.
|
|
///
|
|
/// **Layout-Entscheidung 2026-05-21:** flat sortiert nach `createdAt`
|
|
/// desc (jüngste zuerst). Keine Gruppierung by Article — Till will
|
|
/// "was hab ich zuletzt markiert" als primärer Use-Case. Artikel-
|
|
/// Kontext (Titel, Site) steht pro Row als Untertitel.
|
|
///
|
|
/// Tap auf eine Row navigiert in die `ArticleDetailView` des
|
|
/// zugehörigen Artikels. Swipe-Delete entfernt nur den Highlight,
|
|
/// nicht den Artikel.
|
|
struct MarkierungenTabView: View {
|
|
/// Profil + Tap-Handler für den Konto-Avatar oben rechts (von
|
|
/// ``RootView`` gereicht). Nur iOS — auf macOS liegt das Konto in der
|
|
/// Settings-Scene (⌘,). Defaults für den macOS-Sidebar-Aufruf.
|
|
var accountProfile: ProfileInfo?
|
|
var onAccountTap: () -> Void = {}
|
|
|
|
@Environment(AuthClient.self) private var auth
|
|
@Environment(PendingShareSignal.self) private var shareSignal
|
|
private var api: PagetaAPI {
|
|
PagetaAPI(auth: auth)
|
|
}
|
|
|
|
@State private var items: [HighlightWithArticle] = []
|
|
@State private var loadState: LoadState = .idle
|
|
@State private var editingNoteFor: HighlightWithArticle?
|
|
@State private var writeError: String?
|
|
|
|
enum LoadState: Equatable {
|
|
case idle
|
|
case loading
|
|
case loaded
|
|
case error(String)
|
|
}
|
|
|
|
var body: some View {
|
|
NavigationStack {
|
|
Group {
|
|
if items.isEmpty {
|
|
emptyOrLoadingView
|
|
} else {
|
|
listView
|
|
}
|
|
}
|
|
.navigationTitle(PagetaStrings.screenHighlightsTitle)
|
|
#if os(iOS)
|
|
.manaAccountToolbar(auth: auth, profile: accountProfile, onTap: onAccountTap)
|
|
#endif
|
|
.navigationDestination(for: String.self) { articleId in
|
|
ArticleDetailView(articleId: articleId)
|
|
}
|
|
.alert(
|
|
PagetaStrings.stateActionFailed,
|
|
isPresented: Binding(
|
|
get: { writeError != nil },
|
|
set: { if !$0 { writeError = nil } }
|
|
),
|
|
presenting: writeError
|
|
) { _ in
|
|
Button(PagetaStrings.actionOk, role: .cancel) { writeError = nil }
|
|
} message: { msg in
|
|
Text(msg)
|
|
}
|
|
.paperBackground()
|
|
.task {
|
|
if items.isEmpty { await load() }
|
|
}
|
|
.onAppear {
|
|
// Reload bei Re-Appear (gleiche Logik wie LeseListe) —
|
|
// fängt z.B. Highlight-Löschungen aus dem Detail-View ab.
|
|
if !items.isEmpty {
|
|
Task { await load() }
|
|
}
|
|
}
|
|
.refreshable { await load() }
|
|
.onChange(of: shareSignal.flushedTick) { _, _ in
|
|
// Wenn ein frisch gespeicherter Artikel via ShareExt
|
|
// reinkommt + der User direkt aus dem Web Highlights
|
|
// gesetzt hat (Cross-Device-Sync), kann sich die Liste
|
|
// ändern. Billiger Re-Load.
|
|
Task { await load() }
|
|
}
|
|
}
|
|
}
|
|
|
|
@ViewBuilder
|
|
private var emptyOrLoadingView: some View {
|
|
switch loadState {
|
|
case .idle, .loading:
|
|
ProgressView()
|
|
.frame(maxWidth: .infinity, maxHeight: .infinity)
|
|
case let .error(message):
|
|
ContentUnavailableView(
|
|
PagetaStrings.markerLoadError,
|
|
systemImage: "exclamationmark.triangle",
|
|
description: Text(message)
|
|
)
|
|
case .loaded:
|
|
ContentUnavailableView(
|
|
PagetaStrings.markerEmptyTitle,
|
|
systemImage: "highlighter",
|
|
description: Text(
|
|
PagetaStrings.markerEmptyBody
|
|
)
|
|
)
|
|
}
|
|
}
|
|
|
|
private var listView: some View {
|
|
List {
|
|
ForEach(items) { item in
|
|
NavigationLink(value: item.highlight.articleId) {
|
|
MarkierungRow(
|
|
item: item,
|
|
onColorChange: { color in
|
|
Task { await setColor(for: item.highlight, to: color) }
|
|
}
|
|
)
|
|
}
|
|
.listRowInsets(EdgeInsets(top: 10, leading: 16, bottom: 10, trailing: 16))
|
|
.swipeActions(edge: .leading) {
|
|
Button {
|
|
editingNoteFor = item
|
|
} label: {
|
|
Label(
|
|
item.highlight.note == nil ? PagetaStrings.noteTitle : PagetaStrings.noteEdit,
|
|
systemImage: "note.text"
|
|
)
|
|
}
|
|
.tint(.indigo)
|
|
}
|
|
.swipeActions(edge: .trailing) {
|
|
Button(role: .destructive) {
|
|
Task { await delete(item.highlight) }
|
|
} label: {
|
|
Label(PagetaStrings.actionDelete, systemImage: "trash")
|
|
}
|
|
}
|
|
.contextMenu {
|
|
highlightContextMenu(item)
|
|
}
|
|
}
|
|
}
|
|
.listStyle(.plain)
|
|
.macReadingWidth()
|
|
.sheet(item: $editingNoteFor) { item in
|
|
HighlightNoteSheet(
|
|
highlightText: item.highlight.text,
|
|
initialNote: item.highlight.note,
|
|
color: item.highlight.color,
|
|
onSave: { newNote in
|
|
await saveNote(for: item.highlight, newNote: newNote)
|
|
}
|
|
)
|
|
}
|
|
}
|
|
|
|
/// Long-Press-Kontextmenü für eine Markierung. Spiegelt die Swipe-
|
|
/// Aktionen (Notiz, Löschen) und ergänzt sie um Kopieren, Farbe und
|
|
/// Teilen — der Tap navigiert weiterhin in den Artikel.
|
|
@ViewBuilder
|
|
private func highlightContextMenu(_ item: HighlightWithArticle) -> some View {
|
|
Button {
|
|
PlatformClipboard.copy(item.highlight.text)
|
|
} label: {
|
|
Label(PagetaStrings.markerCopy, systemImage: "doc.on.doc")
|
|
}
|
|
Button {
|
|
editingNoteFor = item
|
|
} label: {
|
|
Label(
|
|
item.highlight.note == nil ? PagetaStrings.noteAdd : PagetaStrings.noteEdit,
|
|
systemImage: "note.text"
|
|
)
|
|
}
|
|
Menu {
|
|
ForEach(HighlightColor.allCases) { color in
|
|
Button {
|
|
Task { await setColor(for: item.highlight, to: color) }
|
|
} label: {
|
|
Label(
|
|
color.label,
|
|
systemImage: color == item.highlight.color ? "checkmark" : "circle.fill"
|
|
)
|
|
}
|
|
}
|
|
} label: {
|
|
Label(PagetaStrings.markerColor, systemImage: "paintpalette")
|
|
}
|
|
ShareLink(item: highlightShareText(item)) {
|
|
Label(PagetaStrings.actionShare, systemImage: "square.and.arrow.up")
|
|
}
|
|
Divider()
|
|
Button(role: .destructive) {
|
|
Task { await delete(item.highlight) }
|
|
} label: {
|
|
Label(PagetaStrings.actionDelete, systemImage: "trash")
|
|
}
|
|
}
|
|
|
|
/// Markierungs-Text mit Quellen-Attribution für Teilen.
|
|
private func highlightShareText(_ item: HighlightWithArticle) -> String {
|
|
var out = "\u{201C}\(item.highlight.text)\u{201D}"
|
|
if let note = item.highlight.note, !note.isEmpty {
|
|
out += "\n\n\(note)"
|
|
}
|
|
out += "\n— \(item.article.title)"
|
|
return out
|
|
}
|
|
|
|
private func load() async {
|
|
loadState = .loading
|
|
do {
|
|
items = try await api.fetchAllHighlights()
|
|
loadState = .loaded
|
|
} catch {
|
|
loadState = .error(error.localizedDescription)
|
|
}
|
|
}
|
|
|
|
private func delete(_ highlight: Highlight) async {
|
|
do {
|
|
try await api.deleteHighlight(id: highlight.id)
|
|
items.removeAll { $0.highlight.id == highlight.id }
|
|
} catch {
|
|
writeError = error.localizedDescription
|
|
}
|
|
}
|
|
|
|
private func saveNote(for highlight: Highlight, newNote: String?) async {
|
|
do {
|
|
try await api.setHighlightNote(id: highlight.id, note: newNote)
|
|
// Lokal patchen — der Pull holt es später ein, aber UI muss sofort
|
|
// reagieren.
|
|
if let idx = items.firstIndex(where: { $0.highlight.id == highlight.id }) {
|
|
items[idx].highlight.note = newNote
|
|
}
|
|
} catch {
|
|
writeError = error.localizedDescription
|
|
}
|
|
}
|
|
|
|
private func setColor(for highlight: Highlight, to color: HighlightColor) async {
|
|
do {
|
|
try await api.setHighlightColor(id: highlight.id, color: color)
|
|
if let idx = items.firstIndex(where: { $0.highlight.id == highlight.id }) {
|
|
items[idx].highlight.color = color
|
|
}
|
|
} catch {
|
|
writeError = error.localizedDescription
|
|
}
|
|
}
|
|
}
|
|
|
|
/// Eine Markierung in der Liste — Farb-Strich links, Highlight-Text,
|
|
/// Artikel-Kontext + Datum als Untertitel.
|
|
struct MarkierungRow: View {
|
|
let item: HighlightWithArticle
|
|
let onColorChange: (HighlightColor) -> Void
|
|
|
|
private var subtitle: String {
|
|
var parts: [String] = []
|
|
parts.append(item.article.title)
|
|
if let site = item.article.siteName, !site.isEmpty {
|
|
parts.append(site)
|
|
}
|
|
if let date = formattedDate(item.highlight.createdAt) {
|
|
parts.append(date)
|
|
}
|
|
return parts.joined(separator: " · ")
|
|
}
|
|
|
|
var body: some View {
|
|
HStack(alignment: .top, spacing: 10) {
|
|
Menu {
|
|
ForEach(HighlightColor.allCases) { color in
|
|
Button {
|
|
onColorChange(color)
|
|
} label: {
|
|
Label(
|
|
color.label,
|
|
systemImage: color == item.highlight.color ? "checkmark" : ""
|
|
)
|
|
}
|
|
}
|
|
} label: {
|
|
RoundedRectangle(cornerRadius: 2)
|
|
.fill(item.highlight.color.swiftUIColor)
|
|
.frame(width: 4)
|
|
}
|
|
.buttonStyle(.plain)
|
|
.menuStyle(.borderlessButton)
|
|
VStack(alignment: .leading, spacing: 4) {
|
|
Text(item.highlight.text)
|
|
.font(.callout)
|
|
.foregroundStyle(PagetaTheme.foreground)
|
|
.lineLimit(4)
|
|
if let note = item.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))
|
|
.lineLimit(3)
|
|
}
|
|
.padding(.top, 2)
|
|
}
|
|
Text(subtitle)
|
|
.font(.caption)
|
|
.foregroundStyle(.secondary)
|
|
.lineLimit(2)
|
|
}
|
|
}
|
|
.padding(.vertical, 4)
|
|
}
|
|
|
|
/// `createdAt` ist ISO8601 vom Server. Auf "vor 3 Stunden" /
|
|
/// "21.05.2026"-Format mappen.
|
|
private func formattedDate(_ iso: String) -> String? {
|
|
let formatter = ISO8601DateFormatter()
|
|
formatter.formatOptions = [.withInternetDateTime, .withFractionalSeconds]
|
|
var date = formatter.date(from: iso)
|
|
if date == nil {
|
|
formatter.formatOptions = [.withInternetDateTime]
|
|
date = formatter.date(from: iso)
|
|
}
|
|
guard let date else { return nil }
|
|
let relative = RelativeDateTimeFormatter()
|
|
relative.unitsStyle = .short
|
|
// Über 7 Tage absolut anzeigen — "vor 3 Wochen" hilft niemandem.
|
|
if abs(date.timeIntervalSinceNow) > 7 * 24 * 3600 {
|
|
let abs = DateFormatter()
|
|
abs.dateStyle = .medium
|
|
abs.timeStyle = .none
|
|
return abs.string(from: date)
|
|
}
|
|
return relative.localizedString(for: date, relativeTo: Date())
|
|
}
|
|
}
|